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:
24
src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ExamAnalyticsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.analytics.title")}
|
||||
description={t("exam.analytics.noData")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx
Normal file
22
src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
<Skeleton className="h-28 md:col-span-1" />
|
||||
</div>
|
||||
<Skeleton className="h-[420px] w-full" />
|
||||
<Skeleton className="h-[360px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,14 @@ import { BarChart3, ArrowLeft } from "lucide-react"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getExamAnalytics } from "@/modules/exams/stats-service"
|
||||
import { ExamAnalyticsDashboard } from "@/modules/exams/components/exam-analytics-dashboard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ExamAnalyticsPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
const t = await getTranslations("examHomework")
|
||||
|
||||
const [exam, analytics] = await Promise.all([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ExamAssembly } from "@/modules/exams/components/exam-assembly"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
@@ -9,40 +10,14 @@ import type { ExamNode } from "@/modules/exams/components/assembly/selected-ques
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
import { AiClientProvider } from "@/modules/ai/context/ai-client-provider"
|
||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const t = await getTranslations("examHomework.exam.build")
|
||||
|
||||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||||
const exam = await getExamById(id, ctx.dataScope)
|
||||
@@ -101,14 +76,14 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}))
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
const aiClientService = createCoreAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
|
||||
<p className="text-muted-foreground">Assemble questions for your exam.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/[id]/edit-rich/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/edit-rich/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function EditRichExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("exam.error.loadFailed")}
|
||||
description={t("exam.error.notFound")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx
Normal file
16
src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
<Skeleton className="h-[640px] lg:col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx
Normal file
80
src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getExamById } from "@/modules/exams/data-access"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
|
||||
// 直接从具体文件导入纯转换函数和类型,绕过 editor/index.ts barrel,
|
||||
// 避免 Server Component 连带加载 @tiptap/react 客户端扩展(会触发
|
||||
// "Class extends value undefined is not a constructor or null" 错误)。
|
||||
import { examNodesToEditorDoc } from "@/modules/exams/editor/exam-nodes-to-editor-doc"
|
||||
import { structureToEditorDoc } from "@/modules/exams/editor/structure-to-editor"
|
||||
import type { EditorJSONContent } from "@/modules/exams/editor/exam-rich-editor-types"
|
||||
import { ExamRichForm } from "@/modules/exams/components/exam-rich-form"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function EditRichExamPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
const t = await getTranslations("examHomework")
|
||||
|
||||
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
|
||||
const exam = await getExamById(id, ctx.dataScope)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// 加载 exam 关联的题目(含 content)
|
||||
const selectedQuestionIds = (exam.questions || []).map((q) => q.id)
|
||||
const selectedResult = selectedQuestionIds.length > 0
|
||||
? await getQuestions({
|
||||
ids: selectedQuestionIds,
|
||||
pageSize: Math.max(10, selectedQuestionIds.length),
|
||||
})
|
||||
: { data: [] as Awaited<ReturnType<typeof getQuestions>>["data"] }
|
||||
|
||||
type RawQuestion = (typeof selectedResult.data)[number]
|
||||
const toQuestion = (q: RawQuestion): Question => ({
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? 1,
|
||||
createdAt: new Date(q.createdAt),
|
||||
updatedAt: new Date(q.updatedAt),
|
||||
author: q.author
|
||||
? {
|
||||
id: q.author.id,
|
||||
name: q.author.name || "Unknown",
|
||||
image: q.author.image || null,
|
||||
}
|
||||
: null,
|
||||
knowledgePoints: q.knowledgePoints ?? [],
|
||||
})
|
||||
|
||||
const questions = selectedResult.data.map(toQuestion)
|
||||
|
||||
// 把 ExamNode[] + questions 转为 EditorDoc,再转为 Tiptap JSON
|
||||
const examNodes = normalizeStructure(exam.structure)
|
||||
const editorDoc = examNodesToEditorDoc(examNodes, questions, exam.title)
|
||||
const initialContent = structureToEditorDoc(editorDoc) as EditorJSONContent
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("exam.richEditor.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("exam.richEditor.description")}</p>
|
||||
</div>
|
||||
<ExamRichForm
|
||||
mode="edit"
|
||||
examId={id}
|
||||
initialTitle={exam.title}
|
||||
initialContent={initialContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard"
|
||||
@@ -18,13 +19,14 @@ export default async function ExamProctoringPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("examHomework.proctoring.page")
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_PROCTOR)
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return (
|
||||
<div className="p-10 text-center text-muted-foreground">
|
||||
您没有监考权限(exam:proctor)。
|
||||
{t("noPermission")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,8 +53,8 @@ export default async function ExamProctoringPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exam Proctoring</h1>
|
||||
<p className="text-muted-foreground">Monitor student activity during the exam.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<ProctoringDashboard examId={id} initialData={initialData} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user