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,32 @@
"use client"
import { useCallback } from "react"
import type { JSX } from "react"
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
import { getQuestionsAction } from "@/modules/questions/actions"
import { extractQuestionPreview } from "@/shared/lib/question-content"
/**
* 手动添加错题对话框包装组件(位于 app 层)。
*
* 负责从 questions 模块加载题目列表并注入到 AddErrorBookDialog
* 避免 error-book 模块直接 import questions 模块(解耦)。
* app 层可以 import 任何模块,因此跨模块依赖在此合法。
*/
export function AddErrorBookDialogWithQuestions(): JSX.Element {
const onLoadQuestions = useCallback(async (): Promise<
Array<{ id: string; preview: string }>
> => {
const res = await getQuestionsAction({ pageSize: 100 })
if (res.success && res.data) {
return res.data.data.map((q) => ({
id: q.id,
preview: extractQuestionPreview(q.content, "", 60),
}))
}
return []
}, [])
return <AddErrorBookDialog onLoadQuestions={onLoadQuestions} />
}

View File

@@ -6,54 +6,40 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
import {
AiClientProvider,
type AiClientService,
} from "@/modules/ai/context/ai-client-provider"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
} from "@/modules/ai/actions"
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
import { StudentErrorBookListClient } from "./student-error-book-list-client"
import { AddErrorBookDialogWithQuestions } from "./add-error-book-dialog-with-questions"
export const dynamic = "force-dynamic"
const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"])
const VALID_SOURCE = new Set(["exam", "homework", "manual"])
function isErrorBookStatus(v: string): v is ErrorBookStatusValue {
return VALID_STATUS.has(v)
}
function isErrorBookSource(v: string): v is ErrorBookSourceTypeValue {
return VALID_SOURCE.has(v)
}
function parseStatus(v?: string): ErrorBookStatusValue | undefined {
return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined
if (!v || !isErrorBookStatus(v)) return undefined
return v
}
function parseSource(v?: string): ErrorBookSourceTypeValue | undefined {
return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined
}
/**
* 构建 AI 客户端服务Server Action 引用集合)
*
* 通过 React Context 注入,客户端组件不直接 import actions
* 遵循依赖注入模式,便于测试时替换为 mock。
*/
function createAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
if (!v || !isErrorBookSource(v)) return undefined
return v
}
async function ErrorBookResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
@@ -74,7 +60,7 @@ async function ErrorBookResults({ searchParams }: { searchParams: Promise<Search
pageSize: 50,
})
return <ErrorBookList items={items} studentId={ctx.userId} errorItems={items} />
return <StudentErrorBookListClient items={items} studentId={ctx.userId} />
}
function ErrorBookResultsFallback() {
@@ -95,7 +81,7 @@ export default async function StudentErrorBookPage({
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const t = await getTranslations("student")
const stats = await getErrorBookStats(ctx.userId)
const aiClientService = createAiClientService()
const aiClientService = createCoreAiClientService()
return (
<AiClientProvider service={aiClientService}>
@@ -108,20 +94,24 @@ export default async function StudentErrorBookPage({
</p>
</div>
<div className="flex items-center gap-2">
<AddErrorBookDialog />
<AddErrorBookDialogWithQuestions />
</div>
</div>
<ErrorBookStatsCards stats={stats} />
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={120}>
<ErrorBookStatsCards stats={stats} />
</WidgetBoundary>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ErrorBookFilters />
</Suspense>
<Suspense fallback={<ErrorBookResultsFallback />}>
<ErrorBookResults searchParams={searchParams} />
</Suspense>
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={360}>
<Suspense fallback={<ErrorBookResultsFallback />}>
<ErrorBookResults searchParams={searchParams} />
</Suspense>
</WidgetBoundary>
</div>
</div>
</AiClientProvider>

View File

@@ -0,0 +1,120 @@
"use client"
import { useTransition } from "react"
import type { JSX } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
import type { ErrorBookItem } from "@/modules/error-book/types"
/**
* 从题目内容中提取纯文本(用于 AI 分析)
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
*/
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}> {
return items.map((it) => ({
questionText: extractQuestionText(it.question?.content),
questionType: it.question?.type ?? "unknown",
knowledgePointIds: it.knowledgePointIds ?? undefined,
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
masteryLevel: it.masteryLevel,
}))
}
interface StudentErrorBookListClientProps {
items: ErrorBookItem[]
studentId: string
}
/**
* 学生端错题本列表客户端包装组件(位于 app 层)。
*
* 负责将 AI 分析和变式练习功能通过 props 注入到 ErrorBookList
* 避免 error-book 模块直接 import ai / adaptive-practice 模块(解耦)。
* app 层可以 import 任何模块,因此跨模块依赖在此合法。
*/
export function StudentErrorBookListClient({
items,
studentId,
}: StudentErrorBookListClientProps): JSX.Element {
const router = useRouter()
const t = useTranslations("error-book")
const tPractice = useTranslations("practice")
const [, startTransition] = useTransition()
const aiAnalysisSlot = (item: ErrorBookItem): React.ReactNode => {
const currentQuestionText = extractQuestionText(item.question?.content)
if (!currentQuestionText) return null
const aiErrorItems = mapErrorItemsForAnalysis(items)
return (
<AiErrorBookAnalysis
studentId={studentId}
subjectId={item.subjectId ?? undefined}
currentQuestionText={currentQuestionText}
currentQuestionType={item.question?.type}
errorItems={aiErrorItems}
/>
)
}
const onStartVariantPractice = (item: ErrorBookItem): void => {
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
practiceType: "error_variant",
subjectId: item.subjectId ?? undefined,
sourceMeta: {
errorBookItemIds: [item.id],
sourceQuestionIds: [item.questionId],
},
questionCount: 10,
}),
)
const res = await createPracticeSessionAction(undefined, formData)
if (res.success && res.data) {
toast.success(res.message ?? tPractice("starter.title"))
router.push(`/student/practice/${res.data.sessionId}`)
} else {
toast.error(res.message ?? t("messages.saveFailed"))
}
})
}
return (
<ErrorBookList
items={items}
aiAnalysisSlot={aiAnalysisSlot}
onStartVariantPractice={onStartVariantPractice}
/>
)
}