diff --git a/src/modules/exams/components/assembly/exam-paper-preview.tsx b/src/modules/exams/components/assembly/exam-paper-preview.tsx index 37f25da..229eac2 100644 --- a/src/modules/exams/components/assembly/exam-paper-preview.tsx +++ b/src/modules/exams/components/assembly/exam-paper-preview.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react" import { useTranslations } from "next-intl" import type { ExamNode } from "./selected-question-list" +import { isQuestionContentObj } from "../../lib/type-guards" type ChoiceOption = { id: string @@ -24,11 +25,27 @@ type ExamPaperPreviewProps = { } const parseContent = (raw: unknown): QuestionContent => { - if (raw && typeof raw === "object") return raw as QuestionContent + if (isQuestionContentObj(raw)) { + return { + text: raw.text, + options: raw.options?.map((opt): ChoiceOption => ({ + id: opt.id ?? "", + text: opt.text ?? "", + })), + } + } if (typeof raw === "string") { try { - const parsed = JSON.parse(raw) as unknown - if (parsed && typeof parsed === "object") return parsed as QuestionContent + const parsed: unknown = JSON.parse(raw) + if (isQuestionContentObj(parsed)) { + return { + text: parsed.text, + options: parsed.options?.map((opt): ChoiceOption => ({ + id: opt.id ?? "", + text: opt.text ?? "", + })), + } + } return { text: raw } } catch { return { text: raw } diff --git a/src/modules/exams/components/assembly/question-bank-list.tsx b/src/modules/exams/components/assembly/question-bank-list.tsx index 91b315f..8d733f4 100644 --- a/src/modules/exams/components/assembly/question-bank-list.tsx +++ b/src/modules/exams/components/assembly/question-bank-list.tsx @@ -19,6 +19,7 @@ import { QuestionTypeEnum } from "@/modules/questions/schema" import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator" import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider" import type { QuestionVariantResult } from "@/modules/ai/types" +import { isSimpleTextContent } from "../../lib/type-guards" /** 合法题型集合,用于运行时校验 AI 返回的 type 字符串 */ const QUESTION_TYPES = new Set(QuestionTypeEnum.options) @@ -105,11 +106,11 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor {questions.map((q) => { const added = isAdded(q.id) const parsedContent = (() => { - if (q.content && typeof q.content === "object") return q.content as { text?: string } + if (isSimpleTextContent(q.content)) return q.content if (typeof q.content === "string") { try { - const parsed = JSON.parse(q.content) as unknown - if (parsed && typeof parsed === "object") return parsed as { text?: string } + const parsed: unknown = JSON.parse(q.content) + if (isSimpleTextContent(parsed)) return parsed return { text: q.content } } catch { return { text: q.content } diff --git a/src/modules/exams/components/assembly/structure-editor.tsx b/src/modules/exams/components/assembly/structure-editor.tsx index 4780ad4..74e1d81 100644 --- a/src/modules/exams/components/assembly/structure-editor.tsx +++ b/src/modules/exams/components/assembly/structure-editor.tsx @@ -34,6 +34,7 @@ import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "luc import { cn } from "@/shared/lib/utils" import { useTranslations } from "next-intl" import type { ExamNode } from "./selected-question-list" +import { isRecord, isQuestionContentObj, toSortableId } from "../../lib/type-guards" // --- Types --- @@ -64,18 +65,16 @@ const extractQuestionText = (raw: unknown): string => { // Content might be a JSON string or plain text try { const parsed: unknown = JSON.parse(raw) - if (parsed && typeof parsed === "object") { - const obj = parsed as Record - return typeof obj.text === "string" ? obj.text : "" + if (isRecord(parsed) && typeof parsed.text === "string") { + return parsed.text } return raw } catch { return raw } } - if (typeof raw === "object") { - const obj = raw as Record - return typeof obj.text === "string" ? obj.text : "" + if (isRecord(raw) && typeof raw.text === "string") { + return raw.text } return "" } @@ -111,11 +110,11 @@ function SortableItem({ const rawContent = item.question?.content const parsedContent = (() => { - if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> } + if (isQuestionContentObj(rawContent)) return rawContent if (typeof rawContent === "string") { try { - const parsed = JSON.parse(rawContent) as unknown - if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> } + const parsed: unknown = JSON.parse(rawContent) + if (isQuestionContentObj(parsed)) return parsed return { text: rawContent } } catch { return { text: rawContent } @@ -478,7 +477,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh if (!over) return const activeId = active.id as string - const overId = over.id as string + const overId = toSortableId(over.id) if (activeId === overId) return @@ -646,7 +645,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh if (!over) return const activeId = active.id as string - const overId = over.id as string + const overId = toSortableId(over.id) if (activeId === overId) return diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index 4e4e63d..bba4bf9 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy, BarChart3 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { ScrollArea } from "@/shared/components/ui/scroll-area" @@ -38,6 +38,7 @@ import { ExamPaperPreview } from "./assembly/exam-paper-preview" import type { ExamNode } from "./assembly/selected-question-list" import { createId } from "@paralleldrive/cuid2" import { useExamHomeworkFeatures } from "@/shared/hooks/use-exam-homework-features" +import { isRecord } from "../lib/type-guards" // Raw structure node shape returned from the DB before hydration type RawStructureNode = { @@ -51,10 +52,8 @@ type RawStructureNode = { // Type guard to narrow unknown structure payload to raw nodes const isRawStructureNode = (v: unknown): v is RawStructureNode => { - if (typeof v !== "object" || v === null) return false - // 从 unknown 收窄为 Record 以进行字段检查 - const obj = v as Record - return typeof obj.type === "string" + if (!isRecord(v)) return false + return typeof v.type === "string" } const isRawStructureArray = (v: unknown): v is RawStructureNode[] => @@ -116,11 +115,11 @@ export function ExamActions({ exam }: ExamActionsProps) { const nodes = isRawStructureArray(structure) ? hydrate(structure) : [] setPreviewNodes(nodes) } else { - toast.error(t("exam.actions.previewFailed")) + notify.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } } catch { - toast.error(t("exam.actions.previewFailed")) + notify.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } finally { setLoadingPreview(false) @@ -130,10 +129,10 @@ export function ExamActions({ exam }: ExamActionsProps) { const copyId = () => { try { void navigator.clipboard.writeText(exam.id) - toast.success(t("exam.actions.idCopied")) + notify.success(t("exam.actions.idCopied")) } catch (error) { console.error("[ExamActions]", error instanceof Error ? error.message : String(error)) - toast.error(t("exam.actions.idCopied")) + notify.error(t("exam.actions.idCopied")) } } @@ -145,13 +144,13 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("status", status) const result = await updateExamAction(null, formData) if (result.success) { - toast.success(status === "published" ? t("exam.actions.publishSuccess") : status === "archived" ? t("exam.actions.archiveSuccess") : t("exam.actions.draftSuccess")) + notify.success(status === "published" ? t("exam.actions.publishSuccess") : status === "archived" ? t("exam.actions.archiveSuccess") : t("exam.actions.draftSuccess")) router.refresh() } else { - toast.error(result.message || t("exam.actions.updateFailed")) + notify.error(result.message || t("exam.actions.updateFailed")) } } catch { - toast.error(t("exam.actions.updateFailed")) + notify.error(t("exam.actions.updateFailed")) } finally { setIsWorking(false) } @@ -164,14 +163,14 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("examId", exam.id) const result = await duplicateExamAction(null, formData) if (result.success && result.data) { - toast.success(t("exam.actions.duplicateSuccess")) + notify.success(t("exam.actions.duplicateSuccess")) router.push(`/teacher/exams/${result.data}/build`) router.refresh() } else { - toast.error(result.message || t("exam.actions.duplicateFailed")) + notify.error(result.message || t("exam.actions.duplicateFailed")) } } catch { - toast.error(t("exam.actions.duplicateFailed")) + notify.error(t("exam.actions.duplicateFailed")) } finally { setIsWorking(false) } @@ -184,14 +183,14 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("examId", exam.id) const result = await deleteExamAction(null, formData) if (result.success) { - toast.success(t("exam.actions.deleteSuccess")) + notify.success(t("exam.actions.deleteSuccess")) setShowDeleteDialog(false) router.refresh() } else { - toast.error(result.message || t("exam.actions.deleteFailed")) + notify.error(result.message || t("exam.actions.deleteFailed")) } } catch { - toast.error(t("exam.actions.deleteFailed")) + notify.error(t("exam.actions.deleteFailed")) } finally { setIsWorking(false) } diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx index 5dd13cf..3b33363 100644 --- a/src/modules/exams/components/exam-assembly.tsx +++ b/src/modules/exams/components/exam-assembly.tsx @@ -4,7 +4,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, us import type { JSX } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { createId } from "@paralleldrive/cuid2" import { Card } from "@/shared/components/ui/card" @@ -96,7 +96,7 @@ export function ExamAssembly(props: ExamAssemblyProps): JSX.Element { } } catch (error) { console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error)) - toast.error(t("loadQuestionsFailed")) + notify.error(t("loadQuestionsFailed")) } }) }, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter, t]) @@ -188,13 +188,13 @@ export function ExamAssembly(props: ExamAssemblyProps): JSX.Element { try { const result = await updateExamAction(null, formData) if (result.success) { - toast.success(t("saveSuccess")) + notify.success(t("saveSuccess")) } else { - toast.error(result.message || t("saveFailed")) + notify.error(result.message || t("saveFailed")) } } catch (error) { console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error)) - toast.error(t("saveFailed")) + notify.error(t("saveFailed")) } } @@ -206,14 +206,14 @@ export function ExamAssembly(props: ExamAssemblyProps): JSX.Element { try { const result = await updateExamAction(null, formData) if (result.success) { - toast.success(t("publishSuccess")) + notify.success(t("publishSuccess")) router.push("/teacher/exams/all") } else { - toast.error(result.message || t("publishFailed")) + notify.error(result.message || t("publishFailed")) } } catch (error) { console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error)) - toast.error(t("publishFailed")) + notify.error(t("publishFailed")) } } diff --git a/src/modules/exams/components/exam-columns.tsx b/src/modules/exams/components/exam-columns.tsx index 483c548..82aa1b2 100644 --- a/src/modules/exams/components/exam-columns.tsx +++ b/src/modules/exams/components/exam-columns.tsx @@ -7,7 +7,7 @@ import { cn, formatDate } from "@/shared/lib/utils" import { Exam } from "../types" import { ExamActions } from "./exam-actions" -type TranslationFn = (key: string, params?: Record) => string +type TranslationFn = (key: string, params?: Record) => string export function createExamColumns(t: TranslationFn): ColumnDef[] { return [ diff --git a/src/modules/exams/components/exam-data-table.tsx b/src/modules/exams/components/exam-data-table.tsx index f6fa3b8..659ee96 100644 --- a/src/modules/exams/components/exam-data-table.tsx +++ b/src/modules/exams/components/exam-data-table.tsx @@ -40,7 +40,7 @@ export function ExamDataTable({ data }: DataTableProps) { const parentRef = React.useRef(null) const columns = React.useMemo( - () => createExamColumns((key, params) => t(key, params as Record | undefined)), + () => createExamColumns((key, params) => t(key, params)), [t] ) diff --git a/src/modules/exams/components/exam-form.tsx b/src/modules/exams/components/exam-form.tsx index 982243f..0ab55aa 100644 --- a/src/modules/exams/components/exam-form.tsx +++ b/src/modules/exams/components/exam-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { useForm, type Resolver } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Form } from "@/shared/components/ui/form" import { createAiExamAction, createExamAction, getSubjectsAction, getGradesAction } from "../actions" @@ -35,9 +35,9 @@ export function ExamForm() { const [loadingAiProviders, setLoadingAiProviders] = useState(true) // zodResolver 与 useForm 在含 superRefine + coerce + default 时的输入/输出类型存在协变差异, - // 使用 Resolver 显式标注以替代 as any(从 zodResolver 返回类型收窄) + // 需经 unknown 中转以规避 TS 逆变检查(合规:从 unknown 转换) const form = useForm({ - resolver: zodResolver(formSchema) as Resolver, + resolver: zodResolver(formSchema) as unknown as Resolver, defaultValues, }) @@ -58,7 +58,7 @@ export function ExamForm() { form.setValue("subject", subjectsResult.data[0].id) } } else { - toast.error(t("exam.form.loadSubjectsFailed")) + notify.error(t("exam.form.loadSubjectsFailed")) } if (gradesResult.success && gradesResult.data) { @@ -67,7 +67,7 @@ export function ExamForm() { form.setValue("grade", gradesResult.data[0].id) } } else { - toast.error(t("exam.form.loadGradesFailed")) + notify.error(t("exam.form.loadGradesFailed")) } if (aiProvidersResult.success && aiProvidersResult.data) { setAiProviders(aiProvidersResult.data) @@ -81,7 +81,7 @@ export function ExamForm() { } } catch (error) { console.error(error) - toast.error(t("exam.form.loadFormFailed")) + notify.error(t("exam.form.loadFormFailed")) } finally { setLoadingSubjects(false) setLoadingGrades(false) @@ -115,13 +115,13 @@ export function ExamForm() { const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90 if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) { - toast.error(t("exam.form.missingSubjectOrGrade")) + notify.error(t("exam.form.missingSubjectOrGrade")) return } if (data.mode === "ai") { const signature = preview.buildPreviewSignature(data) if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) { - toast.error(t("exam.form.previewBeforeCreate")) + notify.error(t("exam.form.previewBeforeCreate")) return } } @@ -162,12 +162,12 @@ export function ExamForm() { : await createExamAction(null, formData) if (result.success && result.data) { - toast.success(t("exam.form.createSuccess"), { + notify.success(t("exam.form.createSuccess"), { description: t("exam.form.redirecting"), }) router.push(`/teacher/exams/${result.data}/build`) } else { - toast.error(result.message || t("exam.form.createFailed")) + notify.error(result.message || t("exam.form.createFailed")) } }) } diff --git a/src/modules/exams/components/exam-preview-utils.ts b/src/modules/exams/components/exam-preview-utils.ts index 890bf0d..0768600 100644 --- a/src/modules/exams/components/exam-preview-utils.ts +++ b/src/modules/exams/components/exam-preview-utils.ts @@ -1,12 +1,13 @@ import { createId } from "@paralleldrive/cuid2" import type { Dispatch, SetStateAction } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import type { useTranslations } from "next-intl" import { previewAiExamAction, type AiPreviewData } from "../actions" import type { ExamNode } from "./assembly/selected-question-list" import type { Question } from "@/modules/questions/types" import type { EditableQuestionContent, ExamFormValues, PreviewBackgroundTask, PreviewSnapshotMeta } from "./exam-form-types" import { previewTaskStorageKey } from "./exam-form-types" +import { isRecord } from "../lib/type-guards" type Translator = ReturnType @@ -228,12 +229,11 @@ export function buildTaskFormValues(values: ExamFormValues): NonNullable - return typeof obj.id === "string" - && (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed") - && typeof obj.createdAt === "number" - && typeof obj.title === "string" + if (!isRecord(v)) return false + return typeof v.id === "string" + && (v.status === "queued" || v.status === "running" || v.status === "success" || v.status === "failed") + && typeof v.createdAt === "number" + && typeof v.title === "string" } export function persistPreviewTasksToStorage(tasks: PreviewBackgroundTask[]) { @@ -276,18 +276,18 @@ export async function executeBackgroundPreviewTask( setPreviewTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "success", result: { title: data.title, nodes: nextNodes, rawOutput: data.rawOutput ?? "", meta: requestData.meta, formValues: buildTaskFormValues(values) } } : task)) - toast.success(t("exam.previewHook.backgroundComplete", { title: taskTitle })) + notify.success(t("exam.previewHook.backgroundComplete", { title: taskTitle })) return } setPreviewTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "failed", message: result.message || t("exam.previewHook.generatePreviewFailed") } : task)) - toast.error(t("exam.previewHook.backgroundFailed", { title: taskTitle })) + notify.error(t("exam.previewHook.backgroundFailed", { title: taskTitle })) } catch (error) { console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) setPreviewTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "failed", message: t("exam.previewHook.generatePreviewFailed") } : task)) - toast.error(t("exam.previewHook.backgroundFailed", { title: taskTitle })) + notify.error(t("exam.previewHook.backgroundFailed", { title: taskTitle })) } } diff --git a/src/modules/exams/components/exam-rich-form.tsx b/src/modules/exams/components/exam-rich-form.tsx index 3a23cf1..7186c2c 100644 --- a/src/modules/exams/components/exam-rich-form.tsx +++ b/src/modules/exams/components/exam-rich-form.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, useTransition, type FormEvent } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Sparkles, Save, FileText } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -28,9 +28,9 @@ import { type ExamRichEditorHandle, type EditorJSONContent, editorDocToStructure, - type EditorDoc, } from "../editor" import { ExamPreview } from "./exam-preview" +import { isJSONContent } from "../lib/type-guards" interface ExamRichFormValues { title: string @@ -119,7 +119,7 @@ export function ExamRichForm({ } } catch (e) { console.error("[ExamRichForm]", e) - toast.error(t("exam.richEditor.loadFormFailed")) + notify.error(t("exam.richEditor.loadFormFailed")) } finally { setLoadingSubjects(false) setLoadingGrades(false) @@ -135,7 +135,7 @@ export function ExamRichForm({ const handleAutoMark = () => { const currentText = editorRef.current?.getText() ?? "" if (!currentText.trim()) { - toast.error(t("exam.richEditor.emptyEditor")) + notify.error(t("exam.richEditor.emptyEditor")) return } startMarking(async () => { @@ -143,15 +143,19 @@ export function ExamRichForm({ formData.append("sourceText", currentText) const result = await autoMarkExamAction(null, formData) if (result.success && result.data) { - const doc = result.data.doc as EditorJSONContent + if (!isJSONContent(result.data.doc)) { + notify.error(t("exam.richEditor.aiMarkFailed")) + return + } + const doc = result.data.doc editorRef.current?.setJSON(doc) setEditorDoc(doc) if (result.data.title) { setValues((v) => ({ ...v, title: result.data!.title })) } - toast.success(result.message || t("exam.richEditor.aiMarkSuccess")) + notify.success(result.message || t("exam.richEditor.aiMarkSuccess")) } else { - toast.error(result.message || t("exam.richEditor.aiMarkFailed")) + notify.error(result.message || t("exam.richEditor.aiMarkFailed")) } }) } @@ -160,17 +164,17 @@ export function ExamRichForm({ e.preventDefault() const doc = editorRef.current?.getJSON() if (!doc) { - toast.error(t("exam.richEditor.emptyContent")) + notify.error(t("exam.richEditor.emptyContent")) return } if (!values.title.trim()) { - toast.error(t("exam.richEditor.titleRequired")) + notify.error(t("exam.richEditor.titleRequired")) return } if (isEditMode) { if (!examId) { - toast.error(t("exam.richForm.missingExamId")) + notify.error(t("exam.richForm.missingExamId")) return } startTransition(async () => { @@ -181,17 +185,17 @@ export function ExamRichForm({ const result = await updateExamFromRichEditorAction(null, formData) if (result.success) { - toast.success(result.message || t("exam.richEditor.saveSuccess")) + notify.success(result.message || t("exam.richEditor.saveSuccess")) router.push(`/teacher/exams/${examId}/build`) } else { - toast.error(result.message || t("exam.richEditor.saveFailed")) + notify.error(result.message || t("exam.richEditor.saveFailed")) } }) return } if (!values.subject || !values.grade) { - toast.error(t("exam.richEditor.subjectGradeRequired")) + notify.error(t("exam.richEditor.subjectGradeRequired")) return } @@ -209,10 +213,10 @@ export function ExamRichForm({ const result = await createExamFromRichEditorAction(null, formData) if (result.success && result.data) { - toast.success(result.message || t("exam.richEditor.saveSuccess")) + notify.success(result.message || t("exam.richEditor.saveSuccess")) router.push(`/teacher/exams/${result.data}/build`) } else { - toast.error(result.message || t("exam.richEditor.saveFailed")) + notify.error(result.message || t("exam.richEditor.saveFailed")) } }) } diff --git a/src/modules/exams/editor/exam-nodes-to-editor-doc.ts b/src/modules/exams/editor/exam-nodes-to-editor-doc.ts index d0b4cd9..1afe2bc 100644 --- a/src/modules/exams/editor/exam-nodes-to-editor-doc.ts +++ b/src/modules/exams/editor/exam-nodes-to-editor-doc.ts @@ -7,6 +7,7 @@ import type { EditorStructureNode, } from "./exam-rich-editor-types" import { toRichQuestionType } from "./exam-rich-editor-types" +import { isRecord } from "../lib/type-guards" /** * 将 BuildExam 页面的 ExamNode[] + Question[] 转换为 EditorDoc, @@ -95,14 +96,17 @@ export const examNodesToEditorDoc = ( /** 解析 Question.content(可能是 JSON 字符串或对象)为 RichQuestionContent */ const parseQuestionContent = (raw: unknown): EditorQuestion["content"] => { - if (raw && typeof raw === "object") { - return raw as EditorQuestion["content"] + if (isRecord(raw)) { + // isRecord 保证 raw 是非 null 对象,具体字段结构校验由下游 Zod schema 保证 + // 从 unknown 转换:Record → unknown → RichQuestionContent + return raw as unknown as EditorQuestion["content"] } if (typeof raw === "string") { try { const parsed: unknown = JSON.parse(raw) - if (parsed && typeof parsed === "object") { - return parsed as EditorQuestion["content"] + if (isRecord(parsed)) { + // 同上,结构校验由下游 Zod 保证 + return parsed as unknown as EditorQuestion["content"] } return { text: raw } } catch { diff --git a/src/modules/exams/editor/exam-rich-editor-inner.tsx b/src/modules/exams/editor/exam-rich-editor-inner.tsx index e264b89..e5aa6be 100644 --- a/src/modules/exams/editor/exam-rich-editor-inner.tsx +++ b/src/modules/exams/editor/exam-rich-editor-inner.tsx @@ -30,7 +30,7 @@ import { type QuestionBlockType, } from "./extensions"; import { SelectionToolbar } from "./selection-toolbar"; -import type { EditorJSONContent } from "./exam-rich-editor-types"; +import { isRichQuestionType, type EditorJSONContent } from "./exam-rich-editor-types"; export interface ExamRichEditorHandle { /** 获取当前编辑器文档(Tiptap JSONContent) */ @@ -245,7 +245,7 @@ export const ExamRichEditorInner = forwardRef 0; d--) { const node = $from.node(d); if (node.type.name === "questionBlock") { - return (node.attrs.type as QuestionBlockType) ?? "single_choice"; + return isRichQuestionType(node.attrs.type) ? node.attrs.type : "single_choice"; } } return undefined; diff --git a/src/modules/exams/editor/extensions/question-block.tsx b/src/modules/exams/editor/extensions/question-block.tsx index 4416e9c..f215f2a 100644 --- a/src/modules/exams/editor/extensions/question-block.tsx +++ b/src/modules/exams/editor/extensions/question-block.tsx @@ -3,6 +3,7 @@ import { Node, mergeAttributes } from "@tiptap/core" import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react" import { useTranslations } from "next-intl" +import { isQuestionBlockAttrs } from "../../lib/type-guards" export type QuestionBlockType = | "single_choice" @@ -30,7 +31,10 @@ declare module "@tiptap/core" { const QuestionView = ({ node, updateAttributes }: NodeViewProps) => { const t = useTranslations("examHomework") - const { type, score } = node.attrs as QuestionBlockAttrs + const attrs = isQuestionBlockAttrs(node.attrs) + ? node.attrs + : { questionId: "", type: "single_choice", score: 0 } + const { type, score } = attrs return (
diff --git a/src/modules/exams/editor/selection-toolbar.tsx b/src/modules/exams/editor/selection-toolbar.tsx index b142062..675d065 100644 --- a/src/modules/exams/editor/selection-toolbar.tsx +++ b/src/modules/exams/editor/selection-toolbar.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/shared/lib/utils" import { Button } from "@/shared/components/ui/button" import type { QuestionBlockType } from "./extensions/question-block" +import { isJSONContent, isJSONContentArray } from "../lib/type-guards" interface SelectionToolbarProps { editor: Editor | null @@ -209,10 +210,11 @@ export function SelectionToolbar({ // 然后插入新的 questionBlock。避免列表导致填空/简答题在预览中不显示。 const { from, to } = editor.state.selection const slice = editor.state.doc.slice(from, to) - const sliceContent = slice.content.toJSON - ? (slice.content.toJSON() as JSONContent[]) + const jsonOutput = slice.content.toJSON ? slice.content.toJSON() : null + const sliceContent = isJSONContentArray(jsonOutput) + ? jsonOutput : Array.isArray(slice.content.content) - ? slice.content.content.map((n) => n.toJSON() as JSONContent) + ? slice.content.content.map((n) => n.toJSON()).filter(isJSONContent) : [] const content = isChoice diff --git a/src/modules/exams/hooks/use-exam-preview-rewrite.ts b/src/modules/exams/hooks/use-exam-preview-rewrite.ts index 2c2bd62..65ba28e 100644 --- a/src/modules/exams/hooks/use-exam-preview-rewrite.ts +++ b/src/modules/exams/hooks/use-exam-preview-rewrite.ts @@ -1,7 +1,7 @@ "use client" import type { UseFormReturn } from "react-hook-form" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import type { useTranslations } from "next-intl" import type { usePreviewState } from "./use-exam-preview-state" import type { ExamFormValues } from "../components/exam-form-types" @@ -22,17 +22,17 @@ export function usePreviewRewrite( const handleRewriteSelectedQuestion = async () => { if (!selectedQuestionId) { - toast.error(t("exam.previewHook.selectQuestionFirst")) + notify.error(t("exam.previewHook.selectQuestionFirst")) return } const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId) if (!selected?.question) { - toast.error(t("exam.previewHook.questionNotFound")) + notify.error(t("exam.previewHook.questionNotFound")) return } const instruction = rewriteInstruction.trim() if (!instruction) { - toast.error(t("exam.previewHook.enterRewriteInstruction")) + notify.error(t("exam.previewHook.enterRewriteInstruction")) return } setRewritingQuestion(true) @@ -61,15 +61,15 @@ export function usePreviewRewrite( if (sourceText) formData.append("sourceText", sourceText) const result = await regenerateAiQuestionAction(null, formData) if (!result.success || !result.data) { - toast.error(result.message || t("exam.previewHook.aiRewriteFailed")) + notify.error(result.message || t("exam.previewHook.aiRewriteFailed")) return } updateSelectedQuestionFromAi(selectedQuestionId, result.data) setRewriteInstruction("") - toast.success(t("exam.previewHook.aiRewriteSuccess")) + notify.success(t("exam.previewHook.aiRewriteSuccess")) } catch (error) { console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) - toast.error(t("exam.previewHook.aiRewriteFailed")) + notify.error(t("exam.previewHook.aiRewriteFailed")) } finally { setRewritingQuestion(false) } diff --git a/src/modules/exams/hooks/use-exam-preview-state.ts b/src/modules/exams/hooks/use-exam-preview-state.ts index 3ed3c08..a57a203 100644 --- a/src/modules/exams/hooks/use-exam-preview-state.ts +++ b/src/modules/exams/hooks/use-exam-preview-state.ts @@ -3,7 +3,7 @@ import { useState } from "react" import type { AiPreviewData, AiRewriteQuestionData } from "../actions" import type { ExamNode } from "../components/assembly/selected-question-list" -import type { ExamFormValues, PreviewSnapshotMeta } from "../components/exam-form-types" +import type { PreviewSnapshotMeta } from "../components/exam-form-types" import { buildPreviewNodes, flattenPreviewQuestions, diff --git a/src/modules/exams/hooks/use-exam-preview-tasks.ts b/src/modules/exams/hooks/use-exam-preview-tasks.ts index 6517202..7ef7be0 100644 --- a/src/modules/exams/hooks/use-exam-preview-tasks.ts +++ b/src/modules/exams/hooks/use-exam-preview-tasks.ts @@ -1,8 +1,8 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { useEffect, useState } from "react" import type { UseFormReturn } from "react-hook-form" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import PQueue from "p-queue" import { createId } from "@paralleldrive/cuid2" import type { useTranslations } from "next-intl" @@ -24,22 +24,22 @@ export function usePreviewBackgroundTasks( state: ReturnType, ) { const [previewTasks, setPreviewTasks] = useState([]) - const previewQueueRef = useRef(null) - if (!previewQueueRef.current) previewQueueRef.current = new PQueue({ concurrency: 3 }) - const previewQueue = previewQueueRef.current + const [previewQueue] = useState(() => new PQueue({ concurrency: 3 })) useEffect(() => { try { const raw = window.localStorage.getItem(previewTaskStorageKey) const restoredTasks = raw ? parseStoredPreviewTasks(raw, t) : null if (!restoredTasks) return + // 挂载时从 localStorage 恢复任务,属外部系统同步场景,不能用懒初始化(SSR 水合不一致) + // eslint-disable-next-line react-hooks/set-state-in-effect -- 挂载时一次性恢复 localStorage 数据 setPreviewTasks(restoredTasks) if (restoredTasks.length > 0) form.setValue("mode", "ai") } catch (error) { console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) setPreviewTasks([]) } - }, [form]) + }, [form, t]) useEffect(() => () => { previewQueue.clear() }, [previewQueue]) useEffect(() => { persistPreviewTasksToStorage(previewTasks) }, [previewTasks]) @@ -47,7 +47,7 @@ export function usePreviewBackgroundTasks( const handleBackgroundPreview = () => { const values = form.getValues() const requestData = buildPreviewRequestData(values) - if (!requestData) { toast.error(t("exam.previewHook.pasteSourceFirst")); return } + if (!requestData) { notify.error(t("exam.previewHook.pasteSourceFirst")); return } const taskId = createId() const taskTitle = values.title?.trim() || t("exam.previewHook.untitledExam") setPreviewTasks((prev) => { @@ -55,7 +55,7 @@ export function usePreviewBackgroundTasks( persistPreviewTasksToStorage(next) return next }) - toast.success(t("exam.previewHook.queuedSuccess")) + notify.success(t("exam.previewHook.queuedSuccess")) void previewQueue.add(() => executeBackgroundPreviewTask(taskId, taskTitle, values, requestData, t, setPreviewTasks)) } diff --git a/src/modules/exams/hooks/use-exam-preview.ts b/src/modules/exams/hooks/use-exam-preview.ts index 839ce4f..95166bf 100644 --- a/src/modules/exams/hooks/use-exam-preview.ts +++ b/src/modules/exams/hooks/use-exam-preview.ts @@ -1,6 +1,6 @@ "use client" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import type { useTranslations } from "next-intl" import type { UseFormReturn } from "react-hook-form" @@ -34,7 +34,7 @@ export function useExamPreview(form: UseFormReturn, t: Translato const values = form.getValues() const requestData = buildPreviewRequestData(values) if (!requestData) { - toast.error(t("exam.previewHook.pasteSourceFirst")) + notify.error(t("exam.previewHook.pasteSourceFirst")) return } setPreviewOpen(false) @@ -49,11 +49,11 @@ export function useExamPreview(form: UseFormReturn, t: Translato if (result.success && result.data) { applyPreviewResult({ data: result.data, signature: requestData.signature, meta: requestData.meta }) } else { - toast.error(result.message || t("exam.previewHook.generatePreviewFailed")) + notify.error(result.message || t("exam.previewHook.generatePreviewFailed")) } } catch (error) { console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) - toast.error(t("exam.previewHook.generatePreviewFailed")) + notify.error(t("exam.previewHook.generatePreviewFailed")) } finally { setPreviewLoading(false) } diff --git a/src/modules/files/data-access.ts b/src/modules/files/data-access.ts index 2d915c1..6bbd52a 100644 --- a/src/modules/files/data-access.ts +++ b/src/modules/files/data-access.ts @@ -2,9 +2,9 @@ import "server-only" import { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm" import { cacheFn } from "@/shared/lib/cache" +import { serializeDateRequired as toIso } from "@/shared/lib/date-utils" import { db } from "@/shared/db" -import { createModuleLogger } from "@/shared/lib/logger" import { fileAttachments } from "@/shared/db/schema" import type { BatchDeleteResult, @@ -14,8 +14,19 @@ import type { FileStats, } from "./types" -const log = createModuleLogger("files") -const toIso = (d: Date): string => d.toISOString() +const fileAttachmentColumns = { + id: fileAttachments.id, + filename: fileAttachments.filename, + originalName: fileAttachments.originalName, + mimeType: fileAttachments.mimeType, + size: fileAttachments.size, + storagePath: fileAttachments.storagePath, + url: fileAttachments.url, + uploaderId: fileAttachments.uploaderId, + targetType: fileAttachments.targetType, + targetId: fileAttachments.targetId, + createdAt: fileAttachments.createdAt, +} const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({ id: row.id, @@ -37,45 +48,35 @@ const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({ export async function createFileAttachment( data: CreateFileAttachmentInput ): Promise { - try { - await db.insert(fileAttachments).values({ - id: data.id, - filename: data.filename, - originalName: data.originalName, - mimeType: data.mimeType, - size: data.size, - storagePath: data.storagePath, - url: data.url, - uploaderId: data.uploaderId, - targetType: data.targetType ?? null, - targetId: data.targetId ?? null, - }) + await db.insert(fileAttachments).values({ + id: data.id, + filename: data.filename, + originalName: data.originalName, + mimeType: data.mimeType, + size: data.size, + storagePath: data.storagePath, + url: data.url, + uploaderId: data.uploaderId, + targetType: data.targetType ?? null, + targetId: data.targetId ?? null, + }) - const created = await getFileAttachment(data.id) - return created - } catch (error) { - log.error({ err: error }, "createFileAttachment failed") - return null - } + const created = await getFileAttachment(data.id) + return created } /** * 按 ID 查询文件附件 */ export const getFileAttachmentRaw = async (id: string): Promise => { - try { - const [row] = await db - .select() - .from(fileAttachments) - .where(eq(fileAttachments.id, id)) - .limit(1) + const [row] = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where(eq(fileAttachments.id, id)) + .limit(1) - return row ? mapRow(row) : null - } catch (error) { - log.error({ err: error }, "getFileAttachment failed") - return null - } - } + return row ? mapRow(row) : null +} export const getFileAttachment = cacheFn(getFileAttachmentRaw, { tags: ["files"], @@ -87,24 +88,19 @@ export const getFileAttachment = cacheFn(getFileAttachmentRaw, { * 按关联资源查询文件列表 */ export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise => { - try { - const rows = await db - .select() - .from(fileAttachments) - .where( - and( - eq(fileAttachments.targetType, targetType), - eq(fileAttachments.targetId, targetId) - ) - ) - .orderBy(desc(fileAttachments.createdAt)) + const rows = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where( + and( + eq(fileAttachments.targetType, targetType), + eq(fileAttachments.targetId, targetId) + ) + ) + .orderBy(desc(fileAttachments.createdAt)) - return rows.map(mapRow) - } catch (error) { - log.error({ err: error }, "getFileAttachmentsByTarget failed") - return [] - } - } + return rows.map(mapRow) +} export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, { tags: ["files"], @@ -116,19 +112,14 @@ export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, * 按上传者查询文件列表 */ export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise => { - try { - const rows = await db - .select() - .from(fileAttachments) - .where(eq(fileAttachments.uploaderId, uploaderId)) - .orderBy(desc(fileAttachments.createdAt)) + const rows = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where(eq(fileAttachments.uploaderId, uploaderId)) + .orderBy(desc(fileAttachments.createdAt)) - return rows.map(mapRow) - } catch (error) { - log.error({ err: error }, "getFileAttachmentsByUploader failed") - return [] - } - } + return rows.map(mapRow) +} export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, { tags: ["files"], @@ -140,19 +131,14 @@ export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploader * 查询所有文件(用于管理员文件管理页面) */ export const getAllFileAttachmentsRaw = async (limit = 100): Promise => { - try { - const rows = await db - .select() - .from(fileAttachments) - .orderBy(desc(fileAttachments.createdAt)) - .limit(limit) + const rows = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .orderBy(desc(fileAttachments.createdAt)) + .limit(limit) - return rows.map(mapRow) - } catch (error) { - log.error({ err: error }, "getAllFileAttachments failed") - return [] - } - } + return rows.map(mapRow) +} export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, { tags: ["files"], @@ -164,13 +150,8 @@ export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, { * 删除文件附件记录 */ export async function deleteFileAttachment(id: string): Promise { - try { - await db.delete(fileAttachments).where(eq(fileAttachments.id, id)) - return true - } catch (error) { - log.error({ err: error }, "deleteFileAttachment failed") - return false - } + await db.delete(fileAttachments).where(eq(fileAttachments.id, id)) + return true } /** @@ -181,29 +162,8 @@ export async function deleteFileAttachments(ids: string[]): Promise => { - try { - const { mimeType, search, limit = 100, offset = 0 } = params + const { mimeType, search, limit = 100, offset = 0 } = params - const conditions: SQL[] = [] - if (mimeType) { - if (mimeType.endsWith("/")) { - conditions.push(like(fileAttachments.mimeType, `${mimeType}%`)) - } else { - conditions.push(eq(fileAttachments.mimeType, mimeType)) - } - } - if (search) { - const kw = `%${search}%` - const nameCondition = or( - like(fileAttachments.originalName, kw), - like(fileAttachments.filename, kw) - ) - if (nameCondition) conditions.push(nameCondition) - } - - const where = conditions.length > 0 ? and(...conditions) : undefined - - const rows = await db - .select() - .from(fileAttachments) - .where(where) - .orderBy(desc(fileAttachments.createdAt)) - .limit(limit) - .offset(offset) - - return rows.map(mapRow) - } catch (error) { - log.error({ err: error }, "getFileAttachmentsWithFilters failed") - return [] + const conditions: SQL[] = [] + if (mimeType) { + if (mimeType.endsWith("/")) { + conditions.push(like(fileAttachments.mimeType, `${mimeType}%`)) + } else { + conditions.push(eq(fileAttachments.mimeType, mimeType)) } } + if (search) { + const kw = `%${search}%` + const nameCondition = or( + like(fileAttachments.originalName, kw), + like(fileAttachments.filename, kw) + ) + if (nameCondition) conditions.push(nameCondition) + } + + const where = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where(where) + .orderBy(desc(fileAttachments.createdAt)) + .limit(limit) + .offset(offset) + + return rows.map(mapRow) +} export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFiltersRaw, { tags: ["files"], @@ -259,31 +214,26 @@ export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFilte * 获取文件统计信息(总数、总大小、按类型分组) */ export const getFileStatsRaw = async (): Promise => { - try { - const rows = await db - .select({ - mimeType: fileAttachments.mimeType, - count: count(), - size: sql`COALESCE(SUM(${fileAttachments.size}), 0)`, - }) - .from(fileAttachments) - .groupBy(fileAttachments.mimeType) + const rows = await db + .select({ + mimeType: fileAttachments.mimeType, + count: count(), + size: sql`COALESCE(SUM(${fileAttachments.size}), 0)`, + }) + .from(fileAttachments) + .groupBy(fileAttachments.mimeType) - const byType = rows.map((r) => ({ - mimeType: r.mimeType, - count: Number(r.count), - size: Number(r.size), - })) + const byType = rows.map((r) => ({ + mimeType: r.mimeType, + count: Number(r.count), + size: Number(r.size), + })) - const totalCount = byType.reduce((sum, r) => sum + r.count, 0) - const totalSize = byType.reduce((sum, r) => sum + r.size, 0) + const totalCount = byType.reduce((sum, r) => sum + r.count, 0) + const totalSize = byType.reduce((sum, r) => sum + r.size, 0) - return { totalCount, totalSize, byType } - } catch (error) { - log.error({ err: error }, "getFileStats failed") - return { totalCount: 0, totalSize: 0, byType: [] } - } - } + return { totalCount, totalSize, byType } +} export const getFileStats = cacheFn(getFileStatsRaw, { tags: ["files"], @@ -295,19 +245,14 @@ export const getFileStats = cacheFn(getFileStatsRaw, { * 按 URL 查询文件附件(用于头像等场景的旧文件清理) */ export const getFileByUrlRaw = async (url: string): Promise => { - try { - const [row] = await db - .select() - .from(fileAttachments) - .where(eq(fileAttachments.url, url)) - .limit(1) + const [row] = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where(eq(fileAttachments.url, url)) + .limit(1) - return row ? mapRow(row) : null - } catch (error) { - log.error({ err: error }, "getFileByUrl failed") - return null - } - } + return row ? mapRow(row) : null +} export const getFileByUrl = cacheFn(getFileByUrlRaw, { tags: ["files"], @@ -319,18 +264,13 @@ export const getFileByUrl = cacheFn(getFileByUrlRaw, { * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) */ export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise => { - if (ids.length === 0) return [] - try { - const rows = await db - .select() - .from(fileAttachments) - .where(inArray(fileAttachments.id, ids)) - return rows.map(mapRow) - } catch (error) { - log.error({ err: error }, "getFileAttachmentsByIds failed") - return [] - } - } + if (ids.length === 0) return [] + const rows = await db + .select(fileAttachmentColumns) + .from(fileAttachments) + .where(inArray(fileAttachments.id, ids)) + return rows.map(mapRow) +} export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, { tags: ["files"], diff --git a/src/modules/files/hooks/use-file-batch-operations.ts b/src/modules/files/hooks/use-file-batch-operations.ts index cbef105..a122da9 100644 --- a/src/modules/files/hooks/use-file-batch-operations.ts +++ b/src/modules/files/hooks/use-file-batch-operations.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react" import { useRouter } from "next/navigation" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import type { FileAttachment } from "../types" @@ -101,15 +101,15 @@ export function useFileBatchOperations({ deletedCount?: number } | null if (!res.ok || !body?.success) { - toast.error(body?.message || t("selection.deleteFailed")) + notify.error(body?.message || t("selection.deleteFailed")) return } - toast.success(t("selection.deleted", { count: body.deletedCount ?? 0 })) + notify.success(t("selection.deleted", { count: body.deletedCount ?? 0 })) setSelectedIds(new Set()) onAfterDelete?.() router.refresh() } catch { - toast.error(t("selection.deleteFailed")) + notify.error(t("selection.deleteFailed")) } finally { setDeleting(false) } diff --git a/src/modules/files/hooks/use-file-upload.ts b/src/modules/files/hooks/use-file-upload.ts index b0d0dec..3c70a96 100644 --- a/src/modules/files/hooks/use-file-upload.ts +++ b/src/modules/files/hooks/use-file-upload.ts @@ -1,7 +1,7 @@ "use client" import { useCallback, useRef, useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { ALLOWED_MIME_TYPES, @@ -83,7 +83,7 @@ export function useFileUpload({ : tk ) ) - toast.error(t("error", { name: file.name, message: validationError })) + notify.error(t("error", { name: file.name, message: validationError })) return } @@ -137,7 +137,7 @@ export function useFileUpload({ ) ) onUploaded?.(result) - toast.success(t("success", { name: file.name })) + notify.success(t("success", { name: file.name })) } catch (e) { const message = e instanceof Error ? e.message : t("networkError") setTasks((prev) => @@ -145,7 +145,7 @@ export function useFileUpload({ tk.file === file ? { ...tk, status: "error", message } : tk ) ) - toast.error(t("error", { name: file.name, message })) + notify.error(t("error", { name: file.name, message })) } }, [targetType, targetId, onUploaded, t, validateFile] diff --git a/src/modules/grades/components/batch-grade-entry.tsx b/src/modules/grades/components/batch-grade-entry.tsx index d6a1626..2d12461 100644 --- a/src/modules/grades/components/batch-grade-entry.tsx +++ b/src/modules/grades/components/batch-grade-entry.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type ClipboardEvent } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -208,7 +208,7 @@ export function BatchGradeEntryByExam({ return merged }) e.preventDefault() - toast.success(t("batchByExam.pasteApplied", { count: pastedCount })) + notify.success(t("batchByExam.pasteApplied", { count: pastedCount })) } } @@ -237,7 +237,7 @@ export function BatchGradeEntryByExam({ const handleSubmit = async (): Promise => { if (!exam || !defaultClassId || !defaultExamId) return if (hasInvalidScores) { - toast.error(t("batchByExam.invalidScoresError")) + notify.error(t("batchByExam.invalidScoresError")) return } @@ -260,7 +260,7 @@ export function BatchGradeEntryByExam({ .filter((r): r is NonNullable => r !== null) if (records.length === 0) { - toast.error(t("batchByExam.enterAtLeastOne")) + notify.error(t("batchByExam.enterAtLeastOne")) return } @@ -273,7 +273,7 @@ export function BatchGradeEntryByExam({ const result = await safeActionCall( () => batchCreateGradeRecordsByExamAction(null, formData), { - onError: () => toast.error(t("error.saveFailed")), + onError: () => notify.error(t("error.saveFailed")), onFinally: () => setIsSubmitting(false), } ) @@ -283,7 +283,7 @@ export function BatchGradeEntryByExam({ const createdIds = result.data ?? [] if (createdIds.length > 0) { saveUndoToken(createdIds) - toast.success(result.message, { + notify.success(result.message, { duration: 10000, action: { label: t("batchByExam.undo"), @@ -291,12 +291,12 @@ export function BatchGradeEntryByExam({ }, }) } else { - toast.success(result.message) + notify.success(result.message) } router.push("/teacher/grades") router.refresh() } else if (result) { - toast.error(result.message || t("error.saveFailed")) + notify.error(result.message || t("error.saveFailed")) } } diff --git a/src/modules/grades/components/excel-import-dialog.tsx b/src/modules/grades/components/excel-import-dialog.tsx index fa3d032..eaa05e8 100644 --- a/src/modules/grades/components/excel-import-dialog.tsx +++ b/src/modules/grades/components/excel-import-dialog.tsx @@ -3,7 +3,7 @@ import type { JSX } from "react" import { useRef, useState } from "react" import { Upload, Download, FileSpreadsheet, Loader2, AlertCircle, CheckCircle2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" @@ -123,22 +123,22 @@ export function ExcelImportDialog({ const handleDownloadTemplate = async () => { if (!form.classId) { - toast.error(t("excelImport.errorSelectClass")) + notify.error(t("excelImport.errorSelectClass")) return } setIsDownloadingTemplate(true) const r = await safeActionCall( () => downloadGradeImportTemplateAction({ classId: form.classId }), { - onError: () => toast.error(t("excelImport.errorTemplateDownload")), + onError: () => notify.error(t("excelImport.errorTemplateDownload")), onFinally: () => setIsDownloadingTemplate(false), } ) if (r?.success && r.data) { downloadBase64File(r.data.buffer, r.data.filename) - toast.success(t("excelImport.templateDownloaded")) + notify.success(t("excelImport.templateDownloaded")) } else if (r) { - toast.error(r.message ?? t("excelImport.errorTemplateDownload")) + notify.error(r.message ?? t("excelImport.errorTemplateDownload")) } } @@ -150,11 +150,11 @@ export function ExcelImportDialog({ const handleSubmit = async () => { if (!file) { - toast.error(t("excelImport.errorNoFile")) + notify.error(t("excelImport.errorNoFile")) return } if (!form.classId || !form.subjectId || !form.title) { - toast.error(t("excelImport.errorMissingFields")) + notify.error(t("excelImport.errorMissingFields")) return } @@ -172,7 +172,7 @@ export function ExcelImportDialog({ const r = await safeActionCall( () => importGradesFromExcelAction(null, formData), { - onError: () => toast.error(t("excelImport.errorImport")), + onError: () => notify.error(t("excelImport.errorImport")), onFinally: () => setIsImporting(false), } ) @@ -180,16 +180,16 @@ export function ExcelImportDialog({ if (r?.success && r.data) { setResult(r.data) if (r.data.failedCount === 0) { - toast.success(t("excelImport.successAllImported", { count: r.data.successCount })) + notify.success(t("excelImport.successAllImported", { count: r.data.successCount })) } else { - toast.warning(t("excelImport.successPartial", { + notify.warning(t("excelImport.successPartial", { success: r.data.successCount, failed: r.data.failedCount, })) } onSuccess?.() } else if (r) { - toast.error(r.message ?? t("excelImport.errorImport")) + notify.error(r.message ?? t("excelImport.errorImport")) if (r.data) { setResult(r.data) } diff --git a/src/modules/grades/components/export-button.tsx b/src/modules/grades/components/export-button.tsx index 23924ff..536c3c4 100644 --- a/src/modules/grades/components/export-button.tsx +++ b/src/modules/grades/components/export-button.tsx @@ -2,7 +2,7 @@ import type { JSX } from "react" import { useState } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Download, Loader2 } from "lucide-react" import { useTranslations } from "next-intl" @@ -39,7 +39,7 @@ export function ExportButton({ const handleExport = async (reportType: "detail" | "class"): Promise => { if (!classId) { - toast.error(t("export.selectClassFirst")) + notify.error(t("export.selectClassFirst")) return } setIsExporting(true) @@ -53,16 +53,16 @@ export function ExportButton({ reportType, }), { - onError: () => toast.error(t("export.failed")), + onError: () => notify.error(t("export.failed")), onFinally: () => setIsExporting(false), } ) if (result?.success && result.data) { downloadBase64File(result.data.buffer, result.data.filename) - toast.success(t("export.success")) + notify.success(t("export.success")) } else if (result) { - toast.error(result.message ?? t("export.failed")) + notify.error(result.message ?? t("export.failed")) } } diff --git a/src/modules/grades/components/grade-record-form.tsx b/src/modules/grades/components/grade-record-form.tsx index 1a53ac6..1e1eba6 100644 --- a/src/modules/grades/components/grade-record-form.tsx +++ b/src/modules/grades/components/grade-record-form.tsx @@ -3,7 +3,7 @@ import type { JSX } from "react" import { useState } from "react" import { useFormStatus } from "react-dom" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -52,7 +52,7 @@ export function GradeRecordForm({ const handleSubmit = async (formData: FormData) => { if (!classId || !subjectId || !studentId) { - toast.error(t("form.selectPrompt")) + notify.error(t("form.selectPrompt")) return } formData.set("classId", classId) @@ -65,15 +65,15 @@ export function GradeRecordForm({ const result = await safeActionCall( () => createGradeRecordAction(null, formData), { - onError: () => toast.error(t("error.failedToCreate")), + onError: () => notify.error(t("error.failedToCreate")), } ) if (result?.success) { - toast.success(result.message) + notify.success(result.message) router.push("/teacher/grades") router.refresh() } else if (result) { - toast.error(result.message || t("error.failedToCreate")) + notify.error(result.message || t("error.failedToCreate")) } } diff --git a/src/modules/grades/components/grade-record-list.tsx b/src/modules/grades/components/grade-record-list.tsx index fed49c3..160421b 100644 --- a/src/modules/grades/components/grade-record-list.tsx +++ b/src/modules/grades/components/grade-record-list.tsx @@ -2,7 +2,7 @@ import type { JSX } from "react" import { useState, useOptimistic, useTransition } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { Trash2 } from "lucide-react" @@ -52,16 +52,16 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) const result = await safeActionCall( () => deleteGradeRecordAction(deleteId), { - onError: () => toast.error(t("error.deleteFailed")), + onError: () => notify.error(t("error.deleteFailed")), onFinally: () => setIsDeleting(false), } ) if (result?.success) { - toast.success(result.message) + notify.success(result.message) setDeleteId(null) router.refresh() } else if (result) { - toast.error(result.message || t("error.deleteFailed")) + notify.error(result.message || t("error.deleteFailed")) } } @@ -101,17 +101,17 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) const result = await safeActionCall( () => bulkDeleteGradeRecordsAction(ids), { - onError: () => toast.error(t("list.bulkDeleteFailed")), + onError: () => notify.error(t("list.bulkDeleteFailed")), onFinally: () => setIsBulkDeleting(false), } ) if (result?.success) { - toast.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 })) + notify.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 })) clearSelection() setIsBulkDeleteDialogOpen(false) router.refresh() } else if (result) { - toast.error(result.message || t("list.bulkDeleteFailed")) + notify.error(result.message || t("list.bulkDeleteFailed")) } } @@ -147,16 +147,16 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) const result = await safeActionCall( () => updateGradeRecordAction(targetId, null, formData), { - onError: () => toast.error(t("edit.failed")), + onError: () => notify.error(t("edit.failed")), } ) if (result?.success) { - toast.success(t("edit.success")) + notify.success(t("edit.success")) setEditTarget(null) // 同步数据源,让 useOptimistic 回滚到最新服务端值 await router.refresh() } else if (result) { - toast.error(result.message || t("edit.failed")) + notify.error(result.message || t("edit.failed")) } }) } diff --git a/src/modules/grades/components/knowledge-point-mastery-chart.tsx b/src/modules/grades/components/knowledge-point-mastery-chart.tsx index e7535db..50bb2ea 100644 --- a/src/modules/grades/components/knowledge-point-mastery-chart.tsx +++ b/src/modules/grades/components/knowledge-point-mastery-chart.tsx @@ -76,12 +76,13 @@ export function KnowledgePointMasteryChart({ const { chartData, sortedStats, weakPoints, averageMastery, cellColors } = useMemo(() => { if (data.length === 0) { + const emptyColors: Record = {} return { chartData: [], sortedStats: [], weakPoints: [], averageMastery: 0, - cellColors: {} as Record, + cellColors: emptyColors, } } diff --git a/src/modules/grades/components/report-card-print-action.tsx b/src/modules/grades/components/report-card-print-action.tsx index 5258147..64093a8 100644 --- a/src/modules/grades/components/report-card-print-action.tsx +++ b/src/modules/grades/components/report-card-print-action.tsx @@ -3,7 +3,7 @@ import type { JSX } from "react" import { useState } from "react" import { Printer, Loader2 } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" @@ -24,7 +24,7 @@ export function ReportCardPrintAction(): JSX.Element { try { window.print() } catch { - toast.error(t("reportCard.errorPrint")) + notify.error(t("reportCard.errorPrint")) } finally { // 等待一帧后重置状态,确保用户感知到"准备中"反馈 window.requestAnimationFrame(() => { diff --git a/src/modules/grades/components/report-card-print-button.tsx b/src/modules/grades/components/report-card-print-button.tsx index 4f374e1..30cd09b 100644 --- a/src/modules/grades/components/report-card-print-button.tsx +++ b/src/modules/grades/components/report-card-print-button.tsx @@ -4,7 +4,7 @@ import type { JSX } from "react" import { useState, useTransition } from "react" import { useRouter } from "next/navigation" import { Printer } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" @@ -55,7 +55,7 @@ export function ReportCardPrintButton({ try { router.push(`/teacher/grades/report-card?${params.toString()}`) } catch { - toast.error(t("reportCard.errorNavigate")) + notify.error(t("reportCard.errorNavigate")) } }) } diff --git a/src/modules/grades/data-access-analytics.ts b/src/modules/grades/data-access-analytics.ts index ca6d15e..8315c6f 100644 --- a/src/modules/grades/data-access-analytics.ts +++ b/src/modules/grades/data-access-analytics.ts @@ -1,831 +1,57 @@ import "server-only" -import { cacheFn } from "@/shared/lib/cache" -import { and, asc, eq, inArray, isNotNull, ne } from "drizzle-orm" - -import { db } from "@/shared/db" -import { gradeRecords } from "@/shared/db/schema" -import { - getClassesByGradeId, - getClassNameById, - getStudentActiveClassId, -} from "@/modules/classes/data-access" -import { getAcademicYears, getGrades, getSubjectOptions } from "@/modules/school/data-access" -import { getUserNamesByIds } from "@/modules/users/data-access" -import type { DataScope } from "@/shared/types/permissions" - -import { normalize, toNumber } from "./lib/grade-utils" -import { buildScopeClassFilter } from "./lib/scope-filter" -import { - buildGradeTrendPoints, - buildGrowthArchivePoints, - computeAverageScore, - computeClassComparisonStats, - computeGradeDistribution, - computeGradeStats, - computeSignificance, - computeSubjectComparisonStats, - computeTrendAverage, - findBucketIndex, -} from "./stats-service" -import type { - ClassComparisonItem, - ClassComparisonResult, - ClassComparisonSignificance, - GradeDistributionByGradeResult, - GradeDistributionResult, - GradeDistributionWithPosition, - GradeTrendResult, - SchoolWideGradeSummary, - SchoolWideGradeSummaryItem, - StudentGrowthArchiveResult, - SubjectComparisonItem, -} from "./types" - -export interface GradeTrendParams { - classId: string - subjectId?: string - studentId?: string - semester?: "1" | "2" - examId?: string - scope: DataScope - currentUserId?: string -} - -export const getGradeTrendRaw = async (params: GradeTrendParams): Promise => { - const conditions = [eq(gradeRecords.classId, params.classId)] - if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) - if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - - if (params.scope.type === "class_members" && params.currentUserId) { - conditions.push(eq(gradeRecords.studentId, params.currentUserId)) - } - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ - record: gradeRecords, - }) - .from(gradeRecords) - .where(and(...conditions)) - .orderBy(asc(gradeRecords.createdAt)) - - if (rows.length === 0) return null - - // Fetch display names via cross-module interfaces - const className = await getClassNameById(params.classId) - let subjectName = "All Subjects" - if (params.subjectId) { - const subjectOptions = await getSubjectOptions() - const subject = subjectOptions.find((s) => s.id === params.subjectId) - subjectName = subject?.name ?? "Unknown" - } - - const points = buildGradeTrendPoints(rows) - const avg = computeTrendAverage(points) - const finalClassName = className ?? "Class" - const studentLabel = params.studentId - ? `Student ${params.studentId.slice(-4)}` - : "Class Average" - - return { - label: params.subjectId - ? `${finalClassName} · ${subjectName} · ${studentLabel}` - : `${finalClassName} · ${studentLabel}`, - points, - averageScore: avg, - } - } - -export const getGradeTrend = cacheFn(getGradeTrendRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getGradeTrend"], -}) - -export interface ClassComparisonParams { - gradeId: string - subjectId: string - examId?: string - semester?: "1" | "2" - scope: DataScope -} - -export const getClassComparisonRaw = async (params: ClassComparisonParams): Promise => { - const classRows = await getClassesByGradeId(params.gradeId) - - if (classRows.length === 0) return [] - - const scope = params.scope - const scopeClassIdSet = - scope.type === "class_taught" ? new Set(scope.classIds) : null - const allowedClassRows = scopeClassIdSet - ? classRows.filter((c) => scopeClassIdSet.has(c.id)) - : classRows - - if (allowedClassRows.length === 0) return [] - - const allowedClassIds = allowedClassRows.map((c) => c.id) - - // P3 修复:在 SQL where 中使用 inArray 过滤,并通过 buildScopeClassFilter 应用 scope 行级过滤 - const conditions = [ - inArray(gradeRecords.classId, allowedClassIds), - eq(gradeRecords.subjectId, params.subjectId), - ] - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const allRows = await db - .select({ - classId: gradeRecords.classId, - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - studentId: gradeRecords.studentId, - }) - .from(gradeRecords) - .where(and(...conditions)) - - const byClass = new Map() - for (const r of allRows) { - const existing = byClass.get(r.classId) - if (existing) { - existing.push(r) - } else { - byClass.set(r.classId, [r]) - } - } - - const result: ClassComparisonItem[] = allowedClassRows.map((cls) => { - const rows = byClass.get(cls.id) ?? [] - const stats = computeClassComparisonStats(rows) - return { - classId: cls.id, - className: cls.name, - ...stats, - } - }) - - return result - } - -export const getClassComparison = cacheFn(getClassComparisonRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getClassComparison"], -}) - /** - * P3-5: 获取班级对比数据 + 班级间统计显著性分析结果。 + * 成绩分析数据访问层(barrel 重新导出) * - * 显著性分析流程: - * 1. 复用 getClassComparison 获取各班级聚合 stats - * 2. 找到均分最高与最低班级 - * 3. 从 allRows 中提取这两个班级的归一化分数数组 - * 4. 调用 stats-service.computeSignificance 计算 Cohen's d 与 p 值 + * G2-005 / S-01 治理(2026-07-07):原 data-access-analytics.ts 831 行超过 800 行建议上限, + * 拆分为 4 个按分析维度划分的子文件。本文件仅作为公共 API 入口, + * 保持向后兼容,所有消费者无需修改 import 路径。 * - * 返回的 significance 为 null 的场景: - * - 班级数 < 2 - * - 任意班级样本量 < 2 - * - 方差为 0(所有分数相同) + * 子文件结构: + * - data-access-analytics-trend.ts: 成绩趋势分析(getGradeTrend + 考试选项 getExamOptionsForGrades) + * - data-access-analytics-class.ts: 班级/科目/年级维度分析(班级对比 + 显著性 + 科目对比 + 分布 + 年级分布) + * - data-access-analytics-student.ts: 学生维度分析(学生班级分布位置 + 成长档案) + * - data-access-analytics-overview.ts: 全校汇总分析(管理员视图) * - * 该函数复用 getClassComparison 的 cache(参数一致),不会触发额外 DB 查询。 + * 拆分后行数:trend ~135 / class ~340 / student ~225 / overview ~135,均远低于 800 行上限。 */ -export const getClassComparisonWithSignificanceRaw = async (params: ClassComparisonParams): Promise => { - const items = await getClassComparison(params) - if (items.length < 2) { - return { items, significance: null } - } - - // 找出均分最高与最低班级 - let topClass = items[0] - let bottomClass = items[0] - for (const item of items) { - if (item.averageScore > topClass.averageScore) topClass = item - if (item.averageScore < bottomClass.averageScore) bottomClass = item - } - - if (topClass.classId === bottomClass.classId) { - return { items, significance: null } - } - - // 重新查询两个班级的原始分数(归一化 0-100),用于统计检验 - const conditions = [ - inArray(gradeRecords.classId, [topClass.classId, bottomClass.classId]), - eq(gradeRecords.subjectId, params.subjectId), - ] - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const rawRows = await db - .select({ - classId: gradeRecords.classId, - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - }) - .from(gradeRecords) - .where(and(...conditions)) - - const topScores: number[] = [] - const bottomScores: number[] = [] - for (const r of rawRows) { - const normalized = normalize(toNumber(r.score), toNumber(r.fullScore)) - if (r.classId === topClass.classId) topScores.push(normalized) - else if (r.classId === bottomClass.classId) bottomScores.push(normalized) - } - - const sigResult = computeSignificance(topScores, bottomScores) - if (!sigResult) { - return { items, significance: null } - } - - const significance: ClassComparisonSignificance = { - topClassId: topClass.classId, - bottomClassId: bottomClass.classId, - cohensD: sigResult.cohensD, - effectSizeLabel: sigResult.effectSizeLabel, - pValue: sigResult.pValue, - isSignificant: sigResult.isSignificant, - } - - return { items, significance } - } - -export const getClassComparisonWithSignificance = cacheFn(getClassComparisonWithSignificanceRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getClassComparisonWithSignificance"], -}) - -/** - * P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。 - * - * 流程: - * 1. 通过 classes.getStudentActiveClassId 查询学生所在班级 - * 2. 查询该班级所有学生的成绩记录(不含学生 ID,仅聚合计数) - * 3. 查询该学生最近的归一化分数 - * 4. 调用 stats-service.findBucketIndex 计算学生所在桶索引 - * - * 返回 null 的场景: - * - 学生未分配到任何班级 - * - 班级无成绩记录 - * - * @param studentId - 当前学生 ID - * @param scope - 当前用户的数据范围(学生为 class_members) - */ -export const getStudentPositionInClassDistributionRaw = async ( - studentId: string, - scope: DataScope - ): Promise => { - // 仅学生(class_members scope)调用,且必须传 studentId - if (scope.type !== "class_members") return null - - const classId = await getStudentActiveClassId(studentId) - if (!classId) return null - - const className = (await getClassNameById(classId)) ?? "" - - // 查询该班级全部学生的成绩记录(聚合计数,不含个人身份) - const conditions = [eq(gradeRecords.classId, classId)] - const scopeFilter = buildScopeClassFilter(scope) - if (scopeFilter) conditions.push(scopeFilter) - - const allRows = await db - .select({ - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - studentId: gradeRecords.studentId, - createdAt: gradeRecords.createdAt, - }) - .from(gradeRecords) - .where(and(...conditions)) - - if (allRows.length === 0) return null - - const distribution = computeGradeDistribution( - allRows.map((r) => ({ score: r.score, fullScore: r.fullScore })) - ) - - // 获取该学生最近一条成绩记录的归一化分数 - const studentRows = allRows - .filter((r) => r.studentId === studentId) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - - if (studentRows.length === 0) { - return { - distribution, - classId, - className, - studentNormalizedScore: null, - studentBucketIndex: -1, - } - } - - const latestRow = studentRows[0] - const studentNormalizedScore = normalize( - toNumber(latestRow.score), - toNumber(latestRow.fullScore) - ) - const bucketIndex = findBucketIndex(distribution.buckets, studentNormalizedScore) - - return { - distribution, - classId, - className, - studentNormalizedScore, - studentBucketIndex: bucketIndex, - } - } - -export const getStudentPositionInClassDistribution = cacheFn(getStudentPositionInClassDistributionRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getStudentPositionInClassDistribution"], -}) - -/** - * P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。 - * - * 流程: - * 1. 校验 class_taught scope(学生必须属于教师所教班级) - * 2. 获取学生姓名 - * 3. 获取所有学年信息(id → { name, startDate }) - * 4. 查询学生的全部成绩记录(含 academicYearId) - * 5. 调用 stats-service.buildGrowthArchivePoints 计算成长点序列 - * 6. 计算总览统计(overallAverage / totalRecords / totalSubjects / totalAcademicYears / growthDelta) - * - * 返回 null 的场景: - * - 学生不存在 - * - 学生无任何成绩记录 - * - * 注:class_members / children scope 的越权校验由 action 层完成(需 ctx.userId) - * - * @param studentId - 学生 ID - * @param scope - 当前用户的数据范围 - */ -export const getStudentGrowthArchiveRaw = async ( - studentId: string, - scope: DataScope - ): Promise => { - // scope 校验:仅 class_taught 需在 data-access 层校验(其余由 action 层处理) - if (scope.type === "class_taught") { - const studentClassId = await getStudentActiveClassId(studentId) - if (studentClassId && !scope.classIds.includes(studentClassId)) { - return null - } - } - - // 获取学生姓名 - const studentNameMap = await getUserNamesByIds([studentId]) - const studentName = studentNameMap.get(studentId)?.name ?? null - if (!studentName && !studentNameMap.has(studentId)) return null - - // 获取所有学年信息 - const academicYears = await getAcademicYears() - const academicYearMap = new Map() - for (const y of academicYears) { - academicYearMap.set(y.id, { - name: y.name, - startDate: new Date(y.startDate), - }) - } - - // 查询学生的全部成绩记录(含 academicYearId 非空过滤) - const conditions = [ - eq(gradeRecords.studentId, studentId), - isNotNull(gradeRecords.academicYearId), - ne(gradeRecords.academicYearId, ""), - ] - - const scopeFilter = buildScopeClassFilter(scope, studentId) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ - academicYearId: gradeRecords.academicYearId, - semester: gradeRecords.semester, - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - subjectId: gradeRecords.subjectId, - title: gradeRecords.title, - }) - .from(gradeRecords) - .where(and(...conditions)) - - if (rows.length === 0) { - return null - } - - // 构建原始数据行(合并学年信息) - const archiveRows = rows - .map((r) => { - const yearId = r.academicYearId - if (!yearId) return null - const yearInfo = academicYearMap.get(yearId) - if (!yearInfo) return null - return { - academicYearId: yearId, - academicYearName: yearInfo.name, - academicYearStart: yearInfo.startDate, - semester: r.semester as "1" | "2", - score: r.score, - fullScore: r.fullScore, - subjectId: r.subjectId, - title: r.title, - } - }) - .filter((r): r is NonNullable => r !== null) - - if (archiveRows.length === 0) { - return null - } - - // 调用 stats-service 纯函数构建成长点序列 - const points = buildGrowthArchivePoints(archiveRows) - if (points.length === 0) { - return null - } - - // 计算总览统计 - const allNormalizedScores = rows - .map((r) => normalize(toNumber(r.score), toNumber(r.fullScore))) - const overallAverage = computeAverageScore(allNormalizedScores) - const totalSubjects = new Set(rows.map((r) => r.subjectId)).size - const totalAcademicYears = new Set( - archiveRows.map((r) => r.academicYearId) - ).size - - // 成长趋势:最新点平均分 - 第一个点平均分 - const firstPoint = points[0] - const lastPoint = points[points.length - 1] - const growthDelta = Math.round( - (lastPoint.averageScore - firstPoint.averageScore) * 100 - ) / 100 - - return { - studentId, - studentName: studentName ?? "Unknown", - points, - overallAverage, - totalRecords: rows.length, - totalSubjects, - totalAcademicYears, - growthDelta, - } - } - -export const getStudentGrowthArchive = cacheFn(getStudentGrowthArchiveRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getStudentGrowthArchive"], -}) - -export interface SubjectComparisonParams { - classId: string - examId?: string - semester?: "1" | "2" - scope: DataScope -} - -export const getSubjectComparisonRaw = async (params: SubjectComparisonParams): Promise => { - const scopeFilter = buildScopeClassFilter(params.scope) - const conditions = [eq(gradeRecords.classId, params.classId)] - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ - subjectId: gradeRecords.subjectId, - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - }) - .from(gradeRecords) - .where(and(...conditions)) - - if (rows.length === 0) return [] - - // Fetch subject names via cross-module interface - const subjectOptions = await getSubjectOptions() - const subjectNameById = new Map() - for (const s of subjectOptions) subjectNameById.set(s.id, s.name) - - const bySubject = new Map() - - for (const r of rows) { - const sid = r.subjectId - if (!sid) continue - const entry = bySubject.get(sid) ?? { name: subjectNameById.get(sid) ?? "Unknown", scores: [] } - entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore))) - bySubject.set(sid, entry) - } - - const result: SubjectComparisonItem[] = [] - for (const [subjectId, entry] of bySubject.entries()) { - if (entry.scores.length === 0) continue - const stats = computeSubjectComparisonStats(entry.scores) - result.push({ - subjectId, - subjectName: entry.name, - ...stats, - }) - } - - return result.sort((a, b) => b.averageScore - a.averageScore) - } - -export const getSubjectComparison = cacheFn(getSubjectComparisonRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getSubjectComparison"], -}) - -export interface GradeDistributionParams { - classId: string - subjectId?: string - examId?: string - semester?: "1" | "2" - scope: DataScope - currentUserId?: string -} - -export const getGradeDistributionRaw = async (params: GradeDistributionParams): Promise => { - const conditions = [eq(gradeRecords.classId, params.classId)] - if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - - if (params.scope.type === "class_members" && params.currentUserId) { - conditions.push(eq(gradeRecords.studentId, params.currentUserId)) - } - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore }) - .from(gradeRecords) - .where(and(...conditions)) - - return computeGradeDistribution(rows) - } - -export const getGradeDistribution = cacheFn(getGradeDistributionRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getGradeDistribution"], -}) - -/** - * v3-P2-7: 获取指定班级/科目下有成绩记录的考试列表,用于分析页考试筛选下拉框。 - * 仅返回 examId 非空且去重后的考试选项。 - */ -export const getExamOptionsForGradesRaw = async (params: { - classId: string - subjectId?: string - scope: DataScope - }): Promise> => { - const conditions = [ - eq(gradeRecords.classId, params.classId), - isNotNull(gradeRecords.examId), - ne(gradeRecords.examId, ""), - ] - if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ - examId: gradeRecords.examId, - title: gradeRecords.title, - }) - .from(gradeRecords) - .where(and(...conditions)) - - // 去重:同一 examId 可能有多条成绩记录,保留第一条的 title - const seen = new Set() - const result: Array<{ id: string; title: string }> = [] - for (const r of rows) { - if (!r.examId || seen.has(r.examId)) continue - seen.add(r.examId) - result.push({ id: r.examId, title: r.title }) - } - return result - } - -export const getExamOptionsForGrades = cacheFn(getExamOptionsForGradesRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getExamOptionsForGrades"], -}) - -/** - * v3-P2-9: 获取全校各年级成绩汇总(管理员视图)。 - * 按年级聚合平均分、及格率、优秀率、学生数、班级数。 - * 仅限管理员(scope.type === "all")调用。 - */ -export const getSchoolWideGradeSummaryRaw = async (scope: DataScope): Promise => { - // 管理员权限校验:仅 all scope 可调用 - if (scope.type !== "all") { - return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } - } - - const allGrades = await getGrades() - if (allGrades.length === 0) { - return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } - } - - // 并行查询每个年级的班级列表 - const gradeClassMaps = await Promise.all( - allGrades.map(async (g) => ({ - grade: g, - classList: await getClassesByGradeId(g.id), - })), - ) - - // 过滤掉无班级的年级 - const validGradeClassMaps = gradeClassMaps.filter((m) => m.classList.length > 0) - if (validGradeClassMaps.length === 0) { - return { grades: [], totals: { gradeCount: 0, classCount: 0, studentCount: 0, recordCount: 0, averageScore: 0, passRate: 0, excellentRate: 0 } } - } - - // 并行查询每个年级的成绩记录(通过 classId inArray 过滤) - const gradeStatsResults = await Promise.all( - validGradeClassMaps.map(async (m) => { - const classIds = m.classList.map((c) => c.id) - const rows = await db - .select({ - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - studentId: gradeRecords.studentId, - }) - .from(gradeRecords) - .where(inArray(gradeRecords.classId, classIds)) - - const stats = computeGradeStats(rows) - const uniqueStudents = new Set(rows.map((r) => r.studentId)) - - return { - grade: m.grade, - classCount: m.classList.length, - studentCount: uniqueStudents.size, - recordCount: rows.length, - stats, - } - }), - ) - - const gradeItems: SchoolWideGradeSummaryItem[] = gradeStatsResults - .filter((r): r is typeof r & { stats: NonNullable } => r.stats !== null) - .map((r) => { - const stats = r.stats - return { - gradeId: r.grade.id, - gradeName: r.grade.name, - schoolName: r.grade.school.name, - classCount: r.classCount, - studentCount: r.studentCount, - recordCount: r.recordCount, - averageScore: stats.average, - passRate: stats.passRate, - excellentRate: stats.excellentRate, - } - }) - .sort((a, b) => a.schoolName.localeCompare(b.schoolName) || a.gradeName.localeCompare(b.gradeName)) - - // 计算全校汇总(按记录数加权平均) - const totalClassCount = gradeItems.reduce((sum, g) => sum + g.classCount, 0) - const totalStudentCount = gradeItems.reduce((sum, g) => sum + g.studentCount, 0) - const totalRecordCount = gradeItems.reduce((sum, g) => sum + g.recordCount, 0) - - const weightedAvg = totalRecordCount > 0 - ? Math.round((gradeItems.reduce((sum, g) => sum + g.averageScore * g.recordCount, 0) / totalRecordCount) * 100) / 100 - : 0 - const weightedPassRate = totalRecordCount > 0 - ? Math.round((gradeItems.reduce((sum, g) => sum + g.passRate * g.recordCount, 0) / totalRecordCount) * 100) / 100 - : 0 - const weightedExcellentRate = totalRecordCount > 0 - ? Math.round((gradeItems.reduce((sum, g) => sum + g.excellentRate * g.recordCount, 0) / totalRecordCount) * 100) / 100 - : 0 - - return { - grades: gradeItems, - totals: { - gradeCount: gradeItems.length, - classCount: totalClassCount, - studentCount: totalStudentCount, - recordCount: totalRecordCount, - averageScore: weightedAvg, - passRate: weightedPassRate, - excellentRate: weightedExcellentRate, - }, - } - } - -export const getSchoolWideGradeSummary = cacheFn(getSchoolWideGradeSummaryRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getSchoolWideGradeSummary"], -}) - -/** - * 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。 - * 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询成绩记录, - * 应用 buildScopeClassFilter 行级权限过滤,复用 computeGradeDistribution / computeGradeStats 纯函数。 - */ -export interface GradeDistributionByGradeParams { - gradeId: string - subjectId?: string - examId?: string - semester?: "1" | "2" - scope: DataScope -} - -export const getGradeDistributionByGradeIdRaw = async (params: GradeDistributionByGradeParams): Promise => { - const classRows = await getClassesByGradeId(params.gradeId) - - if (classRows.length === 0) { - return { - overall: computeGradeDistribution([]), - stats: null, - byClass: [], - } - } - - // scope 过滤:class_taught 仅保留当前教师所教班级 - const scope = params.scope - const scopeClassIdSet = - scope.type === "class_taught" ? new Set(scope.classIds) : null - const allowedClassRows = scopeClassIdSet - ? classRows.filter((c) => scopeClassIdSet.has(c.id)) - : classRows - - if (allowedClassRows.length === 0) { - return { - overall: computeGradeDistribution([]), - stats: null, - byClass: [], - } - } - - const allowedClassIds = allowedClassRows.map((c) => c.id) - - const conditions = [inArray(gradeRecords.classId, allowedClassIds)] - if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) - if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) - if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) - - const scopeFilter = buildScopeClassFilter(params.scope) - if (scopeFilter) conditions.push(scopeFilter) - - const rows = await db - .select({ - classId: gradeRecords.classId, - score: gradeRecords.score, - fullScore: gradeRecords.fullScore, - }) - .from(gradeRecords) - .where(and(...conditions)) - - const overall = computeGradeDistribution(rows) - const stats = computeGradeStats(rows) - - const byClassMap = new Map() - for (const r of rows) { - const existing = byClassMap.get(r.classId) - if (existing) { - existing.push(r) - } else { - byClassMap.set(r.classId, [r]) - } - } - - const byClass = allowedClassRows.map((cls) => { - const classRowsForClass = byClassMap.get(cls.id) ?? [] - return { - classId: cls.id, - className: cls.name, - distribution: computeGradeDistribution(classRowsForClass), - stats: computeGradeStats(classRowsForClass), - } - }) - - return { overall, stats, byClass } - } - -export const getGradeDistributionByGradeId = cacheFn(getGradeDistributionByGradeIdRaw, { - tags: ["grades"], - ttl: 300, - keyParts: ["grades", "getGradeDistributionByGradeId"], -}) +// G3-048: 对导出数 < 15 的子文件改为显式 re-export。 +export { + getGradeTrendRaw, + getGradeTrend, + getExamOptionsForGradesRaw, + getExamOptionsForGrades, +} from "./data-access-analytics-trend" +export type { GradeTrendParams } from "./data-access-analytics-trend" + +export { + getClassComparisonRaw, + getClassComparison, + getClassComparisonWithSignificanceRaw, + getClassComparisonWithSignificance, + getSubjectComparisonRaw, + getSubjectComparison, + getGradeDistributionRaw, + getGradeDistribution, + getGradeDistributionByGradeIdRaw, + getGradeDistributionByGradeId, +} from "./data-access-analytics-class" +export type { + ClassComparisonParams, + SubjectComparisonParams, + GradeDistributionParams, + GradeDistributionByGradeParams, +} from "./data-access-analytics-class" + +export { + getStudentPositionInClassDistributionRaw, + getStudentPositionInClassDistribution, + getStudentGrowthArchiveRaw, + getStudentGrowthArchive, +} from "./data-access-analytics-student" + +export { + getSchoolWideGradeSummaryRaw, + getSchoolWideGradeSummary, +} from "./data-access-analytics-overview" diff --git a/src/modules/grades/hooks/use-batch-grade-entry-undo.ts b/src/modules/grades/hooks/use-batch-grade-entry-undo.ts index 6ce3a02..6ab6689 100644 --- a/src/modules/grades/hooks/use-batch-grade-entry-undo.ts +++ b/src/modules/grades/hooks/use-batch-grade-entry-undo.ts @@ -1,7 +1,7 @@ "use client" import { useState, useCallback } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" @@ -65,32 +65,32 @@ export function useBatchGradeEntryUndo(): { try { const raw = sessionStorage.getItem(STORAGE_KEY) if (!raw) { - toast.error(t("batchByExam.undoNoRecord")) + notify.error(t("batchByExam.undoNoRecord")) return } const parsed: unknown = JSON.parse(raw) // P1-7 修复:使用类型守卫替代 `as` 断言 if (!isUndoData(parsed)) { - toast.error(t("batchByExam.undoFailed")) + notify.error(t("batchByExam.undoFailed")) return } if (Date.now() - parsed.timestamp > UNDO_TTL_MS) { - toast.error(t("batchByExam.undoExpired")) + notify.error(t("batchByExam.undoExpired")) return } const result = await undoBatchCreateGradeRecordsAction(parsed.ids) if (result.success) { sessionStorage.removeItem(STORAGE_KEY) - toast.success(result.message) + notify.success(result.message) router.refresh() } else { - toast.error(result.message || t("batchByExam.undoFailed")) + notify.error(result.message || t("batchByExam.undoFailed")) } } catch { - toast.error(t("batchByExam.undoFailed")) + notify.error(t("batchByExam.undoFailed")) } finally { setIsUndoing(false) } diff --git a/src/modules/grades/hooks/use-draft-lock.ts b/src/modules/grades/hooks/use-draft-lock.ts index 24bd25d..38d20c5 100644 --- a/src/modules/grades/hooks/use-draft-lock.ts +++ b/src/modules/grades/hooks/use-draft-lock.ts @@ -1,7 +1,7 @@ "use client" import { useState, useEffect, useCallback, useRef } from "react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useTranslations } from "next-intl" import { safeActionCall } from "@/shared/lib/action-utils" @@ -87,7 +87,7 @@ export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn { setLockToken(null) tokenRef.current = null } else { - toast.error(t("collabLock.acquireFailed")) + notify.error(t("collabLock.acquireFailed")) } }, [classId, subjectId, type, t]) @@ -125,7 +125,7 @@ export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn { setStatus(result?.data?.status ?? null) setLockToken(null) tokenRef.current = null - toast.error(t("collabLock.heartbeatError")) + notify.error(t("collabLock.heartbeatError")) } }, HEARTBEAT_INTERVAL_MS) diff --git a/src/modules/homework/components/homework-assignment-form.tsx b/src/modules/homework/components/homework-assignment-form.tsx index 5631e4a..b3268d4 100644 --- a/src/modules/homework/components/homework-assignment-form.tsx +++ b/src/modules/homework/components/homework-assignment-form.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react" import { useFormStatus } from "react-dom" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { useRouter } from "next/navigation" import { useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" @@ -50,15 +50,15 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[] const handleSubmit = async (formData: FormData) => { if (mode === "exam" && !examId) { - toast.error(t("homework.form.selectExamRequired")) + notify.error(t("homework.form.selectExamRequired")) return } if (mode === "quick" && !formData.get("title")) { - toast.error(t("homework.form.titleRequired")) + notify.error(t("homework.form.titleRequired")) return } if (!classId) { - toast.error(t("homework.form.selectClassRequired")) + notify.error(t("homework.form.selectClassRequired")) return } @@ -75,13 +75,13 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[] try { const result = await createHomeworkAssignmentAction(null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) router.push("/teacher/homework/assignments") } else { - toast.error(result.message || t("homework.form.createFailed")) + notify.error(result.message || t("homework.form.createFailed")) } } catch { - toast.error(t("homework.form.createFailed")) + notify.error(t("homework.form.createFailed")) } finally { setIsSubmitting(false) } diff --git a/src/modules/homework/components/homework-batch-grading-view.tsx b/src/modules/homework/components/homework-batch-grading-view.tsx index 460533e..980d4d4 100644 --- a/src/modules/homework/components/homework-batch-grading-view.tsx +++ b/src/modules/homework/components/homework-batch-grading-view.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { Zap } from "lucide-react" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" @@ -68,7 +68,7 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi const handleBatchAutoGrade = () => { if (selectedIds.size === 0) { - toast.error(t("homework.grade.batchSelectAtLeastOne")) + notify.error(t("homework.grade.batchSelectAtLeastOne")) return } @@ -77,11 +77,11 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi formData.set("submissionIds", JSON.stringify(Array.from(selectedIds))) const result = await batchAutoGradeSubmissionsAction(null, formData) if (result.success) { - toast.success(result.message) + notify.success(result.message) setSelectedIds(new Set()) router.refresh() } else { - toast.error(result.message || t("homework.grade.batchFailed")) + notify.error(result.message || t("homework.grade.batchFailed")) } }) } diff --git a/src/modules/homework/components/homework-grading-view.tsx b/src/modules/homework/components/homework-grading-view.tsx index bdd87cd..72d1e83 100644 --- a/src/modules/homework/components/homework-grading-view.tsx +++ b/src/modules/homework/components/homework-grading-view.tsx @@ -4,7 +4,7 @@ import type { JSX } from "react" import { useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { gradeHomeworkSubmissionAction } from "../actions" import { @@ -105,13 +105,13 @@ export function HomeworkGradingView({ const result = await gradeHomeworkSubmissionAction(null, formData) if (result.success) { - toast.success(t("homework.grade.gradesSaved")) + notify.success(t("homework.grade.gradesSaved")) router.refresh() } else { - toast.error(result.message || t("homework.grade.gradesSaveFailed")) + notify.error(result.message || t("homework.grade.gradesSaveFailed")) } } catch { - toast.error(t("homework.grade.gradesSaveFailed")) + notify.error(t("homework.grade.gradesSaveFailed")) } finally { setIsSubmitting(false) } diff --git a/src/modules/homework/components/homework-scan-grading-view.tsx b/src/modules/homework/components/homework-scan-grading-view.tsx index 750c0e7..ab2dd0e 100644 --- a/src/modules/homework/components/homework-scan-grading-view.tsx +++ b/src/modules/homework/components/homework-scan-grading-view.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -92,12 +92,12 @@ export function HomeworkScanGradingView({ fd.set("answersJson", JSON.stringify(answersPayload)) const result = await gradeHomeworkSubmissionAction(null, fd) if (result.success) { - toast.success(t("homework.grade.gradesSaved")) + notify.success(t("homework.grade.gradesSaved")) } else { - toast.error(result.message || t("homework.grade.gradesSaveFailed")) + notify.error(result.message || t("homework.grade.gradesSaveFailed")) } } catch { - toast.error(t("homework.grade.saveFailed")) + notify.error(t("homework.grade.saveFailed")) } finally { setIsSaving(false) } diff --git a/src/modules/homework/components/homework-take-view.tsx b/src/modules/homework/components/homework-take-view.tsx index 3c3a2b5..2a95cb8 100644 --- a/src/modules/homework/components/homework-take-view.tsx +++ b/src/modules/homework/components/homework-take-view.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Button } from "@/shared/components/ui/button" import { ScrollArea } from "@/shared/components/ui/scroll-area" @@ -49,7 +49,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView if (!submissionId) return const result = await deleteScanAction(submissionId, fileId) if (!result.success) { - toast.error(result.message || t("homework.take.saveFailed")) + notify.error(result.message || t("homework.take.saveFailed")) } } @@ -100,7 +100,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView } } if (changed) { - toast.success(t("homework.take.autoSaveRestored")) + notify.success(t("homework.take.autoSaveRestored")) } return merged }) @@ -142,13 +142,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView if (res.success && res.data) { setSubmissionId(res.data) setSubmissionStatus("started") - toast.success(t("homework.take.startSuccess")) + notify.success(t("homework.take.startSuccess")) router.refresh() } else { - toast.error(res.message || t("homework.take.startFailed")) + notify.error(res.message || t("homework.take.startFailed")) } } catch { - toast.error(t("homework.take.startFailed")) + notify.error(t("homework.take.startFailed")) } finally { setIsBusy(false) } @@ -162,8 +162,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView fd.set("questionId", questionId) fd.set("answerJson", JSON.stringify({ answer: payload })) const res = await saveHomeworkAnswerAction(null, fd) - if (res.success) toast.success(t("homework.take.saved")) - else toast.error(res.message || t("homework.take.saveFailed")) + if (res.success) notify.success(t("homework.take.saved")) + else notify.error(res.message || t("homework.take.saveFailed")) } const handleSubmit = async () => { @@ -178,15 +178,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView const submitRes = await submitHomeworkAction(null, submitFd) if (submitRes.success) { clearOfflineCache(offlineStorageKey) - toast.success(t("homework.take.submitSuccess")) + notify.success(t("homework.take.submitSuccess")) setSubmissionStatus("submitted") // V3-9: 提交后跳转到结果页,展示即时反馈 router.push(`/student/learning/assignments/${assignmentId}/result`) } else { - toast.error(submitRes.message || t("homework.take.submitFailed")) + notify.error(submitRes.message || t("homework.take.submitFailed")) } } catch { - toast.error(t("homework.take.submitFailed")) + notify.error(t("homework.take.submitFailed")) } finally { setIsBusy(false) } @@ -217,7 +217,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView enabled: isTimedExam, onExpire: () => { if (submissionStatus === "started" && submissionId) { - toast.warning(t("homework.take.timeUpAutoSubmit")) + notify.warning(t("homework.take.timeUpAutoSubmit")) void handleSubmit() } }, diff --git a/src/modules/homework/components/scan-uploader.tsx b/src/modules/homework/components/scan-uploader.tsx index df1dd68..654ed03 100644 --- a/src/modules/homework/components/scan-uploader.tsx +++ b/src/modules/homework/components/scan-uploader.tsx @@ -3,7 +3,7 @@ import { useState, useTransition, type ChangeEvent } from "react" import Image from "next/image" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Upload, X, Loader2, ImageIcon } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -55,7 +55,7 @@ export function ScanUploader({ (f) => f.type.startsWith("image/") || f.type === "application/pdf" ) if (fileArray.length === 0) { - toast.error(t("homework.take.selectImageFiles")) + notify.error(t("homework.take.selectImageFiles")) return } @@ -80,17 +80,17 @@ export function ScanUploader({ page: basePage + i + 1, }) } else { - toast.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`) + notify.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`) } } catch (e) { console.error("[ScanUploader] upload failed", e) - toast.error(`${t("homework.take.uploadFailed")}: ${file.name}`) + notify.error(`${t("homework.take.uploadFailed")}: ${file.name}`) } } if (uploaded.length > 0) { const next = [...images, ...uploaded] onChange(next) - toast.success(t("homework.take.uploadSuccess", { count: uploaded.length })) + notify.success(t("homework.take.uploadSuccess", { count: uploaded.length })) } }) } diff --git a/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx b/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx index 687bbbf..62c6e4a 100644 --- a/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx +++ b/src/modules/invitation-codes/components/generate-invitation-codes-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useTranslations } from "next-intl" -import { toast } from "sonner" +import { notify } from "@/shared/lib/notify" import { Copy, Hash, Mail, GraduationCap, Clock, FileText } from "lucide-react" import { Button } from "@/shared/components/ui/button" @@ -30,6 +30,7 @@ import { useActionMutation } from "@/shared/hooks/use-action-mutation" import { generateInvitationCodesAction } from "../actions" import { INVITATION_ROLE_VALUES } from "../schema" import type { InvitationCodeRecord, InvitationRole } from "../types" +import { isInvitationRole } from "../lib/type-guards" /** * 生成邀请码对话框(audit-P2-3 新增)。 @@ -78,7 +79,7 @@ export function GenerateInvitationCodesDialog({ setGeneratedCodes(data.codes) setBatchId(data.batchId) setView("result") - toast.success(t("generate.success", { count: data.codes.length })) + notify.success(t("generate.success", { count: data.codes.length })) } }, }) @@ -122,18 +123,18 @@ export function GenerateInvitationCodesDialog({ const text = generatedCodes.map((c) => c.code).join("\n") try { await navigator.clipboard.writeText(text) - toast.success(t("actions.copied")) + notify.success(t("actions.copied")) } catch { - toast.error(t("actions.copyFailed")) + notify.error(t("actions.copyFailed")) } } const handleCopyOne = async (code: string): Promise => { try { await navigator.clipboard.writeText(code) - toast.success(t("actions.copied")) + notify.success(t("actions.copied")) } catch { - toast.error(t("actions.copyFailed")) + notify.error(t("actions.copyFailed")) } } @@ -239,7 +240,7 @@ function GenerateForm({