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

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