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:
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user