feat(modules-assessment): update exams, files, grades, homework, invitation-codes, leave-requests

- exams: update assembly (exam-paper-preview, question-bank-list, structure-editor),

  exam-actions, exam-assembly, exam-columns, exam-data-table, exam-form,

  exam-preview-utils, exam-rich-form, editor (exam-nodes-to-editor-doc,

  exam-rich-editor-inner, question-block, selection-toolbar),

  hooks (use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks,

  use-exam-preview)

- files: update data-access, use-file-batch-operations, use-file-upload

- grades: update batch-grade-entry, excel-import-dialog, export-button,

  grade-record-form, grade-record-list, knowledge-point-mastery-chart,

  report-card-print-action, report-card-print-button, data-access-analytics,

  use-batch-grade-entry-undo, use-draft-lock

- homework: update homework-assignment-form, homework-batch-grading-view,

  homework-grading-view, homework-scan-grading-view, homework-take-view, scan-uploader

- invitation-codes: update generate-invitation-codes-dialog, invitation-codes-view, data-access

- leave-requests: update leave-request-form, leave-review-dialog, data-access
This commit is contained in:
SpecialX
2026-07-07 16:19:46 +08:00
parent d7017f0e30
commit 2adf61faa8
44 changed files with 434 additions and 1237 deletions

View File

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

View File

@@ -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<string>(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 }

View File

@@ -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<string, unknown>
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<string, unknown>
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

View File

@@ -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<string, unknown> 以进行字段检查
const obj = v as Record<string, unknown>
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)
}

View File

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

View File

@@ -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, unknown>) => string
type TranslationFn = (key: string, params?: Record<string, string | number | Date>) => string
export function createExamColumns(t: TranslationFn): ColumnDef<Exam>[] {
return [

View File

@@ -40,7 +40,7 @@ export function ExamDataTable({ data }: DataTableProps) {
const parentRef = React.useRef<HTMLDivElement>(null)
const columns = React.useMemo(
() => createExamColumns((key, params) => t(key, params as Record<string, string | number | Date> | undefined)),
() => createExamColumns((key, params) => t(key, params)),
[t]
)

View File

@@ -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<ExamFormValues> 显式标注以替代 as any从 zodResolver 返回类型收窄
// 需经 unknown 中转以规避 TS 逆变检查(合规:从 unknown 转换
const form = useForm<ExamFormValues>({
resolver: zodResolver(formSchema) as Resolver<ExamFormValues>,
resolver: zodResolver(formSchema) as unknown as Resolver<ExamFormValues>,
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"))
}
})
}

View File

@@ -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<typeof useTranslations>
@@ -228,12 +229,11 @@ export function buildTaskFormValues(values: ExamFormValues): NonNullable<Preview
}
export function isPreviewBackgroundTask(v: unknown): v is PreviewBackgroundTask {
if (!v || typeof v !== "object") return false
const obj = v as Record<string, unknown>
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 }))
}
}

View File

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

View File

@@ -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<string, unknown> → 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 {

View File

@@ -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<ExamRichEditorHandle, ExamRichEdit
for (let d = $from.depth; d > 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;

View File

@@ -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 (
<NodeViewWrapper className="my-3 rounded-md border bg-card p-3">
<div className="mb-2 flex items-center gap-2 border-b pb-2">

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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<typeof usePreviewState>,
) {
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
const previewQueueRef = useRef<PQueue | null>(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))
}

View File

@@ -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<ExamFormValues>, 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<ExamFormValues>, 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)
}

View File

