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:
SpecialX
2026-07-03 10:26:25 +08:00
parent e9a5264fe7
commit 21142f9b99
280 changed files with 7137 additions and 1855 deletions

View File

@@ -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" />
}

View File

@@ -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}

View 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" />
}

View 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>
)
}

View 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>
)
}

View 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" />
}

View File

@@ -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

View 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" />
}

View File

@@ -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} />

View 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" />
}

View 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>
)
}

View File

@@ -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>
)
}

View 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" />
}

View File

@@ -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>
)
}

View 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" />
}

View File

@@ -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>
)

View 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" />
}

View File

@@ -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>
)

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)

View 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>
)
}

View 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>
)
}

View File

@@ -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-3data-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}

View File

@@ -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"

View File

@@ -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>
)

View File

@@ -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"

View File

@@ -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: 移除客户端 filterDataScope 过滤已在 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>
)
}

View File

@@ -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"

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View File

@@ -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>
)

View File

@@ -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>
)

View 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>
)
}

View 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>
)
}

View File

@@ -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([

View File

@@ -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}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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"))

View 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>
)
}

View File

@@ -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">

View 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>
)
}

View 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>
)
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
)

View File

@@ -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">

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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_READclass_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>
)
}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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()

View File

@@ -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} />

View 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>
)
}

View 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>
)
}

View File

@@ -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

View 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>
)
}

View 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>
)
}

View 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/[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>
)
}

View File

@@ -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>
)
}

View File

@@ -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">

View 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/[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>
)
}

View File

@@ -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>
)
}

View File

@@ -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()

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)}

View 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" />
}

View 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>
)
}

View 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>
)
}

View File

@@ -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}
/>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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),

View File

@@ -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>

View File

@@ -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}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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" />
}

View File

@@ -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>

View File

@@ -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" />
}

View File

@@ -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">