feat(app): add error/loading boundaries across all dashboard routes and new routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes - Add admin elective detail and components, files, course-plans, users, scheduling boundaries - Add messages group-compose route - Add parent course-plans, elective, grades report-card, practice routes - Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries - Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar - Add auth loading, onboarding loading, api cron
This commit is contained in:
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function TeacherAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
|
||||
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getAttendanceRecords } from "@/modules/attendance/data-access"
|
||||
@@ -36,6 +37,7 @@ export default async function TeacherAttendancePage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const ctx = await getAuthContext()
|
||||
const t = await getTranslations("attendance")
|
||||
|
||||
@@ -55,12 +57,11 @@ export default async function TeacherAttendancePage({
|
||||
])
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
// 分页计算
|
||||
// 分页计算:使用后端返回的 total/totalPages,避免基于截断数据计算
|
||||
const { page } = computePagination(sp, PAGE_SIZE)
|
||||
const total = result.items.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const total = result.total
|
||||
const totalPages = result.totalPages
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE)
|
||||
const hasFilters = Boolean(classId || status || date)
|
||||
|
||||
const header = (
|
||||
@@ -103,7 +104,7 @@ export default async function TeacherAttendancePage({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<AttendanceRecordList records={pagedRecords} />
|
||||
<AttendanceRecordList records={result.items} />
|
||||
{total > 0 ? (
|
||||
<ListPagination
|
||||
page={currentPage}
|
||||
|
||||
7
src/app/(dashboard)/teacher/attendance/report/error.tsx
Normal file
7
src/app/(dashboard)/teacher/attendance/report/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function AttendanceReportError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
35
src/app/(dashboard)/teacher/attendance/report/loading.tsx
Normal file
35
src/app/(dashboard)/teacher/attendance/report/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
src/app/(dashboard)/teacher/attendance/report/page.tsx
Normal file
77
src/app/(dashboard)/teacher/attendance/report/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
||||
import { AttendanceReportPrint } from "@/modules/attendance/components/attendance-report-print"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AttendanceReportPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const t = await getTranslations("attendance")
|
||||
const sp = await searchParams
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const startDate = getParam(sp, "startDate")
|
||||
const endDate = getParam(sp, "endDate")
|
||||
// L-8:报告类型(weekly 默认 / monthly)
|
||||
const reportType = getParam(sp, "reportType") === "monthly" ? "monthly" : "weekly"
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
|
||||
if (classes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("report.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("report.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t("stats.noClasses")}
|
||||
description={t("stats.noClassesDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
|
||||
const summary = await getClassAttendanceStats(
|
||||
targetClassId,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const targetClass = classes.find((c) => c.id === targetClassId)
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("report.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("report.description")}</p>
|
||||
</div>
|
||||
|
||||
<AttendanceReportPrint
|
||||
summary={summary}
|
||||
classes={classOptions}
|
||||
currentClassId={targetClassId}
|
||||
currentClassName={targetClass?.name ?? ""}
|
||||
startDate={startDate ?? ""}
|
||||
endDate={endDate ?? ""}
|
||||
reportType={reportType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
src/app/(dashboard)/teacher/attendance/sheet/error.tsx
Normal file
7
src/app/(dashboard)/teacher/attendance/sheet/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function AttendanceSheetError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
|
||||
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
|
||||
@@ -12,6 +14,7 @@ export default async function AttendanceSheetPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const t = await getTranslations("attendance")
|
||||
const sp = await searchParams
|
||||
|
||||
|
||||
7
src/app/(dashboard)/teacher/attendance/stats/error.tsx
Normal file
7
src/app/(dashboard)/teacher/attendance/stats/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function AttendanceStatsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
|
||||
import { getClassAttendanceStats, getClassAttendanceWarnings, getAttendanceTrend } from "@/modules/attendance/data-access-stats"
|
||||
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
|
||||
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
|
||||
import { AttendanceStatsClassSelector } from "@/modules/attendance/components/attendance-stats-class-selector"
|
||||
import { AttendanceWarningsCard } from "@/modules/attendance/components/attendance-warnings-card"
|
||||
import { AttendanceTrendChart } from "@/modules/attendance/components/attendance-trend-chart"
|
||||
import type { TrendGranularity } from "@/modules/attendance/trend-compute"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { BarChart3, FileText } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -16,12 +23,19 @@ export default async function AttendanceStatsPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const t = await getTranslations("attendance")
|
||||
const sp = await searchParams
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const startDate = getParam(sp, "startDate")
|
||||
const endDate = getParam(sp, "endDate")
|
||||
// L-4:趋势粒度(默认 weekly)
|
||||
const granularityParam = getParam(sp, "granularity")
|
||||
const granularity: TrendGranularity =
|
||||
granularityParam === "daily" || granularityParam === "monthly"
|
||||
? granularityParam
|
||||
: "weekly"
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
|
||||
@@ -44,19 +58,28 @@ export default async function AttendanceStatsPage({
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
|
||||
const summary = await getClassAttendanceStats(
|
||||
targetClassId,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
// 并行获取统计、预警、趋势三个独立查询
|
||||
const [summary, warnings, trend] = await Promise.all([
|
||||
getClassAttendanceStats(targetClassId, startDate, endDate),
|
||||
getClassAttendanceWarnings(targetClassId, startDate, endDate),
|
||||
getAttendanceTrend(targetClassId, granularity, startDate, endDate),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherStats")}</h1>
|
||||
<p className="text-muted-foreground">{t("description.teacherStats")}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherStats")}</h1>
|
||||
<p className="text-muted-foreground">{t("description.teacherStats")}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/teacher/attendance/report?classId=${targetClassId}${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{t("report.title")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AttendanceStatsClassSelector
|
||||
@@ -69,6 +92,8 @@ export default async function AttendanceStatsPage({
|
||||
{summary ? (
|
||||
<>
|
||||
<AttendanceStatsCard stats={summary.stats} />
|
||||
<AttendanceWarningsCard summary={warnings} />
|
||||
<AttendanceTrendChart summary={trend} granularity={granularity} />
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">{t("stats.studentRecords")}</h2>
|
||||
<AttendanceRecordList records={summary.studentRecords} />
|
||||
|
||||
13
src/app/(dashboard)/teacher/classes/my/[id]/error.tsx
Normal file
13
src/app/(dashboard)/teacher/classes/my/[id]/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function ClassDetailError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="classes" />
|
||||
}
|
||||
41
src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx
Normal file
41
src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
||||
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
||||
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
|
||||
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
|
||||
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
|
||||
@@ -13,6 +16,14 @@ import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/c
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.classDetail")} - Next_Edu`,
|
||||
description: t("metadata.classDetail"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ClassDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -66,45 +77,47 @@ export default async function ClassDetailPage({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-muted/10">
|
||||
<ClassHeader
|
||||
classId={insights.class.id}
|
||||
name={insights.class.name}
|
||||
grade={insights.class.grade}
|
||||
homeroom={insights.class.homeroom}
|
||||
room={insights.class.room}
|
||||
schoolName={insights.class.schoolName}
|
||||
studentCount={insights.studentCounts.total}
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-6 p-6">
|
||||
{/* Key Metrics */}
|
||||
<ClassOverviewStats
|
||||
averageScore={insights.overallScores.avg}
|
||||
submissionRate={totalSubmissionRate * 100}
|
||||
papersToGrade={papersToGrade}
|
||||
overdueCount={overdueCount}
|
||||
<ClassErrorBoundary>
|
||||
<ClassHeader
|
||||
classId={insights.class.id}
|
||||
name={insights.class.name}
|
||||
grade={insights.class.grade}
|
||||
homeroom={insights.class.homeroom}
|
||||
room={insights.class.room}
|
||||
schoolName={insights.class.schoolName}
|
||||
studentCount={insights.studentCounts.total}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content Area (Left 2/3) */}
|
||||
<div className="min-w-0 space-y-6 lg:col-span-2">
|
||||
<ClassTrendsWidget assignments={assignmentSummaries} />
|
||||
<ClassStudentsWidget
|
||||
classId={insights.class.id}
|
||||
students={studentSummaries}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-6 p-6">
|
||||
{/* Key Metrics */}
|
||||
<ClassOverviewStats
|
||||
averageScore={insights.overallScores.avg}
|
||||
submissionRate={totalSubmissionRate * 100}
|
||||
papersToGrade={papersToGrade}
|
||||
overdueCount={overdueCount}
|
||||
/>
|
||||
|
||||
{/* Sidebar Area (Right 1/3) */}
|
||||
<div className="min-w-0 space-y-6">
|
||||
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
|
||||
<ClassAssignmentsWidget
|
||||
classId={insights.class.id}
|
||||
assignments={assignmentSummaries}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content Area (Left 2/3) */}
|
||||
<div className="min-w-0 space-y-6 lg:col-span-2">
|
||||
<ClassTrendsWidget assignments={assignmentSummaries} />
|
||||
<ClassStudentsWidget
|
||||
classId={insights.class.id}
|
||||
students={studentSummaries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Area (Right 1/3) */}
|
||||
<div className="min-w-0 space-y-6">
|
||||
<ClassScheduleWidget classId={insights.class.id} schedule={schedule} />
|
||||
<ClassAssignmentsWidget
|
||||
classId={insights.class.id}
|
||||
assignments={assignmentSummaries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClassErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
13
src/app/(dashboard)/teacher/classes/my/error.tsx
Normal file
13
src/app/(dashboard)/teacher/classes/my/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function MyClassesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="classes" />
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
|
||||
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.myClasses")} - Next_Edu`,
|
||||
description: t("metadata.myClasses"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function MyClassesPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-8">
|
||||
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
|
||||
<ClassErrorBoundary>
|
||||
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
|
||||
</ClassErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
13
src/app/(dashboard)/teacher/classes/schedule/error.tsx
Normal file
13
src/app/(dashboard)/teacher/classes/schedule/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function ScheduleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="classes" />
|
||||
}
|
||||
@@ -1,21 +1,33 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { Calendar } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
|
||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
||||
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.schedule")} - Next_Edu`,
|
||||
description: t("metadata.schedule"),
|
||||
}
|
||||
}
|
||||
|
||||
async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const classId = getParam(params, "classId")
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const classes = await getTeacherClasses()
|
||||
const schedule = await getClassSchedule({
|
||||
@@ -28,9 +40,9 @@ async function ScheduleResults({ searchParams }: { searchParams: Promise<SearchP
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={hasFilters ? "No schedule for this class" : "No schedule available"}
|
||||
description={hasFilters ? "Try selecting another class." : "Your class schedule has not been set up yet."}
|
||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/schedule" } : undefined}
|
||||
title={hasFilters ? t("schedule.empty.noMatch") : t("schedule.empty.title")}
|
||||
description={hasFilters ? t("schedule.empty.noMatchDescription") : t("schedule.empty.description")}
|
||||
action={hasFilters ? { label: t("filters.reset"), href: "/teacher/classes/schedule" } : undefined}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
@@ -66,13 +78,15 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-6">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ScheduleFilters classes={classes} />
|
||||
</Suspense>
|
||||
<ClassErrorBoundary>
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ScheduleFilters classes={classes} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ScheduleResultsFallback />}>
|
||||
<ScheduleResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<ScheduleResultsFallback />}>
|
||||
<ScheduleResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</ClassErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
13
src/app/(dashboard)/teacher/classes/students/error.tsx
Normal file
13
src/app/(dashboard)/teacher/classes/students/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function StudentsError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="classes" />
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { User } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
|
||||
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
||||
import { StudentsTable } from "@/modules/classes/components/students-table"
|
||||
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.students")} - Next_Edu`,
|
||||
description: t("metadata.students"),
|
||||
}
|
||||
}
|
||||
|
||||
async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise<SearchParams>, defaultClassId?: string }): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const t = await getTranslations("classes")
|
||||
|
||||
const q = getParam(params, "q") || undefined
|
||||
const classId = getParam(params, "classId")
|
||||
@@ -25,7 +37,7 @@ async function StudentsResults({ searchParams, defaultClassId }: { searchParams:
|
||||
// However, the requirement is "Default to showing the first class".
|
||||
// If classId param is missing, we use defaultClassId.
|
||||
const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId
|
||||
|
||||
|
||||
const filteredStudents = await getClassStudents({
|
||||
q,
|
||||
classId: targetClassId,
|
||||
@@ -47,9 +59,9 @@ async function StudentsResults({ searchParams, defaultClassId }: { searchParams:
|
||||
return (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title={hasFilters ? "No students match your filters" : "No students found"}
|
||||
description={hasFilters ? "Try clearing filters or adjusting keywords." : "There are no students in your classes yet."}
|
||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/classes/students" } : undefined}
|
||||
title={hasFilters ? t("students.empty.noMatch") : t("students.empty.title")}
|
||||
description={hasFilters ? t("students.empty.noMatchDescription") : t("students.empty.description")}
|
||||
action={hasFilters ? { label: t("filters.reset"), href: "/teacher/classes/students" } : undefined}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
@@ -80,20 +92,22 @@ function StudentsResultsFallback() {
|
||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const classes = await getTeacherClasses()
|
||||
|
||||
|
||||
// Logic to determine default class (first one available)
|
||||
const defaultClassId = classes.length > 0 ? classes[0].id : undefined
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-8">
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
<ClassErrorBoundary>
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<StudentsResultsFallback />}>
|
||||
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<StudentsResultsFallback />}>
|
||||
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
|
||||
</Suspense>
|
||||
</ClassErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
20
src/app/(dashboard)/teacher/course-plans/[id]/error.tsx
Normal file
20
src/app/(dashboard)/teacher/course-plans/[id]/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherCoursePlanDetailError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherCoursePlanDetailLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-[240px]" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-[80px]" />
|
||||
<Skeleton className="h-9 w-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,9 +13,13 @@ export default async function TeacherCoursePlanDetailPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
// P0-1/P0-3:教师视角仅能查看自己负责的计划,避免信息泄露
|
||||
const plan = await getCoursePlanById(
|
||||
id,
|
||||
{ userId: ctx.userId, isAdmin: false, teacherId: ctx.userId }
|
||||
)
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
@@ -24,6 +28,9 @@ export default async function TeacherCoursePlanDetailPage({
|
||||
<CoursePlanDetail
|
||||
plan={plan}
|
||||
backHref="/teacher/course-plans"
|
||||
successHref="/teacher/course-plans"
|
||||
textbooksHref="/teacher/textbooks"
|
||||
homeworkHref="/teacher/homework"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
20
src/app/(dashboard)/teacher/course-plans/error.tsx
Normal file
20
src/app/(dashboard)/teacher/course-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherCoursePlansError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/app/(dashboard)/teacher/course-plans/loading.tsx
Normal file
21
src/app/(dashboard)/teacher/course-plans/loading.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherCoursePlansLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,15 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||
import { isCoursePlanStatus } from "@/modules/course-plans/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const VALID_STATUSES: ReadonlySet<string> = new Set([
|
||||
"planning",
|
||||
"active",
|
||||
"completed",
|
||||
"paused",
|
||||
])
|
||||
|
||||
function parseStatus(v?: string): CoursePlanStatus | undefined {
|
||||
return v && VALID_STATUSES.has(v) ? (v as CoursePlanStatus) : undefined
|
||||
}
|
||||
|
||||
export default async function TeacherCoursePlansPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -27,21 +18,25 @@ export default async function TeacherCoursePlansPage({
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const teacherId = ctx.userId
|
||||
|
||||
const t = await getTranslations("coursePlans")
|
||||
const sp = await searchParams
|
||||
const statusParam = getParam(sp, "status")
|
||||
const status = parseStatus(statusParam)
|
||||
// P1-4:使用类型守卫替代 as 断言
|
||||
const status = isCoursePlanStatus(statusParam) ? statusParam : undefined
|
||||
|
||||
// P0-3:data-access 按教师范围过滤
|
||||
const plans = teacherId
|
||||
? await getCoursePlans({ teacherId, status })
|
||||
? await getCoursePlans(
|
||||
{ teacherId, status },
|
||||
{ userId: teacherId, isAdmin: false, teacherId }
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">My Course Plans</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View your course teaching plans and weekly schedules.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function DiagnosticClassError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("diagnostic")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="班级学情诊断加载失败"
|
||||
description="抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。"
|
||||
title={t("error.classLoadFailed")}
|
||||
description={t("error.classLoadFailedDesc")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
import {
|
||||
DiagnosticServiceProvider,
|
||||
} from "@/modules/diagnostic/services/diagnostic-service-context"
|
||||
import { defaultDiagnosticService } from "@/modules/diagnostic/services/default-diagnostic-service"
|
||||
import { createMonitoredDiagnosticService } from "@/modules/diagnostic/services/monitored-diagnostic-service"
|
||||
import {
|
||||
DiagnosticMonitorProvider,
|
||||
} from "@/modules/diagnostic/services/diagnostic-monitor-context"
|
||||
import { noopDiagnosticMonitor } from "@/modules/diagnostic/services/diagnostic-monitor"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -16,6 +26,7 @@ export default async function ClassDiagnosticPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const { classId } = await params
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("diagnostic")
|
||||
|
||||
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
|
||||
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
|
||||
@@ -31,19 +42,29 @@ export default async function ClassDiagnosticPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
// v2-P2-7: 包装默认服务以添加监控埋点(默认 no-op,生产环境可注入真实实现)
|
||||
const monitoredService = createMonitoredDiagnosticService(
|
||||
defaultDiagnosticService,
|
||||
noopDiagnosticMonitor,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" aria-hidden="true" />
|
||||
Class Diagnostic
|
||||
{t("title.teacherClass")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Class-level knowledge point mastery overview and student attention list.
|
||||
{t("title.teacherClassDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<WidgetBoundary title="班级学情诊断" skeletonHeight={400}>
|
||||
<ClassDiagnosticView summary={summary} />
|
||||
<WidgetBoundary title={t("title.teacherClass")} skeletonHeight={400}>
|
||||
<DiagnosticMonitorProvider monitor={noopDiagnosticMonitor}>
|
||||
<DiagnosticServiceProvider service={monitoredService}>
|
||||
<ClassDiagnosticView summary={summary} />
|
||||
</DiagnosticServiceProvider>
|
||||
</DiagnosticMonitorProvider>
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function TeacherDiagnosticError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("diagnostic")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学情诊断页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("error.pageTitle")}
|
||||
description={t("error.pageDescription")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { ReportList } from "@/modules/diagnostic/components/report-list"
|
||||
import {
|
||||
DiagnosticServiceProvider,
|
||||
} from "@/modules/diagnostic/services/diagnostic-service-context"
|
||||
import { defaultDiagnosticService } from "@/modules/diagnostic/services/default-diagnostic-service"
|
||||
import { createMonitoredDiagnosticService } from "@/modules/diagnostic/services/monitored-diagnostic-service"
|
||||
import {
|
||||
DiagnosticMonitorProvider,
|
||||
} from "@/modules/diagnostic/services/diagnostic-monitor-context"
|
||||
import { noopDiagnosticMonitor } from "@/modules/diagnostic/services/diagnostic-monitor"
|
||||
import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -20,12 +30,12 @@ const VALID_REPORT_STATUSES: ReadonlySet<string> = new Set([
|
||||
"archived",
|
||||
])
|
||||
|
||||
function parseReportType(v?: string): DiagnosticReportType | undefined {
|
||||
return v && VALID_REPORT_TYPES.has(v) ? (v as DiagnosticReportType) : undefined
|
||||
function isReportType(v: string): v is DiagnosticReportType {
|
||||
return VALID_REPORT_TYPES.has(v)
|
||||
}
|
||||
|
||||
function parseReportStatus(v?: string): DiagnosticReportStatus | undefined {
|
||||
return v && VALID_REPORT_STATUSES.has(v) ? (v as DiagnosticReportStatus) : undefined
|
||||
function isReportStatus(v: string): v is DiagnosticReportStatus {
|
||||
return VALID_REPORT_STATUSES.has(v)
|
||||
}
|
||||
|
||||
export default async function TeacherDiagnosticPage({
|
||||
@@ -35,33 +45,44 @@ export default async function TeacherDiagnosticPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("diagnostic")
|
||||
|
||||
const reportType = getParam(sp, "reportType")
|
||||
const status = getParam(sp, "status")
|
||||
const reportTypeParam = getParam(sp, "reportType")
|
||||
const statusParam = getParam(sp, "status")
|
||||
|
||||
const reports = await getDiagnosticReports(
|
||||
{
|
||||
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
|
||||
status: status && status !== "all" ? parseReportStatus(status) : undefined,
|
||||
},
|
||||
ctx.dataScope,
|
||||
const reportType =
|
||||
reportTypeParam && reportTypeParam !== "all" && isReportType(reportTypeParam)
|
||||
? reportTypeParam
|
||||
: undefined
|
||||
const status =
|
||||
statusParam && statusParam !== "all" && isReportStatus(statusParam)
|
||||
? statusParam
|
||||
: undefined
|
||||
|
||||
const reports = await getDiagnosticReports({ reportType, status }, ctx.dataScope)
|
||||
|
||||
// v2-P2-2: 移除客户端 filter,DataScope 过滤已在 data-access 层完成
|
||||
// class_members scope 的学生已通过 filters.studentId 在 data-access 层过滤
|
||||
|
||||
// v2-P2-7: 包装默认服务以添加监控埋点(默认 no-op,生产环境可注入真实实现)
|
||||
const monitoredService = createMonitoredDiagnosticService(
|
||||
defaultDiagnosticService,
|
||||
noopDiagnosticMonitor,
|
||||
)
|
||||
|
||||
// 学生角色仅查看自己的报告;其他角色查看全部
|
||||
const visibleReports =
|
||||
ctx.dataScope.type === "class_members"
|
||||
? reports.reports.filter((r) => r.studentId === ctx.userId)
|
||||
: reports.reports
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Learning Diagnostic</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherReportList")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View and manage diagnostic reports based on knowledge point mastery.
|
||||
{t("title.teacherReportListDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<ReportList reports={visibleReports} />
|
||||
<DiagnosticMonitorProvider monitor={noopDiagnosticMonitor}>
|
||||
<DiagnosticServiceProvider service={monitoredService}>
|
||||
<ReportList reports={reports.reports} />
|
||||
</DiagnosticServiceProvider>
|
||||
</DiagnosticMonitorProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function DiagnosticStudentError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("diagnostic")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学生学情诊断加载失败"
|
||||
description="抱歉,加载学生诊断数据时发生了意外错误。请稍后重试。"
|
||||
title={t("error.studentLoadFailed")}
|
||||
description={t("error.studentLoadFailedDesc")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -22,6 +23,7 @@ export default async function StudentDiagnosticPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const { studentId } = await params
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("diagnostic")
|
||||
|
||||
// DataScope 二次校验:学生只能看自己,家长只能看子女
|
||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||
@@ -67,18 +69,18 @@ export default async function StudentDiagnosticPage({
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" aria-hidden="true" />
|
||||
Student Diagnostic
|
||||
{t("title.teacherStudent")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Knowledge point mastery analysis and diagnostic reports.
|
||||
{t("title.teacherStudentDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<WidgetBoundary title="学生学情诊断" skeletonHeight={400}>
|
||||
<WidgetBoundary title={t("title.teacherStudent")} skeletonHeight={400}>
|
||||
<StudentDiagnosticView
|
||||
summary={summary}
|
||||
reports={reports}
|
||||
classAverageMastery={classAverageMastery}
|
||||
practiceHrefBase="/teacher/questions"
|
||||
role="teacher"
|
||||
/>
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
|
||||
27
src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx
Normal file
52
src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getElectiveCourseById } from "@/modules/elective/data-access"
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherEditElectiveCoursePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("elective")
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const { id } = await params
|
||||
|
||||
const [course, subjects, grades, teachers] = await Promise.all([
|
||||
getElectiveCourseById(id),
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
getStaffOptions(),
|
||||
])
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
// 教师只能编辑自己教授的课程(防止跨教师越权编辑)
|
||||
if (course.teacherId && course.teacherId !== ctx.userId) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title.edit")}</h2>
|
||||
<p className="text-muted-foreground">{t("description.edit")}</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="edit"
|
||||
course={course}
|
||||
subjects={subjects}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))}
|
||||
backHref="/teacher/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/elective/create/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/elective/create/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/app/(dashboard)/teacher/elective/create/page.tsx
Normal file
36
src/app/(dashboard)/teacher/elective/create/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherCreateElectiveCoursePage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("elective")
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
|
||||
const [subjects, grades, teachers] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
getGrades(),
|
||||
getStaffOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title.create")}</h2>
|
||||
<p className="text-muted-foreground">{t("description.create")}</p>
|
||||
</div>
|
||||
<ElectiveCourseForm
|
||||
mode="create"
|
||||
subjects={subjects}
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))}
|
||||
backHref="/teacher/elective"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,10 @@ export default function TeacherElectiveError({ reset }: { error: Error & { diges
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
title={t("errors.title")}
|
||||
description={t("errors.description")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -50,8 +50,8 @@ export default async function TeacherElectivePage({
|
||||
<ElectiveCourseList
|
||||
courses={courses}
|
||||
canManage
|
||||
createHref="/admin/elective/create"
|
||||
editBaseHref="/admin/elective"
|
||||
createHref="/teacher/elective/create"
|
||||
editBaseHref="/teacher/elective"
|
||||
/>
|
||||
</ElectivePageLayout>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access"
|
||||
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
getSubjectErrorOverviews,
|
||||
getClassErrorOverviews,
|
||||
getChapterWeakness,
|
||||
} from "@/modules/error-book/data-access"
|
||||
} from "@/modules/error-book/data-access-analytics"
|
||||
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
|
||||
import { ClassFilter } from "@/modules/error-book/components/class-filter"
|
||||
@@ -34,6 +36,7 @@ async function TeacherErrorBookContent({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("errorBook")
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)
|
||||
|
||||
const params = await searchParams
|
||||
@@ -45,13 +48,13 @@ async function TeacherErrorBookContent({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<p className="text-muted-foreground">按学科、班级查看学生的错题统计与薄弱知识点。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.descriptionShort")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无可查看的班级"
|
||||
description="您还未被分配到任何班级,无法查看错题分析数据。"
|
||||
title={t("teacher.noClass")}
|
||||
description={t("teacher.noClassDesc")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -72,13 +75,13 @@ async function TeacherErrorBookContent({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<p className="text-muted-foreground">按学科、班级查看学生的错题统计与薄弱知识点。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("teacher.descriptionShort")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="班级暂无学生"
|
||||
description="班级中没有学生,无法查看错题分析数据。"
|
||||
title={t("teacher.noStudent")}
|
||||
description={t("teacher.noStudentDesc")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -128,9 +131,9 @@ async function TeacherErrorBookContent({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("teacher.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
按学科、班级查看学生的错题统计与薄弱知识点,辅助精准教学。
|
||||
{t("teacher.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -155,71 +158,86 @@ async function TeacherErrorBookContent({
|
||||
) : null}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<AnalyticsStatsCards
|
||||
totalStudents={queryStudentIds.length}
|
||||
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||
totalErrorItems={totalErrorItems}
|
||||
averageMasteryRate={averageMasteryRate}
|
||||
dueReviewCount={totalDueReview}
|
||||
knowledgePointCount={knowledgePointCount}
|
||||
/>
|
||||
<WidgetBoundary title={t("teacher.title")} skeletonHeight={120}>
|
||||
<AnalyticsStatsCards
|
||||
totalStudents={queryStudentIds.length}
|
||||
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||
totalErrorItems={totalErrorItems}
|
||||
averageMasteryRate={averageMasteryRate}
|
||||
dueReviewCount={totalDueReview}
|
||||
knowledgePointCount={knowledgePointCount}
|
||||
/>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}
|
||||
{effectiveClassId === "all" && classOverviews.length > 1 ? (
|
||||
<ClassErrorBarChart data={classOverviews} />
|
||||
<WidgetBoundary title={t("teacher.subjectDist")} skeletonHeight={300}>
|
||||
<ClassErrorBarChart data={classOverviews} />
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
|
||||
{/* 章节错题分布 + 知识点薄弱度(并排) */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{chapterWeakness.length > 0 ? (
|
||||
<ChapterWeaknessChart data={chapterWeakness} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无章节错题数据"
|
||||
description="尚未关联知识点到章节,无法显示章节维度统计。"
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
{weakKps.length > 0 ? (
|
||||
<KnowledgePointWeaknessChart data={weakKps} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无知识点数据"
|
||||
description="错题尚未关联知识点,无法显示薄弱知识点统计。"
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
<WidgetBoundary title={t("teacher.weakPoints")} skeletonHeight={300}>
|
||||
{chapterWeakness.length > 0 ? (
|
||||
<ChapterWeaknessChart data={chapterWeakness} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noChapterDataTitle")}
|
||||
description={t("teacher.noChapterDataDesc")}
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title={t("teacher.weakPoints")} skeletonHeight={300}>
|
||||
{weakKps.length > 0 ? (
|
||||
<KnowledgePointWeaknessChart data={weakKps} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noKpDataTitle")}
|
||||
description={t("teacher.noKpDataDesc")}
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
|
||||
{/* 学生错题详情(按班级分组) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">学生错题详情</h2>
|
||||
<h2 className="text-lg font-semibold">{t("teacher.studentDetail")}</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
共 {queryStudentIds.length} 名学生,{studentsWithErrorBook.length} 名有错题
|
||||
{t("teacher.studentsCount", {
|
||||
total: queryStudentIds.length,
|
||||
withErrors: studentsWithErrorBook.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{sortedSummaries.length > 0 ? (
|
||||
<GroupedStudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/teacher/error-book"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无学生错题"
|
||||
description="所选范围内没有学生错题数据。"
|
||||
className="h-[200px] bg-card"
|
||||
/>
|
||||
)}
|
||||
<WidgetBoundary title={t("teacher.studentDetail")} skeletonHeight={300}>
|
||||
{sortedSummaries.length > 0 ? (
|
||||
<GroupedStudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/teacher/error-book"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noStudentErrorsTitle")}
|
||||
description={t("teacher.noStudentErrorsDesc")}
|
||||
className="h-[200px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
|
||||
{/* 高频错题 Top 10 */}
|
||||
{topWrongQuestions.length > 0 ? (
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
<WidgetBoundary title={t("teacher.topWrong")} skeletonHeight={300}>
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ExamAnalyticsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.analytics.title")}
|
||||
description={t("exam.analytics.noData")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx
Normal file
22
src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
</div>
|
||||
<Skeleton className="h-[420px] w-full" />
|
||||
<Skeleton className="h-[360px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import { BarChart3, ArrowLeft } from "lucide-react"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getExamAnalytics } from "@/modules/exams/stats-service"
|
||||
import { ExamAnalyticsDashboard } from "@/modules/exams/components/exam-analytics-dashboard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ExamAnalyticsPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
const t = await getTranslations("examHomework")
|
||||
|
||||
const [exam, analytics] = await Promise.all([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
@@ -9,40 +10,14 @@ import type { ExamNode } from "@/modules/exams/components/assembly/selected-ques
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const t = await getTranslations("examHomework.exam.build")
|
||||
|
||||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||||
const exam = await getExamById(id, ctx.dataScope)
|
||||
@@ -101,14 +76,14 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}))
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
const aiClientService = createCoreAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
|
||||
<p className="text-muted-foreground">Assemble questions for your exam.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/[id]/edit-rich/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/edit-rich/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function EditRichExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.error.loadFailed")}
|
||||
description={t("exam.error.notFound")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx
Normal file
16
src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx
Normal file
80
src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
|
||||
// 直接从具体文件导入纯转换函数和类型,绕过 editor/index.ts barrel,
|
||||
// 避免 Server Component 连带加载 @tiptap/react 客户端扩展(会触发
|
||||
// "Class extends value undefined is not a constructor or null" 错误)。
|
||||
import { examNodesToEditorDoc } from "@/modules/exams/editor/exam-nodes-to-editor-doc"
|
||||
import { structureToEditorDoc } from "@/modules/exams/editor/structure-to-editor"
|
||||
import type { EditorJSONContent } from "@/modules/exams/editor/exam-rich-editor-types"
|
||||
import { ExamRichForm } from "@/modules/exams/components/exam-rich-form"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditRichExamPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const t = await getTranslations("examHomework")
|
||||
|
||||
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
|
||||
const exam = await getExamById(id, ctx.dataScope)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// 加载 exam 关联的题目(含 content)
|
||||
const selectedQuestionIds = (exam.questions || []).map((q) => q.id)
|
||||
const selectedResult = selectedQuestionIds.length > 0
|
||||
? await getQuestions({
|
||||
ids: selectedQuestionIds,
|
||||
pageSize: Math.max(10, selectedQuestionIds.length),
|
||||
})
|
||||
: { data: [] as Awaited<ReturnType<typeof getQuestions>>["data"] }
|
||||
|
||||
type RawQuestion = (typeof selectedResult.data)[number]
|
||||
const toQuestion = (q: RawQuestion): Question => ({
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? 1,
|
||||
createdAt: new Date(q.createdAt),
|
||||
updatedAt: new Date(q.updatedAt),
|
||||
author: q.author
|
||||
? {
|
||||
id: q.author.id,
|
||||
name: q.author.name || "Unknown",
|
||||
image: q.author.image || null,
|
||||
}
|
||||
: null,
|
||||
knowledgePoints: q.knowledgePoints ?? [],
|
||||
})
|
||||
|
||||
const questions = selectedResult.data.map(toQuestion)
|
||||
|
||||
// 把 ExamNode[] + questions 转为 EditorDoc,再转为 Tiptap JSON
|
||||
const examNodes = normalizeStructure(exam.structure)
|
||||
const editorDoc = examNodesToEditorDoc(examNodes, questions, exam.title)
|
||||
const initialContent = structureToEditorDoc(editorDoc) as EditorJSONContent
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("exam.richEditor.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("exam.richEditor.description")}</p>
|
||||
</div>
|
||||
<ExamRichForm
|
||||
mode="edit"
|
||||
examId={id}
|
||||
initialTitle={exam.title}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard"
|
||||
@@ -18,13 +19,14 @@ export default async function ExamProctoringPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("examHomework.proctoring.page")
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_PROCTOR)
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
您没有监考权限(exam:proctor)。
|
||||
{t("noPermission")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,8 +53,8 @@ export default async function ExamProctoringPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exam Proctoring</h1>
|
||||
<p className="text-muted-foreground">Monitor student activity during the exam.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<ProctoringDashboard examId={id} initialData={initialData} />
|
||||
</div>
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/all/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/all/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AllExamsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.error.loadFailed")}
|
||||
description={t("exam.error.notFound")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,13 +9,14 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
|
||||
import { ExamFilters } from "@/modules/exams/components/exam-filters"
|
||||
import { getExams } from "@/modules/exams/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { FileText, PlusCircle } from "lucide-react"
|
||||
|
||||
async function ExamsResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const { dataScope } = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||||
const t = await getTranslations("examHomework")
|
||||
|
||||
const q = getParam(params, "q")
|
||||
@@ -26,7 +27,7 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
|
||||
q,
|
||||
status,
|
||||
difficulty,
|
||||
scope: dataScope,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
|
||||
const hasFilters = Boolean(q || (status && status !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/create/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/create/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function CreateExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.form.createFailed")}
|
||||
description={t("exam.form.loadFormFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateExamPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.EXAM_CREATE)
|
||||
const t = await getTranslations("examHomework")
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/new/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/new/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function NewExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.form.createFailed")}
|
||||
description={t("exam.form.loadFormFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/teacher/exams/new/loading.tsx
Normal file
16
src/app/(dashboard)/teacher/exams/new/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ExamRichForm } from "@/modules/exams/components/exam-rich-form"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function NewExamPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.EXAM_CREATE)
|
||||
const t = await getTranslations("examHomework")
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { BarChart3, ArrowLeft } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -12,7 +13,7 @@ import { getGrades } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
getClassComparisonWithSignificance,
|
||||
getExamOptionsForGrades,
|
||||
getGradeDistribution,
|
||||
getGradeTrend,
|
||||
@@ -23,7 +24,9 @@ import { ClassComparisonChart } from "@/modules/grades/components/class-comparis
|
||||
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
||||
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
import { KnowledgePointMasteryChart } from "@/modules/grades/components/knowledge-point-mastery-chart"
|
||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -34,6 +37,7 @@ export default async function GradeAnalyticsPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
@@ -52,14 +56,12 @@ export default async function GradeAnalyticsPage({
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">成绩分析</h1>
|
||||
<p className="text-muted-foreground">
|
||||
趋势分析、班级对比与分数分布。
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.analytics")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.analytics.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="暂无班级"
|
||||
description="您还没有任何班级。"
|
||||
title={t("page.analytics.noClassesTitle")}
|
||||
description={t("page.analytics.noClassesDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -77,59 +79,65 @@ export default async function GradeAnalyticsPage({
|
||||
const targetExamId = examId && examId !== "all" ? examId : undefined
|
||||
|
||||
// Run analytics queries in parallel
|
||||
const [trend, distribution, subjectComparison, classComparison, examOptions] =
|
||||
await Promise.all([
|
||||
getGradeTrend({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
semester: targetSemester,
|
||||
examId: targetExamId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getGradeDistribution({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getSubjectComparison({
|
||||
classId: targetClassId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
targetGradeId
|
||||
? getClassComparison({
|
||||
gradeId: targetGradeId,
|
||||
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getExamOptionsForGrades({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
])
|
||||
// P3-3: 集成 diagnostic 模块的知识点掌握度视图(classId 已由 getTeacherClasses 校验在 scope 内)
|
||||
const [
|
||||
trend,
|
||||
distribution,
|
||||
subjectComparison,
|
||||
classComparisonResult,
|
||||
examOptions,
|
||||
classMasterySummary,
|
||||
] = await Promise.all([
|
||||
getGradeTrend({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
semester: targetSemester,
|
||||
examId: targetExamId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getGradeDistribution({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getSubjectComparison({
|
||||
classId: targetClassId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
targetGradeId
|
||||
? getClassComparisonWithSignificance({
|
||||
gradeId: targetGradeId,
|
||||
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
: Promise.resolve({ items: [], significance: null }),
|
||||
getExamOptionsForGrades({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
getClassMasterySummary(targetClassId),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">成绩分析</h1>
|
||||
<p className="text-muted-foreground">
|
||||
趋势分析、班级对比与分数分布。
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.analytics")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.analytics.description")}</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/teacher/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
返回成绩列表
|
||||
{t("page.analytics.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -147,54 +155,63 @@ export default async function GradeAnalyticsPage({
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<WidgetBoundary title="成绩趋势">
|
||||
<WidgetBoundary title={t("page.analytics.trendTitle")}>
|
||||
{trend ? (
|
||||
<GradeTrendChart data={trend} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无趋势数据"
|
||||
description="当前筛选条件下没有可显示的成绩趋势。"
|
||||
title={t("page.analytics.trendEmptyTitle")}
|
||||
description={t("page.analytics.trendEmptyDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="分数分布">
|
||||
<WidgetBoundary title={t("page.analytics.distributionTitle")}>
|
||||
{distribution.totalCount > 0 ? (
|
||||
<GradeDistributionChart data={distribution} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无分布数据"
|
||||
description="当前筛选条件下没有可显示的分数分布。"
|
||||
title={t("page.analytics.distributionEmptyTitle")}
|
||||
description={t("page.analytics.distributionEmptyDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="科目对比">
|
||||
<WidgetBoundary title={t("page.analytics.subjectComparisonTitle")}>
|
||||
{subjectComparison.length > 0 ? (
|
||||
<SubjectComparisonChart data={subjectComparison} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无科目对比数据"
|
||||
description="当前筛选条件下没有可显示的科目对比。"
|
||||
title={t("page.analytics.subjectComparisonEmptyTitle")}
|
||||
description={t("page.analytics.subjectComparisonEmptyDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="班级对比">
|
||||
{classComparison.length > 0 ? (
|
||||
<ClassComparisonChart data={classComparison} />
|
||||
<WidgetBoundary title={t("page.analytics.classComparisonTitle")}>
|
||||
{classComparisonResult.items.length > 0 ? (
|
||||
<ClassComparisonChart
|
||||
data={classComparisonResult.items}
|
||||
significance={classComparisonResult.significance}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无班级对比数据"
|
||||
description="当前筛选条件下没有可显示的班级对比。"
|
||||
title={t("page.analytics.classComparisonEmptyTitle")}
|
||||
description={t("page.analytics.classComparisonEmptyDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title={t("knowledgePointMastery.title")}>
|
||||
<KnowledgePointMasteryChart
|
||||
data={classMasterySummary?.knowledgePointStats ?? []}
|
||||
detailHref="/teacher/diagnostic"
|
||||
/>
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -18,6 +19,7 @@ export default async function BatchEntryPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const sp = await searchParams
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const examId = getParam(sp, "examId")
|
||||
const classId = getParam(sp, "classId")
|
||||
@@ -62,16 +64,14 @@ export default async function BatchEntryPage({
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">批量录入成绩</h1>
|
||||
<p className="text-muted-foreground">
|
||||
从试卷库选择试卷,按每题得分录入,像填 Excel 表格一样。
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("page.entry.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.entry.description")}</p>
|
||||
</div>
|
||||
|
||||
{exams.length === 0 ? (
|
||||
<EmptyState
|
||||
title="没有可用的试卷"
|
||||
description="请先在试卷管理中创建试卷并添加题目,才能录入成绩。"
|
||||
title={t("page.entry.noExamsTitle")}
|
||||
description={t("page.entry.noExamsDescription")}
|
||||
icon={ClipboardList}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -9,15 +11,16 @@ export default function TeacherGradesError({
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("page.error.title")}
|
||||
description={t("page.error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("page.error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
@@ -13,21 +15,11 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
|
||||
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
|
||||
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||
import type { GradeRecordType, GradeRecordSemester } from "@/modules/grades/types"
|
||||
import { ExcelImportDialog } from "@/modules/grades/components/excel-import-dialog"
|
||||
import { isGradeType, isSemester } from "@/modules/grades/lib/type-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const VALID_GRADE_TYPES: ReadonlySet<string> = new Set(["exam", "quiz", "homework", "other"])
|
||||
const VALID_SEMESTERS: ReadonlySet<string> = new Set(["1", "2"])
|
||||
|
||||
function parseGradeType(v?: string): GradeRecordType | undefined {
|
||||
return v && VALID_GRADE_TYPES.has(v) ? (v as GradeRecordType) : undefined
|
||||
}
|
||||
|
||||
function parseSemester(v?: string): GradeRecordSemester | undefined {
|
||||
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default async function TeacherGradesPage({
|
||||
@@ -37,6 +29,7 @@ export default async function TeacherGradesPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
@@ -55,8 +48,8 @@ export default async function TeacherGradesPage({
|
||||
currentUserId: ctx.userId,
|
||||
classId: classId && classId !== "all" ? classId : undefined,
|
||||
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
||||
type: type && type !== "all" ? parseGradeType(type) : undefined,
|
||||
semester: semester && semester !== "all" ? parseSemester(semester) : undefined,
|
||||
type: type && type !== "all" ? isGradeType(type) ? type : undefined : undefined,
|
||||
semester: semester && semester !== "all" ? isSemester(semester) ? semester : undefined : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
@@ -76,20 +69,20 @@ export default async function TeacherGradesPage({
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">成绩记录</h1>
|
||||
<p className="text-muted-foreground">管理学生成绩记录。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.list")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.list.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/grades/stats">
|
||||
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
统计
|
||||
{t("page.list.stats")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/teacher/grades/entry">
|
||||
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
批量录入
|
||||
{t("page.list.batchEntry")}
|
||||
</Link>
|
||||
</Button>
|
||||
<ExportButton
|
||||
@@ -97,10 +90,15 @@ export default async function TeacherGradesPage({
|
||||
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
|
||||
variant="outline"
|
||||
/>
|
||||
<ExcelImportDialog
|
||||
classId={classId && classId !== "all" ? classId : (classes[0]?.id ?? "")}
|
||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
||||
/>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/grades/entry">
|
||||
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
录入成绩
|
||||
{t("page.list.enterGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -110,17 +108,20 @@ export default async function TeacherGradesPage({
|
||||
|
||||
{total === 0 && !hasFilters ? (
|
||||
<EmptyState
|
||||
title="暂无成绩记录"
|
||||
description="开始为您的班级录入成绩。"
|
||||
title={t("page.list.emptyTitle")}
|
||||
description={t("page.list.emptyDescription")}
|
||||
icon={ClipboardList}
|
||||
action={{
|
||||
label: "录入成绩",
|
||||
label: t("page.list.emptyActionLabel"),
|
||||
href: "/teacher/grades/entry",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<GradeRecordList records={pagedRecords} />
|
||||
{/* P1-9: 用 WidgetBoundary 包裹独立数据区块,隔离故障域 */}
|
||||
<WidgetBoundary title={t("title.list")}>
|
||||
<GradeRecordList records={pagedRecords} />
|
||||
</WidgetBoundary>
|
||||
{total > 0 ? (
|
||||
<ListPagination
|
||||
page={currentPage}
|
||||
@@ -129,7 +130,7 @@ export default async function TeacherGradesPage({
|
||||
totalPages={totalPages}
|
||||
basePath="/teacher/grades"
|
||||
searchParams={sp}
|
||||
itemLabel="条记录"
|
||||
itemLabel={t("page.list.recordUnit")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
35
src/app/(dashboard)/teacher/grades/report-card/error.tsx
Normal file
35
src/app/(dashboard)/teacher/grades/report-card/error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
useEffect(() => {
|
||||
console.error("[ReportCard] Route error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-12">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden="true" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold">{t("reportCard.errorTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("reportCard.errorDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("reportCard.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/app/(dashboard)/teacher/grades/report-card/loading.tsx
Normal file
12
src/app/(dashboard)/teacher/grades/report-card/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export default async function Loading() {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">{t("reportCard.loading")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
src/app/(dashboard)/teacher/grades/report-card/page.tsx
Normal file
116
src/app/(dashboard)/teacher/grades/report-card/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { JSX } from "react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
import { getReportCardData } from "@/modules/grades/lib/report-card"
|
||||
import { ReportCardView } from "@/modules/grades/components/report-card-view"
|
||||
import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action"
|
||||
import { getAcademicYears } from "@/modules/school/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* P3-1: 教师视角的学生成绩报告卡页面。
|
||||
*
|
||||
* 路由:/teacher/grades/report-card?studentId=xxx
|
||||
* 查询参数:
|
||||
* - studentId: 必填,目标学生 ID
|
||||
* - academicYearId?: 指定学年(不传则使用当前活跃学年)
|
||||
* - semester?: "1" | "2"(不传则全部学期)
|
||||
*
|
||||
* 权限:GRADE_RECORD_READ(class_taught scope 在 data-access 层校验学生归属)
|
||||
*/
|
||||
export default async function TeacherReportCardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const studentId = getParam(sp, "studentId")
|
||||
const academicYearIdParam = getParam(sp, "academicYearId")
|
||||
const semesterParam = getParam(sp, "semester")
|
||||
const academicYearId =
|
||||
academicYearIdParam && academicYearIdParam !== "all"
|
||||
? academicYearIdParam
|
||||
: undefined
|
||||
const semester =
|
||||
semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined
|
||||
|
||||
if (!studentId) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href="/teacher/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyState
|
||||
title={t("reportCard.missingStudentTitle")}
|
||||
description={t("reportCard.missingStudentDescription")}
|
||||
icon={ArrowLeft}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [data, academicYears] = await Promise.all([
|
||||
getReportCardData(studentId, ctx.dataScope, {
|
||||
academicYearId,
|
||||
semester,
|
||||
}),
|
||||
getAcademicYears(),
|
||||
])
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href="/teacher/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyState
|
||||
title={t("reportCard.emptyTitle")}
|
||||
description={t("reportCard.emptyDescription")}
|
||||
icon={ArrowLeft}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/teacher/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
<ReportCardPrintAction />
|
||||
</div>
|
||||
|
||||
<ReportCardView data={data} />
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("reportCard.academicYearsCount", {
|
||||
count: academicYears.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -20,6 +21,7 @@ export default async function StatsPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const sp = await searchParams
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
@@ -33,12 +35,12 @@ export default async function StatsPage({
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.stats")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.stats.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No classes"
|
||||
description="You don't have any classes yet."
|
||||
title={t("page.stats.noClassesTitle")}
|
||||
description={t("page.stats.noClassesDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -57,12 +59,12 @@ export default async function StatsPage({
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.stats")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.stats.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No accessible classes"
|
||||
description="You don't have permission to view any classes."
|
||||
title={t("page.stats.noAccessTitle")}
|
||||
description={t("page.stats.noAccessDescription")}
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -98,14 +100,14 @@ export default async function StatsPage({
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.stats")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.stats.description")}</p>
|
||||
</div>
|
||||
<ExportButton
|
||||
classId={targetClassId}
|
||||
subjectId={targetSubjectId}
|
||||
variant="outline"
|
||||
label="导出成绩"
|
||||
label={t("page.stats.exportLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access"
|
||||
import { getHomeworkAssignmentAnalytics } from "@/modules/homework/stats-service"
|
||||
import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card"
|
||||
import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { ArrowLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
await requirePermission(Permissions.HOMEWORK_CREATE)
|
||||
const t = await getTranslations("examHomework")
|
||||
const analytics = await getHomeworkAssignmentAnalytics(id)
|
||||
|
||||
if (!analytics) return notFound()
|
||||
@@ -28,7 +37,7 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href="/teacher/homework/assignments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
返回作业列表
|
||||
{t("homework.result.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -37,14 +46,14 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
{assignment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || "No description provided."}</p>
|
||||
<p className="text-muted-foreground text-sm max-w-2xl">{assignment.description || t("homework.take.noDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 md:mt-0">
|
||||
<Button asChild variant="outline" className="shadow-sm">
|
||||
<Link href={`/teacher/homework/assignments/${assignment.id}/submissions`}>
|
||||
<Users className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
View Submissions
|
||||
{t("homework.detail.viewSubmissions")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -54,38 +63,40 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2 mt-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Due: <span className="font-medium text-foreground tabular-nums">{assignment.dueAt ? formatDate(assignment.dueAt) : "No due date"}</span></span>
|
||||
<span>{t("homework.detail.dueLabel")}: <span className="font-medium text-foreground tabular-nums">{assignment.dueAt ? formatDate(assignment.dueAt) : t("homework.detail.noDueDate")}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Targets: <span className="font-medium text-foreground tabular-nums">{assignment.targetCount}</span></span>
|
||||
<span>{t("homework.grade.targets")}: <span className="font-medium text-foreground tabular-nums">{assignment.targetCount}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Submissions: <span className="font-medium text-foreground tabular-nums">{assignment.submissionCount}</span></span>
|
||||
<span>{t("homework.detail.submissionsLabel")}: <span className="font-medium text-foreground tabular-nums">{assignment.submissionCount}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Graded: <span className="font-medium text-foreground tabular-nums">{gradedSampleCount}</span></span>
|
||||
<span>{t("homework.grade.gradedCount")}: <span className="font-medium text-foreground tabular-nums">{gradedSampleCount}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-8 space-y-8 bg-muted/5">
|
||||
{/* Analytics Section */}
|
||||
{/* Analytics Section - wrapped with SectionErrorBoundary + Suspense for graceful degradation */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Performance Analytics</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-1">
|
||||
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t("homework.detail.performanceAnalytics")}</h2>
|
||||
</div>
|
||||
<SectionErrorBoundary namespace="examHomework">
|
||||
<Suspense fallback={<AnalyticsSkeleton />}>
|
||||
<HomeworkAssignmentQuestionErrorOverviewCard questions={questions} gradedSampleCount={gradedSampleCount} />
|
||||
</Suspense>
|
||||
</SectionErrorBoundary>
|
||||
</section>
|
||||
|
||||
{/* Content Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Assignment Content</h2>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t("homework.detail.assignmentContent")}</h2>
|
||||
</div>
|
||||
<HomeworkAssignmentExamContentCard
|
||||
structure={assignment.structure}
|
||||
@@ -97,3 +108,17 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics 区块加载骨架屏。
|
||||
*/
|
||||
function AnalyticsSkeleton(): ReactNode {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
|
||||
import { HomeworkBatchGradingView } from "@/modules/homework/components/homework-batch-grading-view"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const t = await getTranslations("examHomework")
|
||||
const [assignment, submissions] = await Promise.all([
|
||||
getHomeworkAssignmentById(id),
|
||||
getHomeworkSubmissions({ assignmentId: id }),
|
||||
getHomeworkAssignmentById(id, ctx.dataScope),
|
||||
getHomeworkSubmissions({ assignmentId: id, scope: ctx.dataScope }),
|
||||
])
|
||||
if (!assignment) return notFound()
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import { getExams } from "@/modules/exams/data-access"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateHomeworkAssignmentPage(): Promise<JSX.Element> {
|
||||
const { dataScope } = await getAuthContext()
|
||||
const t = await getTranslations("examHomework")
|
||||
const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()])
|
||||
const options = exams.map((e) => ({ id: e.id, title: e.title }))
|
||||
|
||||
@@ -17,17 +19,17 @@ export default async function CreateHomeworkAssignmentPage(): Promise<JSX.Elemen
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Create Assignment</h1>
|
||||
<p className="text-muted-foreground">快速发布文本作业或从考试派生。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("homework.form.createTitle")}</h1>
|
||||
<p className="text-muted-foreground">{t("homework.form.createDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{classes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No classes available"
|
||||
description="Create a class first, then publish homework to that class."
|
||||
title={t("homework.form.noClassesAvailable")}
|
||||
description={t("homework.form.noClassesDescription")}
|
||||
icon={FileQuestion}
|
||||
action={{ label: "Go to Classes", href: "/teacher/classes/my" }}
|
||||
action={{ label: t("homework.form.goToClasses"), href: "/teacher/classes/my" }}
|
||||
/>
|
||||
) : (
|
||||
<HomeworkAssignmentForm exams={options} classes={classes} />
|
||||
|
||||
28
src/app/(dashboard)/teacher/homework/assignments/error.tsx
Normal file
28
src/app/(dashboard)/teacher/homework/assignments/error.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* teacher/homework/assignments/error.tsx
|
||||
* 作业列表错误边界(教师视图)。
|
||||
*/
|
||||
export default function AssignmentsListError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.notFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/teacher/homework/assignments/loading.tsx
Normal file
27
src/app/(dashboard)/teacher/homework/assignments/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* teacher/homework/assignments/loading.tsx
|
||||
* 作业列表骨架屏(教师视图):标题 + 表格行骨架。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<div className="p-4 space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { PenTool, PlusCircle, AlertCircle } from "lucide-react"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -29,6 +31,7 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
|
||||
const t = await getTranslations("examHomework")
|
||||
const sp = await searchParams
|
||||
const rawClassId = getParam(sp, "classId")
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_CREATE)
|
||||
const creatorId = await getTeacherIdForMutations()
|
||||
|
||||
// Only fetch classes list when a class filter is active — needed to resolve
|
||||
@@ -36,7 +39,7 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
|
||||
// avoid an unnecessary DB round-trip.
|
||||
const filteredClassId = rawClassId && rawClassId !== "all" ? rawClassId : null
|
||||
const [assignments, classes] = await Promise.all([
|
||||
getHomeworkAssignments({ creatorId, classId: filteredClassId ?? undefined }),
|
||||
getHomeworkAssignments({ creatorId, classId: filteredClassId ?? undefined, scope: ctx.dataScope }),
|
||||
filteredClassId ? getTeacherClasses() : Promise.resolve([]),
|
||||
])
|
||||
const hasAssignments = assignments.length > 0
|
||||
|
||||
28
src/app/(dashboard)/teacher/homework/error.tsx
Normal file
28
src/app/(dashboard)/teacher/homework/error.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* teacher/homework/error.tsx
|
||||
* 作业模块入口错误边界。
|
||||
*/
|
||||
export default function HomeworkRootError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.notFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/teacher/homework/loading.tsx
Normal file
17
src/app/(dashboard)/teacher/homework/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* teacher/homework/loading.tsx
|
||||
* 作业模块入口骨架屏(实际会重定向到 /assignments,但保留骨架屏以避免空白闪烁)。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/[submissionId]/error.tsx
|
||||
* 批改页错误边界(教师视图)。
|
||||
*/
|
||||
export default function SubmissionGradingError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.submissionNotFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/[submissionId]/loading.tsx
|
||||
* 批改页骨架屏:标题 + 题目卡片骨架 + 右侧汇总卡片骨架。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid h-[calc(100vh-12rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-9 space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-40 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,46 +7,22 @@ import { HomeworkGradingView } from "@/modules/homework/components/homework-grad
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScanLine } from "lucide-react"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
suggestGradingAction,
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
|
||||
const { submissionId } = await params
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const t = await getTranslations("examHomework")
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
const aiClientService = createCoreAiClientService()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
@@ -55,12 +31,12 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
||||
<h1 className="text-2xl font-bold tracking-tight line-clamp-2">{submission.assignmentTitle}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
|
||||
<span>
|
||||
Student: <span className="font-medium text-foreground">{submission.studentName}</span>
|
||||
{t("homework.grade.student")}: <span className="font-medium text-foreground">{submission.studentName}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="tabular-nums">Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
|
||||
<span className="tabular-nums">{t("homework.grade.submitted")}: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="capitalize">Status: {submission.status}</span>
|
||||
<span className="capitalize">{t("homework.grade.status")}: {submission.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/[submissionId]/scan-grading/error.tsx
|
||||
* 阅卷式批改页错误边界(教师视图)。
|
||||
*/
|
||||
export default function ScanGradingError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.submissionNotFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/[submissionId]/scan-grading/loading.tsx
|
||||
* 阅卷式批改页骨架屏:标题 + 左侧题目 + 右侧扫描图区域骨架。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</div>
|
||||
<div className="grid h-[calc(100vh-12rem)] grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-40 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
|
||||
import { HomeworkScanGradingView } from "@/modules/homework/components/homework-scan-grading-view"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -12,6 +14,7 @@ export default async function HomeworkScanGradingPage({
|
||||
params: Promise<{ submissionId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { submissionId } = await params
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
28
src/app/(dashboard)/teacher/homework/submissions/error.tsx
Normal file
28
src/app/(dashboard)/teacher/homework/submissions/error.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/error.tsx
|
||||
* 提交列表错误边界(教师视图)。
|
||||
*/
|
||||
export default function SubmissionsListError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.notFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/teacher/homework/submissions/loading.tsx
Normal file
24
src/app/(dashboard)/teacher/homework/submissions/loading.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* teacher/homework/submissions/loading.tsx
|
||||
* 提交列表骨架屏(教师视图):标题 + 表格行骨架。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="rounded-md border bg-card">
|
||||
<div className="p-4 space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import { type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -23,8 +26,10 @@ const PAGE_SIZE = 10
|
||||
|
||||
export default async function SubmissionsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
const t = await getTranslations("examHomework")
|
||||
const creatorId = await getTeacherIdForMutations()
|
||||
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
|
||||
const assignments = await getHomeworkAssignmentReviewList({ creatorId, scope: ctx.dataScope })
|
||||
const hasAssignments = assignments.length > 0
|
||||
|
||||
// 分页计算
|
||||
@@ -38,17 +43,17 @@ export default async function SubmissionsPage({ searchParams }: { searchParams:
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">作业提交</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("homework.submissions.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
按作业查看提交与批改进度。
|
||||
{t("homework.submissions.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasAssignments ? (
|
||||
<EmptyState
|
||||
title="暂无作业"
|
||||
description="还没有可批改的作业。"
|
||||
title={t("homework.list.empty")}
|
||||
description={t("homework.submissions.emptyDescription")}
|
||||
icon={Inbox}
|
||||
/>
|
||||
) : (
|
||||
@@ -56,13 +61,13 @@ export default async function SubmissionsPage({ searchParams }: { searchParams:
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>作业</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>截止时间</TableHead>
|
||||
<TableHead className="text-right">应交</TableHead>
|
||||
<TableHead className="text-right">已交</TableHead>
|
||||
<TableHead className="text-right">已批</TableHead>
|
||||
<TableHead className="text-right">提交率</TableHead>
|
||||
<TableHead>{t("homework.submissions.columns.assignment")}</TableHead>
|
||||
<TableHead>{t("homework.list.columns.status")}</TableHead>
|
||||
<TableHead>{t("homework.list.columns.dueAt")}</TableHead>
|
||||
<TableHead className="text-right">{t("homework.grade.targets")}</TableHead>
|
||||
<TableHead className="text-right">{t("homework.grade.submittedCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("homework.grade.gradedCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("homework.list.columns.submissionRate")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -80,7 +85,7 @@ export default async function SubmissionsPage({ searchParams }: { searchParams:
|
||||
{a.sourceExamTitle ? (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">快速作业</div>
|
||||
<div className="text-xs text-muted-foreground italic">{t("homework.submissions.quickAssignment")}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -107,7 +112,7 @@ export default async function SubmissionsPage({ searchParams }: { searchParams:
|
||||
totalPages={totalPages}
|
||||
basePath="/teacher/homework/submissions"
|
||||
searchParams={sp}
|
||||
itemLabel="个作业"
|
||||
itemLabel={t("homework.list.pagination.itemLabel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
7
src/app/(dashboard)/teacher/leave/error.tsx
Normal file
7
src/app/(dashboard)/teacher/leave/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function TeacherLeaveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <RouteErrorBoundary reset={reset} namespace="leave" />
|
||||
}
|
||||
26
src/app/(dashboard)/teacher/leave/loading.tsx
Normal file
26
src/app/(dashboard)/teacher/leave/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/app/(dashboard)/teacher/leave/page.tsx
Normal file
80
src/app/(dashboard)/teacher/leave/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ArrowLeft, CalendarDays } from "lucide-react"
|
||||
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { getLeaveRequests } from "@/modules/leave-requests/data-access"
|
||||
import { LeaveReviewList } from "@/modules/leave-requests/components/leave-review-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 教师请假审批页面。
|
||||
*
|
||||
* L-5 功能:
|
||||
* - 列表展示教师所辖班级的所有请假申请(按 dataScope=class_taught 过滤)
|
||||
* - 待审批项可点击「审批」按钮打开对话框进行批准/拒绝
|
||||
*/
|
||||
export default async function TeacherLeavePage() {
|
||||
const t = await getTranslations("leave")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// 校验 LEAVE_REQUEST_REVIEW 权限(教师/管理员才有)
|
||||
if (!ctx.permissions.includes(Permissions.LEAVE_REQUEST_REVIEW)) {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacher")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description.teacher")}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("empty.teacherDesc")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const result = await getLeaveRequests({
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacher")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description.teacher")}</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||
<Link href="/teacher/dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{t("onlineLeave")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LeaveReviewList
|
||||
items={result.items}
|
||||
emptyTitle={t("empty.teacherTitle")}
|
||||
emptyDescription={t("empty.teacherDesc")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
|
||||
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
|
||||
import type { AiContentGeneratorSlotProps } from "@/modules/lesson-preparation/components/node-edit-panel";
|
||||
|
||||
/**
|
||||
* P0-11 修复:AI 内容生成器 slot 实现。
|
||||
* 此组件在 app 层组合 @/modules/ai 的具体实现,
|
||||
* 通过 props 注入到 lesson-preparation 模块的 NodeEditPanel,
|
||||
* 避免备课模块直接依赖 @/modules/ai。
|
||||
*/
|
||||
export function AiContentGeneratorSlot({
|
||||
topic,
|
||||
textbookId,
|
||||
chapterId,
|
||||
}: AiContentGeneratorSlotProps) {
|
||||
const aiClient = useAiClientOptional();
|
||||
if (!aiClient) return null;
|
||||
return (
|
||||
<AiLessonContentGenerator
|
||||
topic={topic}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,48 +5,25 @@ import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
|
||||
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
||||
// P0-11 修复:通过 slot 组件注入 AI 功能,避免备课模块直接 import @/modules/ai
|
||||
import { AiContentGeneratorSlot } from "./ai-content-generator-slot"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditLessonPlanPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ planId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const ctx = await getAuthContext()
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
|
||||
|
||||
const [plan, teacherClasses] = await Promise.all([
|
||||
getLessonPlanById(planId, ctx.userId),
|
||||
@@ -64,22 +41,13 @@ export default async function EditLessonPlanPage({
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
// P1-6 修复:使用共享工具 findChapterById 替代页面内重复的递归函数
|
||||
const chapter = findChapterById(chapters, plan.chapterId)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
const aiClientService = createCoreAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
@@ -102,6 +70,7 @@ export default async function EditLessonPlanPage({
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
classes={classes}
|
||||
aiContentGenerator={AiContentGeneratorSlot}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
20
src/app/(dashboard)/teacher/lesson-plans/calendar/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/calendar/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
||||
|
||||
export default function CalendarError() {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title={t("calendar.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
export default function CalendarLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[260px]" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
<Skeleton className="h-9 w-[80px]" />
|
||||
<Skeleton className="h-4 w-[200px] ml-2" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<div className="flex-1 min-h-[200px] space-y-1 pt-1">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/app/(dashboard)/teacher/lesson-plans/calendar/page.tsx
Normal file
34
src/app/(dashboard)/teacher/lesson-plans/calendar/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { JSX } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requirePermission } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { CalendarView } from "@/modules/lesson-preparation/components/calendar-view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CalendarPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("calendar.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("calendar.description")}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/teacher/lesson-plans">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" aria-hidden="true" />
|
||||
{t("action.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CalendarView initialTeacherId={ctx.userId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { ArrowLeft } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
||||
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
|
||||
@@ -12,6 +14,8 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
await requirePermission(Permissions.LESSON_PLAN_READ)
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Plus } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
@@ -15,7 +16,8 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function LessonPlansPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
|
||||
|
||||
const [items, subjects] = await Promise.all([
|
||||
getLessonPlans({}, ctx.dataScope, ctx.userId),
|
||||
|
||||
@@ -2,24 +2,38 @@
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function Error() {
|
||||
export default function TeacherPracticeError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
useEffect(() => {
|
||||
console.error("Practice analytics page error")
|
||||
}, [])
|
||||
console.error("Practice analytics page error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">专项练习分析</h1>
|
||||
<p className="text-muted-foreground">加载练习分析数据时发生错误</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("errors.pageErrorTitlePractice")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("errors.pageErrorPractice")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="加载失败"
|
||||
description="请刷新页面重试,或联系管理员检查数据访问权限。"
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDescription")}
|
||||
action={{
|
||||
label: t("errors.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { ClassFilter, type ClassFilterItem } from "@/shared/components/class-filter"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import {
|
||||
getClassIdsByGradeIds,
|
||||
@@ -28,8 +29,6 @@ import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/componen
|
||||
import { ClassKnowledgePointWeaknessChart } from "@/modules/adaptive-practice/components/class-knowledge-point-weakness-chart"
|
||||
import { StudentPracticeRankingTable } from "@/modules/adaptive-practice/components/student-practice-ranking-table"
|
||||
import { InactiveStudentsAlert } from "@/modules/adaptive-practice/components/inactive-students-alert"
|
||||
import { ClassFilter } from "@/modules/error-book/components/class-filter"
|
||||
import type { ClassErrorOverview } from "@/modules/error-book/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -104,15 +103,11 @@ async function TeacherPracticeContent({
|
||||
// 班级概览(用于班级筛选器显示)
|
||||
const classOverviews = await getTeacherClassPracticeOverviews(targetClassIds)
|
||||
|
||||
// 构造 ClassFilter 所需的数据格式
|
||||
const classFilterData: ClassErrorOverview[] = classOverviews.map((c) => ({
|
||||
// 构造通用 ClassFilter 所需的数据格式(直接使用 practice 数据,无需字段强转)
|
||||
const classFilterItems: ClassFilterItem[] = classOverviews.map((c) => ({
|
||||
classId: c.classId,
|
||||
className: c.className,
|
||||
studentCount: c.totalStudents,
|
||||
totalErrorItems: c.totalSessions,
|
||||
dueReviewCount: 0,
|
||||
averageErrorPerStudent: c.totalStudents > 0 ? c.totalSessions / c.totalStudents : 0,
|
||||
averageMasteryRate: c.averageAccuracy,
|
||||
badgeText: t("classFilter.sessionCount", { count: c.totalSessions }),
|
||||
}))
|
||||
|
||||
// 确定查询的班级范围
|
||||
@@ -172,12 +167,14 @@ async function TeacherPracticeContent({
|
||||
<p className="text-muted-foreground">{t("teacher.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 班级筛选器 */}
|
||||
{classFilterData.length > 0 ? (
|
||||
{/* 班级筛选器(使用通用共享组件,无字段强转) */}
|
||||
{classFilterItems.length > 0 ? (
|
||||
<Suspense fallback={<Skeleton className="h-10 w-full" />}>
|
||||
<ClassFilter
|
||||
classes={classFilterData}
|
||||
classes={classFilterItems}
|
||||
currentClassId={effectiveClassId}
|
||||
allLabel={t("classFilter.all")}
|
||||
ariaLabel={t("classFilter.selectClass")}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
29
src/app/(dashboard)/teacher/questions/error.tsx
Normal file
29
src/app/(dashboard)/teacher/questions/error.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherQuestionsError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("questions")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { QuestionDataTable } from "@/modules/questions/components/question-data-table"
|
||||
import { columns } from "@/modules/questions/components/question-columns"
|
||||
import { QuestionFilters } from "@/modules/questions/components/question-filters"
|
||||
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
|
||||
import { QuestionBankResultsClient } from "@/modules/questions/components/question-bank-results-client"
|
||||
import { ImportExportButtons } from "@/modules/questions/components/import-export-buttons"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
@@ -37,6 +38,8 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
const type = getParam(params, "type")
|
||||
const difficulty = getParam(params, "difficulty")
|
||||
const knowledgePointId = getParam(params, "kp")
|
||||
const textbookId = getParam(params, "tb")
|
||||
const chapterId = getParam(params, "ch")
|
||||
|
||||
const questionType = parseQuestionType(type)
|
||||
|
||||
@@ -48,6 +51,8 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
type: questionType,
|
||||
difficulty: safeDifficulty,
|
||||
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
||||
textbookId: textbookId && textbookId !== "all" ? textbookId : undefined,
|
||||
chapterId: chapterId && chapterId !== "all" ? chapterId : undefined,
|
||||
pageSize: 200,
|
||||
})
|
||||
|
||||
@@ -55,20 +60,23 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
q ||
|
||||
(type && type !== "all") ||
|
||||
(difficulty && difficulty !== "all") ||
|
||||
(knowledgePointId && knowledgePointId !== "all")
|
||||
(knowledgePointId && knowledgePointId !== "all") ||
|
||||
(textbookId && textbookId !== "all") ||
|
||||
(chapterId && chapterId !== "all")
|
||||
)
|
||||
|
||||
if (questions.length === 0) {
|
||||
const t = await getTranslations("questions")
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={hasFilters ? "No questions match your filters" : "No questions yet"}
|
||||
title={hasFilters ? t("empty.withFilters") : t("empty.withoutFilters")}
|
||||
description={
|
||||
hasFilters
|
||||
? "Try clearing filters or adjusting keywords."
|
||||
: "Create your first question to start building exams and assignments."
|
||||
? t("empty.withFiltersDesc")
|
||||
: t("empty.withoutFiltersDesc")
|
||||
}
|
||||
action={hasFilters ? { label: "Clear filters", href: "/teacher/questions" } : undefined}
|
||||
action={hasFilters ? { label: t("filters.clear"), href: "/teacher/questions" } : undefined}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
@@ -76,7 +84,40 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-card">
|
||||
<QuestionDataTable columns={columns} data={questions} />
|
||||
<QuestionBankResultsClient questions={questions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function QuestionBankPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("questions")
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ImportExportButtons />
|
||||
<CreateQuestionButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<QuestionFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<QuestionBankResultsFallback />}>
|
||||
<QuestionBankResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -95,35 +136,3 @@ function QuestionBankResultsFallback() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function QuestionBankPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Question Bank</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your question repository for exams and assignments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CreateQuestionButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<QuestionFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<QuestionBankResultsFallback />}>
|
||||
<QuestionBankResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteError } from "@/shared/components/route-error"
|
||||
|
||||
export default function TeacherTextbookDetailError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
||||
}
|
||||
|
||||
@@ -1,63 +1,46 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-md" /> {/* Back Button */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5"
|
||||
>
|
||||
{/* Reader Header Skeleton */}
|
||||
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur py-3 px-6 shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" /> {/* Edit Button */}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Main Content Skeleton */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-6 w-6 rounded-md" />
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
{/* Reader Body Skeleton: sidebar + content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full max-w-[1600px] mx-auto w-full grid grid-cols-1 md:grid-cols-[280px_1fr]">
|
||||
{/* Chapter Sidebar Skeleton */}
|
||||
<div className="hidden md:block border-r bg-card p-4 space-y-2 overflow-hidden">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4 rounded-sm" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Skeleton */}
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-32 mb-4" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
{/* Content Area Skeleton */}
|
||||
<div className="p-6 space-y-4 overflow-hidden">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteError } from "@/shared/components/route-error"
|
||||
|
||||
export default function TeacherTextbooksError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/u
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user