@@ -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<FileAttachment | null> {
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<FileAttachment | null> => {
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<FileAttachment[]> => {
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<FileAttachment[]> => {
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<FileAttachment[]> => {
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<boolean> {
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<BatchDeleteR
if (ids.length === 0) {
return { success: true, deletedCount: 0, failedIds: [] }
}
try {
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
return { success: true, deletedCount: ids.length, failedIds: [] }
} catch (error) {
log.error({ err: error }, "deleteFileAttachments batch failed")
// 失败时回退到逐条删除,尽量多删
const failedIds: string[] = []
let deletedCount = 0
for (const id of ids) {
try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
deletedCount += 1
} catch (err) {
log.error({ err }, "deleteFileAttachments single failed")
failedIds.push(id)
}
}
return {
success: failedIds.length === 0,
deletedCount,
failedIds,
}
}
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
return { success: true, deletedCount: ids.length, failedIds: [] }
}
/**
@@ -212,42 +172,37 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
* - search: 在 originalName / filename 中模糊匹配
*/
export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
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<FileStats> => {
try {
const rows = await db
.select({
mimeType: fileAttachments.mimeType,
count: count(),
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
})
.from(fileAttachments)
.groupBy(fileAttachments.mimeType)
const rows = await db
.select({
mimeType: fileAttachments.mimeType,
count: count(),
size: sql<number>`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<FileAttachment | null> => {
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<FileAttachment[]> => {
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"],

View File

@@ -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)
}

View File

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

View File

@@ -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<void> => {
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<typeof r> => 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"))
}
}

View File

@@ -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)
}

View File

@@ -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<void> => {
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"))
}
}

View File

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

View File

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

View File

@@ -76,12 +76,13 @@ export function KnowledgePointMasteryChart({
const { chartData, sortedStats, weakPoints, averageMastery, cellColors } = useMemo(() => {
if (data.length === 0) {
const emptyColors: Record<string, string> = {}
return {
chartData: [],
sortedStats: [],
weakPoints: [],
averageMastery: 0,
cellColors: {} as Record<string, string>,
cellColors: emptyColors,
}
}

View File

@@ -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(() => {

View File

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

View File

@@ -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<GradeTrendResult | null> => {
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<ClassComparisonItem[]> => {
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<string, typeof allRows>()
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<ClassComparisonResult> => {
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<GradeDistributionWithPosition | null> => {
// 仅学生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<StudentGrowthArchiveResult | null> => {
// 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<string, { name: string; startDate: Date }>()
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<typeof r> => 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<SubjectComparisonItem[]> => {
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<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const bySubject = new Map<string, { name: string; scores: number[] }>()
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<GradeDistributionResult> => {
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<Array<{ id: string; title: string }>> => {
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<string>()
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<SchoolWideGradeSummary> => {
// 管理员权限校验:仅 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<typeof r.stats> } => 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<GradeDistributionByGradeResult> => {
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<string, typeof rows>()
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"

View File

@@ -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)
}

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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()
}
},

View File

@@ -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 }))
}
})
}

View File

@@ -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<void> => {
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({
<Label htmlFor="role">{t("generate.role")}</Label>
<Select
value={role}
onValueChange={(v) => setRole(v as InvitationRole)}
onValueChange={(v) => setRole(isInvitationRole(v) ? v : "student")}
>
<SelectTrigger id="role">
<SelectValue />

View File

@@ -30,7 +30,7 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { formatDateTime } from "@/shared/lib/utils"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { deleteInvitationCodeAction } from "../actions"
import type { InvitationCodeRecord, InvitationRole } from "../types"
@@ -92,9 +92,9 @@ export function InvitationCodesView({ initialCodes, now }: InvitationCodesViewPr
const handleCopy = async (code: string): Promise<void> => {
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"))
}
}

View File

@@ -10,13 +10,13 @@ import type {
ConsumeInvitationCodeInput,
GenerateInvitationCodesInput,
InvitationCodeRecord,
InvitationRole,
ValidateInvitationCodeResult,
} from "./types"
import {
generateBatchId,
generateUniqueInvitationCodes,
} from "./lib/code-generator"
import { toInvitationRole } from "./lib/type-guards"
/** 数据库行 → InvitationCodeRecord 映射 */
function toRecord(row: typeof invitationCodes.$inferSelect): InvitationCodeRecord {
@@ -24,7 +24,7 @@ function toRecord(row: typeof invitationCodes.$inferSelect): InvitationCodeRecor
id: row.id.toString(),
code: row.code,
email: row.email,
role: row.role as InvitationRole,
role: toInvitationRole(row.role),
classId: row.classId,
notes: row.notes,
createdAt: row.createdAt,

View File

@@ -4,7 +4,7 @@ import { useActionState, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Loader2, Send } from "lucide-react"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -79,11 +79,11 @@ export function LeaveRequestForm({
useEffect(() => {
if (state.success) {
toast.success(state.message)
notify.success(state.message)
formRef.current?.reset()
onSuccess?.()
} else if (state.message) {
toast.error(state.message)
notify.error(state.message)
}
}, [state, onSuccess])

View File

@@ -4,7 +4,7 @@ import { useActionState, useEffect, useRef, useState } from "react"
import { useFormStatus } from "react-dom"
import { useTranslations } from "next-intl"
import { Check, Loader2, X } from "lucide-react"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button"
import {
@@ -71,12 +71,12 @@ export function LeaveReviewDialog({
useEffect(() => {
if (state.success) {
toast.success(state.message)
notify.success(state.message)
onOpenChange(false)
formRef.current?.reset()
onReviewed?.()
} else if (state.message) {
toast.error(state.message)
notify.error(state.message)
}
}, [state, onOpenChange, onReviewed])

View File

@@ -13,12 +13,11 @@ import type { DataScope } from "@/shared/types/permissions"
import type {
LeaveRequest,
LeaveRequestListItem,
LeaveStatus,
LeaveType,
PaginatedLeaveResult,
LeaveQueryParams,
} from "./types"
import type { CreateLeaveRequestInput, ReviewLeaveRequestInput } from "./schema"
import { toLeaveStatus, toLeaveType } from "./lib/type-guards"
/**
* 构建 dataScope 过滤条件。
@@ -60,11 +59,11 @@ function serializeRequest(row: typeof leaveRequests.$inferSelect): LeaveRequest
studentId: row.studentId,
requesterId: row.requesterId,
classId: row.classId,
leaveType: row.leaveType as LeaveType,
leaveType: toLeaveType(row.leaveType),
startDate: serializeDate(row.startDate),
endDate: serializeDate(row.endDate),
reason: row.reason,
status: row.status as LeaveStatus,
status: toLeaveStatus(row.status),
reviewerId: row.reviewerId ?? null,
reviewComment: row.reviewComment ?? null,
reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,