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 { useMemo } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { ExamNode } from "./selected-question-list" import type { ExamNode } from "./selected-question-list"
import { isQuestionContentObj } from "../../lib/type-guards"
type ChoiceOption = { type ChoiceOption = {
id: string id: string
@@ -24,11 +25,27 @@ type ExamPaperPreviewProps = {
} }
const parseContent = (raw: unknown): QuestionContent => { 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") { if (typeof raw === "string") {
try { try {
const parsed = JSON.parse(raw) as unknown const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === "object") return parsed as QuestionContent if (isQuestionContentObj(parsed)) {
return {
text: parsed.text,
options: parsed.options?.map((opt): ChoiceOption => ({
id: opt.id ?? "",
text: opt.text ?? "",
})),
}
}
return { text: raw } return { text: raw }
} catch { } catch {
return { text: raw } 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 { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider" import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
import type { QuestionVariantResult } from "@/modules/ai/types" import type { QuestionVariantResult } from "@/modules/ai/types"
import { isSimpleTextContent } from "../../lib/type-guards"
/** 合法题型集合,用于运行时校验 AI 返回的 type 字符串 */ /** 合法题型集合,用于运行时校验 AI 返回的 type 字符串 */
const QUESTION_TYPES = new Set<string>(QuestionTypeEnum.options) const QUESTION_TYPES = new Set<string>(QuestionTypeEnum.options)
@@ -105,11 +106,11 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
{questions.map((q) => { {questions.map((q) => {
const added = isAdded(q.id) const added = isAdded(q.id)
const parsedContent = (() => { 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") { if (typeof q.content === "string") {
try { try {
const parsed = JSON.parse(q.content) as unknown const parsed: unknown = JSON.parse(q.content)
if (parsed && typeof parsed === "object") return parsed as { text?: string } if (isSimpleTextContent(parsed)) return parsed
return { text: q.content } return { text: q.content }
} catch { } catch {
return { text: q.content } 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 { cn } from "@/shared/lib/utils"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { ExamNode } from "./selected-question-list" import type { ExamNode } from "./selected-question-list"
import { isRecord, isQuestionContentObj, toSortableId } from "../../lib/type-guards"
// --- Types --- // --- Types ---
@@ -64,18 +65,16 @@ const extractQuestionText = (raw: unknown): string => {
// Content might be a JSON string or plain text // Content might be a JSON string or plain text
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === "object") { if (isRecord(parsed) && typeof parsed.text === "string") {
const obj = parsed as Record<string, unknown> return parsed.text
return typeof obj.text === "string" ? obj.text : ""
} }
return raw return raw
} catch { } catch {
return raw return raw
} }
} }
if (typeof raw === "object") { if (isRecord(raw) && typeof raw.text === "string") {
const obj = raw as Record<string, unknown> return raw.text
return typeof obj.text === "string" ? obj.text : ""
} }
return "" return ""
} }
@@ -111,11 +110,11 @@ function SortableItem({
const rawContent = item.question?.content const rawContent = item.question?.content
const parsedContent = (() => { 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") { if (typeof rawContent === "string") {
try { try {
const parsed = JSON.parse(rawContent) as unknown const parsed: unknown = JSON.parse(rawContent)
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> } if (isQuestionContentObj(parsed)) return parsed
return { text: rawContent } return { text: rawContent }
} catch { } catch {
return { text: rawContent } return { text: rawContent }
@@ -478,7 +477,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (!over) return if (!over) return
const activeId = active.id as string const activeId = active.id as string
const overId = over.id as string const overId = toSortableId(over.id)
if (activeId === overId) return if (activeId === overId) return
@@ -646,7 +645,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
if (!over) return if (!over) return
const activeId = active.id as string const activeId = active.id as string
const overId = over.id as string const overId = toSortableId(over.id)
if (activeId === overId) return if (activeId === overId) return

View File

@@ -4,7 +4,7 @@ import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy, BarChart3 } from "lucide-react" 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 { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area" 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 type { ExamNode } from "./assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { useExamHomeworkFeatures } from "@/shared/hooks/use-exam-homework-features" 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 // Raw structure node shape returned from the DB before hydration
type RawStructureNode = { type RawStructureNode = {
@@ -51,10 +52,8 @@ type RawStructureNode = {
// Type guard to narrow unknown structure payload to raw nodes // Type guard to narrow unknown structure payload to raw nodes
const isRawStructureNode = (v: unknown): v is RawStructureNode => { const isRawStructureNode = (v: unknown): v is RawStructureNode => {
if (typeof v !== "object" || v === null) return false if (!isRecord(v)) return false
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查 return typeof v.type === "string"
const obj = v as Record<string, unknown>
return typeof obj.type === "string"
} }
const isRawStructureArray = (v: unknown): v is RawStructureNode[] => const isRawStructureArray = (v: unknown): v is RawStructureNode[] =>
@@ -116,11 +115,11 @@ export function ExamActions({ exam }: ExamActionsProps) {
const nodes = isRawStructureArray(structure) ? hydrate(structure) : [] const nodes = isRawStructureArray(structure) ? hydrate(structure) : []
setPreviewNodes(nodes) setPreviewNodes(nodes)
} else { } else {
toast.error(t("exam.actions.previewFailed")) notify.error(t("exam.actions.previewFailed"))
setShowViewDialog(false) setShowViewDialog(false)
} }
} catch { } catch {
toast.error(t("exam.actions.previewFailed")) notify.error(t("exam.actions.previewFailed"))
setShowViewDialog(false) setShowViewDialog(false)
} finally { } finally {
setLoadingPreview(false) setLoadingPreview(false)
@@ -130,10 +129,10 @@ export function ExamActions({ exam }: ExamActionsProps) {
const copyId = () => { const copyId = () => {
try { try {
void navigator.clipboard.writeText(exam.id) void navigator.clipboard.writeText(exam.id)
toast.success(t("exam.actions.idCopied")) notify.success(t("exam.actions.idCopied"))
} catch (error) { } catch (error) {
console.error("[ExamActions]", error instanceof Error ? error.message : String(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) formData.set("status", status)
const result = await updateExamAction(null, formData) const result = await updateExamAction(null, formData)
if (result.success) { 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() router.refresh()
} else { } else {
toast.error(result.message || t("exam.actions.updateFailed")) notify.error(result.message || t("exam.actions.updateFailed"))
} }
} catch { } catch {
toast.error(t("exam.actions.updateFailed")) notify.error(t("exam.actions.updateFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -164,14 +163,14 @@ export function ExamActions({ exam }: ExamActionsProps) {
formData.set("examId", exam.id) formData.set("examId", exam.id)
const result = await duplicateExamAction(null, formData) const result = await duplicateExamAction(null, formData)
if (result.success && result.data) { 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.push(`/teacher/exams/${result.data}/build`)
router.refresh() router.refresh()
} else { } else {
toast.error(result.message || t("exam.actions.duplicateFailed")) notify.error(result.message || t("exam.actions.duplicateFailed"))
} }
} catch { } catch {
toast.error(t("exam.actions.duplicateFailed")) notify.error(t("exam.actions.duplicateFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -184,14 +183,14 @@ export function ExamActions({ exam }: ExamActionsProps) {
formData.set("examId", exam.id) formData.set("examId", exam.id)
const result = await deleteExamAction(null, formData) const result = await deleteExamAction(null, formData)
if (result.success) { if (result.success) {
toast.success(t("exam.actions.deleteSuccess")) notify.success(t("exam.actions.deleteSuccess"))
setShowDeleteDialog(false) setShowDeleteDialog(false)
router.refresh() router.refresh()
} else { } else {
toast.error(result.message || t("exam.actions.deleteFailed")) notify.error(result.message || t("exam.actions.deleteFailed"))
} }
} catch { } catch {
toast.error(t("exam.actions.deleteFailed")) notify.error(t("exam.actions.deleteFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }

View File

@@ -4,7 +4,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, us
import type { JSX } from "react" import type { JSX } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { Card } from "@/shared/components/ui/card" import { Card } from "@/shared/components/ui/card"
@@ -96,7 +96,7 @@ export function ExamAssembly(props: ExamAssemblyProps): JSX.Element {
} }
} catch (error) { } catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(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]) }, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter, t])
@@ -188,13 +188,13 @@ export function ExamAssembly(props: ExamAssemblyProps): JSX.Element {
try { try {
const result = await updateExamAction(null, formData) const result = await updateExamAction(null, formData)
if (result.success) { if (result.success) {
toast.success(t("saveSuccess")) notify.success(t("saveSuccess"))
} else { } else {
toast.error(result.message || t("saveFailed")) notify.error(result.message || t("saveFailed"))
} }
} catch (error) { } catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(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 { try {
const result = await updateExamAction(null, formData) const result = await updateExamAction(null, formData)
if (result.success) { if (result.success) {
toast.success(t("publishSuccess")) notify.success(t("publishSuccess"))
router.push("/teacher/exams/all") router.push("/teacher/exams/all")
} else { } else {
toast.error(result.message || t("publishFailed")) notify.error(result.message || t("publishFailed"))
} }
} catch (error) { } catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(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 { Exam } from "../types"
import { ExamActions } from "./exam-actions" 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>[] { export function createExamColumns(t: TranslationFn): ColumnDef<Exam>[] {
return [ return [

View File

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

View File

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useForm, type Resolver } from "react-hook-form" import { useForm, type Resolver } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Form } from "@/shared/components/ui/form" import { Form } from "@/shared/components/ui/form"
import { createAiExamAction, createExamAction, getSubjectsAction, getGradesAction } from "../actions" import { createAiExamAction, createExamAction, getSubjectsAction, getGradesAction } from "../actions"
@@ -35,9 +35,9 @@ export function ExamForm() {
const [loadingAiProviders, setLoadingAiProviders] = useState(true) const [loadingAiProviders, setLoadingAiProviders] = useState(true)
// zodResolver 与 useForm 在含 superRefine + coerce + default 时的输入/输出类型存在协变差异, // zodResolver 与 useForm 在含 superRefine + coerce + default 时的输入/输出类型存在协变差异,
// 使用 Resolver<ExamFormValues> 显式标注以替代 as any从 zodResolver 返回类型收窄 // 需经 unknown 中转以规避 TS 逆变检查(合规:从 unknown 转换
const form = useForm<ExamFormValues>({ const form = useForm<ExamFormValues>({
resolver: zodResolver(formSchema) as Resolver<ExamFormValues>, resolver: zodResolver(formSchema) as unknown as Resolver<ExamFormValues>,
defaultValues, defaultValues,
}) })
@@ -58,7 +58,7 @@ export function ExamForm() {
form.setValue("subject", subjectsResult.data[0].id) form.setValue("subject", subjectsResult.data[0].id)
} }
} else { } else {
toast.error(t("exam.form.loadSubjectsFailed")) notify.error(t("exam.form.loadSubjectsFailed"))
} }
if (gradesResult.success && gradesResult.data) { if (gradesResult.success && gradesResult.data) {
@@ -67,7 +67,7 @@ export function ExamForm() {
form.setValue("grade", gradesResult.data[0].id) form.setValue("grade", gradesResult.data[0].id)
} }
} else { } else {
toast.error(t("exam.form.loadGradesFailed")) notify.error(t("exam.form.loadGradesFailed"))
} }
if (aiProvidersResult.success && aiProvidersResult.data) { if (aiProvidersResult.success && aiProvidersResult.data) {
setAiProviders(aiProvidersResult.data) setAiProviders(aiProvidersResult.data)
@@ -81,7 +81,7 @@ export function ExamForm() {
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
toast.error(t("exam.form.loadFormFailed")) notify.error(t("exam.form.loadFormFailed"))
} finally { } finally {
setLoadingSubjects(false) setLoadingSubjects(false)
setLoadingGrades(false) setLoadingGrades(false)
@@ -115,13 +115,13 @@ export function ExamForm() {
const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90 const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90
if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) { if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) {
toast.error(t("exam.form.missingSubjectOrGrade")) notify.error(t("exam.form.missingSubjectOrGrade"))
return return
} }
if (data.mode === "ai") { if (data.mode === "ai") {
const signature = preview.buildPreviewSignature(data) const signature = preview.buildPreviewSignature(data)
if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) { if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) {
toast.error(t("exam.form.previewBeforeCreate")) notify.error(t("exam.form.previewBeforeCreate"))
return return
} }
} }
@@ -162,12 +162,12 @@ export function ExamForm() {
: await createExamAction(null, formData) : await createExamAction(null, formData)
if (result.success && result.data) { if (result.success && result.data) {
toast.success(t("exam.form.createSuccess"), { notify.success(t("exam.form.createSuccess"), {
description: t("exam.form.redirecting"), description: t("exam.form.redirecting"),
}) })
router.push(`/teacher/exams/${result.data}/build`) router.push(`/teacher/exams/${result.data}/build`)
} else { } 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 { createId } from "@paralleldrive/cuid2"
import type { Dispatch, SetStateAction } from "react" import type { Dispatch, SetStateAction } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import type { useTranslations } from "next-intl" import type { useTranslations } from "next-intl"
import { previewAiExamAction, type AiPreviewData } from "../actions" import { previewAiExamAction, type AiPreviewData } from "../actions"
import type { ExamNode } from "./assembly/selected-question-list" import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types" import type { Question } from "@/modules/questions/types"
import type { EditableQuestionContent, ExamFormValues, PreviewBackgroundTask, PreviewSnapshotMeta } from "./exam-form-types" import type { EditableQuestionContent, ExamFormValues, PreviewBackgroundTask, PreviewSnapshotMeta } from "./exam-form-types"
import { previewTaskStorageKey } from "./exam-form-types" import { previewTaskStorageKey } from "./exam-form-types"
import { isRecord } from "../lib/type-guards"
type Translator = ReturnType<typeof useTranslations> type Translator = ReturnType<typeof useTranslations>
@@ -228,12 +229,11 @@ export function buildTaskFormValues(values: ExamFormValues): NonNullable<Preview
} }
export function isPreviewBackgroundTask(v: unknown): v is PreviewBackgroundTask { export function isPreviewBackgroundTask(v: unknown): v is PreviewBackgroundTask {
if (!v || typeof v !== "object") return false if (!isRecord(v)) return false
const obj = v as Record<string, unknown> return typeof v.id === "string"
return typeof obj.id === "string" && (v.status === "queued" || v.status === "running" || v.status === "success" || v.status === "failed")
&& (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed") && typeof v.createdAt === "number"
&& typeof obj.createdAt === "number" && typeof v.title === "string"
&& typeof obj.title === "string"
} }
export function persistPreviewTasksToStorage(tasks: PreviewBackgroundTask[]) { export function persistPreviewTasksToStorage(tasks: PreviewBackgroundTask[]) {
@@ -276,18 +276,18 @@ export async function executeBackgroundPreviewTask(
setPreviewTasks((prev) => prev.map((task) => task.id === taskId 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, status: "success", result: { title: data.title, nodes: nextNodes, rawOutput: data.rawOutput ?? "", meta: requestData.meta, formValues: buildTaskFormValues(values) } }
: task)) : task))
toast.success(t("exam.previewHook.backgroundComplete", { title: taskTitle })) notify.success(t("exam.previewHook.backgroundComplete", { title: taskTitle }))
return return
} }
setPreviewTasks((prev) => prev.map((task) => task.id === taskId setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: result.message || t("exam.previewHook.generatePreviewFailed") } ? { ...task, status: "failed", message: result.message || t("exam.previewHook.generatePreviewFailed") }
: task)) : task))
toast.error(t("exam.previewHook.backgroundFailed", { title: taskTitle })) notify.error(t("exam.previewHook.backgroundFailed", { title: taskTitle }))
} catch (error) { } catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks((prev) => prev.map((task) => task.id === taskId setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: t("exam.previewHook.generatePreviewFailed") } ? { ...task, status: "failed", message: t("exam.previewHook.generatePreviewFailed") }
: task)) : 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 { useEffect, useRef, useState, useTransition, type FormEvent } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Sparkles, Save, FileText } from "lucide-react" import { Sparkles, Save, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -28,9 +28,9 @@ import {
type ExamRichEditorHandle, type ExamRichEditorHandle,
type EditorJSONContent, type EditorJSONContent,
editorDocToStructure, editorDocToStructure,
type EditorDoc,
} from "../editor" } from "../editor"
import { ExamPreview } from "./exam-preview" import { ExamPreview } from "./exam-preview"
import { isJSONContent } from "../lib/type-guards"
interface ExamRichFormValues { interface ExamRichFormValues {
title: string title: string
@@ -119,7 +119,7 @@ export function ExamRichForm({
} }
} catch (e) { } catch (e) {
console.error("[ExamRichForm]", e) console.error("[ExamRichForm]", e)
toast.error(t("exam.richEditor.loadFormFailed")) notify.error(t("exam.richEditor.loadFormFailed"))
} finally { } finally {
setLoadingSubjects(false) setLoadingSubjects(false)
setLoadingGrades(false) setLoadingGrades(false)
@@ -135,7 +135,7 @@ export function ExamRichForm({
const handleAutoMark = () => { const handleAutoMark = () => {
const currentText = editorRef.current?.getText() ?? "" const currentText = editorRef.current?.getText() ?? ""
if (!currentText.trim()) { if (!currentText.trim()) {
toast.error(t("exam.richEditor.emptyEditor")) notify.error(t("exam.richEditor.emptyEditor"))
return return
} }
startMarking(async () => { startMarking(async () => {
@@ -143,15 +143,19 @@ export function ExamRichForm({
formData.append("sourceText", currentText) formData.append("sourceText", currentText)
const result = await autoMarkExamAction(null, formData) const result = await autoMarkExamAction(null, formData)
if (result.success && result.data) { 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) editorRef.current?.setJSON(doc)
setEditorDoc(doc) setEditorDoc(doc)
if (result.data.title) { if (result.data.title) {
setValues((v) => ({ ...v, title: 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 { } 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() e.preventDefault()
const doc = editorRef.current?.getJSON() const doc = editorRef.current?.getJSON()
if (!doc) { if (!doc) {
toast.error(t("exam.richEditor.emptyContent")) notify.error(t("exam.richEditor.emptyContent"))
return return
} }
if (!values.title.trim()) { if (!values.title.trim()) {
toast.error(t("exam.richEditor.titleRequired")) notify.error(t("exam.richEditor.titleRequired"))
return return
} }
if (isEditMode) { if (isEditMode) {
if (!examId) { if (!examId) {
toast.error(t("exam.richForm.missingExamId")) notify.error(t("exam.richForm.missingExamId"))
return return
} }
startTransition(async () => { startTransition(async () => {
@@ -181,17 +185,17 @@ export function ExamRichForm({
const result = await updateExamFromRichEditorAction(null, formData) const result = await updateExamFromRichEditorAction(null, formData)
if (result.success) { 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`) router.push(`/teacher/exams/${examId}/build`)
} else { } else {
toast.error(result.message || t("exam.richEditor.saveFailed")) notify.error(result.message || t("exam.richEditor.saveFailed"))
} }
}) })
return return
} }
if (!values.subject || !values.grade) { if (!values.subject || !values.grade) {
toast.error(t("exam.richEditor.subjectGradeRequired")) notify.error(t("exam.richEditor.subjectGradeRequired"))
return return
} }
@@ -209,10 +213,10 @@ export function ExamRichForm({
const result = await createExamFromRichEditorAction(null, formData) const result = await createExamFromRichEditorAction(null, formData)
if (result.success && result.data) { 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`) router.push(`/teacher/exams/${result.data}/build`)
} else { } 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, EditorStructureNode,
} from "./exam-rich-editor-types" } from "./exam-rich-editor-types"
import { toRichQuestionType } from "./exam-rich-editor-types" import { toRichQuestionType } from "./exam-rich-editor-types"
import { isRecord } from "../lib/type-guards"
/** /**
* 将 BuildExam 页面的 ExamNode[] + Question[] 转换为 EditorDoc, * 将 BuildExam 页面的 ExamNode[] + Question[] 转换为 EditorDoc,
@@ -95,14 +96,17 @@ export const examNodesToEditorDoc = (
/** 解析 Question.content(可能是 JSON 字符串或对象)为 RichQuestionContent */ /** 解析 Question.content(可能是 JSON 字符串或对象)为 RichQuestionContent */
const parseQuestionContent = (raw: unknown): EditorQuestion["content"] => { const parseQuestionContent = (raw: unknown): EditorQuestion["content"] => {
if (raw && typeof raw === "object") { if (isRecord(raw)) {
return raw as EditorQuestion["content"] // isRecord 保证 raw 是非 null 对象,具体字段结构校验由下游 Zod schema 保证
// 从 unknown 转换Record<string, unknown> → unknown → RichQuestionContent
return raw as unknown as EditorQuestion["content"]
} }
if (typeof raw === "string") { if (typeof raw === "string") {
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === "object") { if (isRecord(parsed)) {
return parsed as EditorQuestion["content"] // 同上,结构校验由下游 Zod 保证
return parsed as unknown as EditorQuestion["content"]
} }
return { text: raw } return { text: raw }
} catch { } catch {

View File

@@ -30,7 +30,7 @@ import {
type QuestionBlockType, type QuestionBlockType,
} from "./extensions"; } from "./extensions";
import { SelectionToolbar } from "./selection-toolbar"; 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 { export interface ExamRichEditorHandle {
/** 获取当前编辑器文档(Tiptap JSONContent) */ /** 获取当前编辑器文档(Tiptap JSONContent) */
@@ -245,7 +245,7 @@ export const ExamRichEditorInner = forwardRef<ExamRichEditorHandle, ExamRichEdit
for (let d = $from.depth; d > 0; d--) { for (let d = $from.depth; d > 0; d--) {
const node = $from.node(d); const node = $from.node(d);
if (node.type.name === "questionBlock") { 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; return undefined;

View File

@@ -3,6 +3,7 @@
import { Node, mergeAttributes } from "@tiptap/core" import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react" import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { isQuestionBlockAttrs } from "../../lib/type-guards"
export type QuestionBlockType = export type QuestionBlockType =
| "single_choice" | "single_choice"
@@ -30,7 +31,10 @@ declare module "@tiptap/core" {
const QuestionView = ({ node, updateAttributes }: NodeViewProps) => { const QuestionView = ({ node, updateAttributes }: NodeViewProps) => {
const t = useTranslations("examHomework") 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 ( return (
<NodeViewWrapper className="my-3 rounded-md border bg-card p-3"> <NodeViewWrapper className="my-3 rounded-md border bg-card p-3">
<div className="mb-2 flex items-center gap-2 border-b pb-2"> <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 { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import type { QuestionBlockType } from "./extensions/question-block" import type { QuestionBlockType } from "./extensions/question-block"
import { isJSONContent, isJSONContentArray } from "../lib/type-guards"
interface SelectionToolbarProps { interface SelectionToolbarProps {
editor: Editor | null editor: Editor | null
@@ -209,10 +210,11 @@ export function SelectionToolbar({
// 然后插入新的 questionBlock。避免列表导致填空/简答题在预览中不显示。 // 然后插入新的 questionBlock。避免列表导致填空/简答题在预览中不显示。
const { from, to } = editor.state.selection const { from, to } = editor.state.selection
const slice = editor.state.doc.slice(from, to) const slice = editor.state.doc.slice(from, to)
const sliceContent = slice.content.toJSON const jsonOutput = slice.content.toJSON ? slice.content.toJSON() : null
? (slice.content.toJSON() as JSONContent[]) const sliceContent = isJSONContentArray(jsonOutput)
? jsonOutput
: Array.isArray(slice.content.content) : 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 const content = isChoice

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import type { UseFormReturn } from "react-hook-form" 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 { useTranslations } from "next-intl"
import type { usePreviewState } from "./use-exam-preview-state" import type { usePreviewState } from "./use-exam-preview-state"
import type { ExamFormValues } from "../components/exam-form-types" import type { ExamFormValues } from "../components/exam-form-types"
@@ -22,17 +22,17 @@ export function usePreviewRewrite(
const handleRewriteSelectedQuestion = async () => { const handleRewriteSelectedQuestion = async () => {
if (!selectedQuestionId) { if (!selectedQuestionId) {
toast.error(t("exam.previewHook.selectQuestionFirst")) notify.error(t("exam.previewHook.selectQuestionFirst"))
return return
} }
const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId) const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId)
if (!selected?.question) { if (!selected?.question) {
toast.error(t("exam.previewHook.questionNotFound")) notify.error(t("exam.previewHook.questionNotFound"))
return return
} }
const instruction = rewriteInstruction.trim() const instruction = rewriteInstruction.trim()
if (!instruction) { if (!instruction) {
toast.error(t("exam.previewHook.enterRewriteInstruction")) notify.error(t("exam.previewHook.enterRewriteInstruction"))
return return
} }
setRewritingQuestion(true) setRewritingQuestion(true)
@@ -61,15 +61,15 @@ export function usePreviewRewrite(
if (sourceText) formData.append("sourceText", sourceText) if (sourceText) formData.append("sourceText", sourceText)
const result = await regenerateAiQuestionAction(null, formData) const result = await regenerateAiQuestionAction(null, formData)
if (!result.success || !result.data) { if (!result.success || !result.data) {
toast.error(result.message || t("exam.previewHook.aiRewriteFailed")) notify.error(result.message || t("exam.previewHook.aiRewriteFailed"))
return return
} }
updateSelectedQuestionFromAi(selectedQuestionId, result.data) updateSelectedQuestionFromAi(selectedQuestionId, result.data)
setRewriteInstruction("") setRewriteInstruction("")
toast.success(t("exam.previewHook.aiRewriteSuccess")) notify.success(t("exam.previewHook.aiRewriteSuccess"))
} catch (error) { } catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
toast.error(t("exam.previewHook.aiRewriteFailed")) notify.error(t("exam.previewHook.aiRewriteFailed"))
} finally { } finally {
setRewritingQuestion(false) setRewritingQuestion(false)
} }

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import type { AiPreviewData, AiRewriteQuestionData } from "../actions" import type { AiPreviewData, AiRewriteQuestionData } from "../actions"
import type { ExamNode } from "../components/assembly/selected-question-list" 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 { import {
buildPreviewNodes, buildPreviewNodes,
flattenPreviewQuestions, flattenPreviewQuestions,

View File

@@ -1,8 +1,8 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useState } from "react"
import type { UseFormReturn } from "react-hook-form" import type { UseFormReturn } from "react-hook-form"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import PQueue from "p-queue" import PQueue from "p-queue"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import type { useTranslations } from "next-intl" import type { useTranslations } from "next-intl"
@@ -24,22 +24,22 @@ export function usePreviewBackgroundTasks(
state: ReturnType<typeof usePreviewState>, state: ReturnType<typeof usePreviewState>,
) { ) {
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([]) const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
const previewQueueRef = useRef<PQueue | null>(null) const [previewQueue] = useState(() => new PQueue({ concurrency: 3 }))
if (!previewQueueRef.current) previewQueueRef.current = new PQueue({ concurrency: 3 })
const previewQueue = previewQueueRef.current
useEffect(() => { useEffect(() => {
try { try {
const raw = window.localStorage.getItem(previewTaskStorageKey) const raw = window.localStorage.getItem(previewTaskStorageKey)
const restoredTasks = raw ? parseStoredPreviewTasks(raw, t) : null const restoredTasks = raw ? parseStoredPreviewTasks(raw, t) : null
if (!restoredTasks) return if (!restoredTasks) return
// 挂载时从 localStorage 恢复任务属外部系统同步场景不能用懒初始化SSR 水合不一致)
// eslint-disable-next-line react-hooks/set-state-in-effect -- 挂载时一次性恢复 localStorage 数据
setPreviewTasks(restoredTasks) setPreviewTasks(restoredTasks)
if (restoredTasks.length > 0) form.setValue("mode", "ai") if (restoredTasks.length > 0) form.setValue("mode", "ai")
} catch (error) { } catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks([]) setPreviewTasks([])
} }
}, [form]) }, [form, t])
useEffect(() => () => { previewQueue.clear() }, [previewQueue]) useEffect(() => () => { previewQueue.clear() }, [previewQueue])
useEffect(() => { persistPreviewTasksToStorage(previewTasks) }, [previewTasks]) useEffect(() => { persistPreviewTasksToStorage(previewTasks) }, [previewTasks])
@@ -47,7 +47,7 @@ export function usePreviewBackgroundTasks(
const handleBackgroundPreview = () => { const handleBackgroundPreview = () => {
const values = form.getValues() const values = form.getValues()
const requestData = buildPreviewRequestData(values) 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 taskId = createId()
const taskTitle = values.title?.trim() || t("exam.previewHook.untitledExam") const taskTitle = values.title?.trim() || t("exam.previewHook.untitledExam")
setPreviewTasks((prev) => { setPreviewTasks((prev) => {
@@ -55,7 +55,7 @@ export function usePreviewBackgroundTasks(
persistPreviewTasksToStorage(next) persistPreviewTasksToStorage(next)
return 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)) void previewQueue.add(() => executeBackgroundPreviewTask(taskId, taskTitle, values, requestData, t, setPreviewTasks))
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import type { useTranslations } from "next-intl" import type { useTranslations } from "next-intl"
import type { UseFormReturn } from "react-hook-form" import type { UseFormReturn } from "react-hook-form"
@@ -34,7 +34,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>, t: Translato
const values = form.getValues() const values = form.getValues()
const requestData = buildPreviewRequestData(values) const requestData = buildPreviewRequestData(values)
if (!requestData) { if (!requestData) {
toast.error(t("exam.previewHook.pasteSourceFirst")) notify.error(t("exam.previewHook.pasteSourceFirst"))
return return
} }
setPreviewOpen(false) setPreviewOpen(false)
@@ -49,11 +49,11 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>, t: Translato
if (result.success && result.data) { if (result.success && result.data) {
applyPreviewResult({ data: result.data, signature: requestData.signature, meta: requestData.meta }) applyPreviewResult({ data: result.data, signature: requestData.signature, meta: requestData.meta })
} else { } else {
toast.error(result.message || t("exam.previewHook.generatePreviewFailed")) notify.error(result.message || t("exam.previewHook.generatePreviewFailed"))
} }
} catch (error) { } catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error)) console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
toast.error(t("exam.previewHook.generatePreviewFailed")) notify.error(t("exam.previewHook.generatePreviewFailed"))
} finally { } finally {
setPreviewLoading(false) 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 { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { createModuleLogger } from "@/shared/lib/logger"
import { fileAttachments } from "@/shared/db/schema" import { fileAttachments } from "@/shared/db/schema"
import type { import type {
BatchDeleteResult, BatchDeleteResult,
@@ -14,8 +14,19 @@ import type {
FileStats, FileStats,
} from "./types" } from "./types"
const log = createModuleLogger("files") const fileAttachmentColumns = {
const toIso = (d: Date): string => d.toISOString() 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 => ({ const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
id: row.id, id: row.id,
@@ -37,45 +48,35 @@ const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
export async function createFileAttachment( export async function createFileAttachment(
data: CreateFileAttachmentInput data: CreateFileAttachmentInput
): Promise<FileAttachment | null> { ): Promise<FileAttachment | null> {
try { await db.insert(fileAttachments).values({
await db.insert(fileAttachments).values({ id: data.id,
id: data.id, filename: data.filename,
filename: data.filename, originalName: data.originalName,
originalName: data.originalName, mimeType: data.mimeType,
mimeType: data.mimeType, size: data.size,
size: data.size, storagePath: data.storagePath,
storagePath: data.storagePath, url: data.url,
url: data.url, uploaderId: data.uploaderId,
uploaderId: data.uploaderId, targetType: data.targetType ?? null,
targetType: data.targetType ?? null, targetId: data.targetId ?? null,
targetId: data.targetId ?? null, })
})
const created = await getFileAttachment(data.id) const created = await getFileAttachment(data.id)
return created return created
} catch (error) {
log.error({ err: error }, "createFileAttachment failed")
return null
}
} }
/** /**
* 按 ID 查询文件附件 * 按 ID 查询文件附件
*/ */
export const getFileAttachmentRaw = async (id: string): Promise<FileAttachment | null> => { export const getFileAttachmentRaw = async (id: string): Promise<FileAttachment | null> => {
try { const [row] = await db
const [row] = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .where(eq(fileAttachments.id, id))
.where(eq(fileAttachments.id, id)) .limit(1)
.limit(1)
return row ? mapRow(row) : null return row ? mapRow(row) : null
} catch (error) { }
log.error({ err: error }, "getFileAttachment failed")
return null
}
}
export const getFileAttachment = cacheFn(getFileAttachmentRaw, { export const getFileAttachment = cacheFn(getFileAttachmentRaw, {
tags: ["files"], tags: ["files"],
@@ -87,24 +88,19 @@ export const getFileAttachment = cacheFn(getFileAttachmentRaw, {
* 按关联资源查询文件列表 * 按关联资源查询文件列表
*/ */
export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise<FileAttachment[]> => { export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
try { const rows = await db
const rows = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .where(
.where( and(
and( eq(fileAttachments.targetType, targetType),
eq(fileAttachments.targetType, targetType), eq(fileAttachments.targetId, targetId)
eq(fileAttachments.targetId, targetId) )
) )
) .orderBy(desc(fileAttachments.createdAt))
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow) return rows.map(mapRow)
} catch (error) { }
log.error({ err: error }, "getFileAttachmentsByTarget failed")
return []
}
}
export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, { export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, {
tags: ["files"], tags: ["files"],
@@ -116,19 +112,14 @@ export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw,
* 按上传者查询文件列表 * 按上传者查询文件列表
*/ */
export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise<FileAttachment[]> => { export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise<FileAttachment[]> => {
try { const rows = await db
const rows = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .where(eq(fileAttachments.uploaderId, uploaderId))
.where(eq(fileAttachments.uploaderId, uploaderId)) .orderBy(desc(fileAttachments.createdAt))
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow) return rows.map(mapRow)
} catch (error) { }
log.error({ err: error }, "getFileAttachmentsByUploader failed")
return []
}
}
export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, { export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, {
tags: ["files"], tags: ["files"],
@@ -140,19 +131,14 @@ export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploader
* 查询所有文件(用于管理员文件管理页面) * 查询所有文件(用于管理员文件管理页面)
*/ */
export const getAllFileAttachmentsRaw = async (limit = 100): Promise<FileAttachment[]> => { export const getAllFileAttachmentsRaw = async (limit = 100): Promise<FileAttachment[]> => {
try { const rows = await db
const rows = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .orderBy(desc(fileAttachments.createdAt))
.orderBy(desc(fileAttachments.createdAt)) .limit(limit)
.limit(limit)
return rows.map(mapRow) return rows.map(mapRow)
} catch (error) { }
log.error({ err: error }, "getAllFileAttachments failed")
return []
}
}
export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, { export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, {
tags: ["files"], tags: ["files"],
@@ -164,13 +150,8 @@ export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, {
* 删除文件附件记录 * 删除文件附件记录
*/ */
export async function deleteFileAttachment(id: string): Promise<boolean> { export async function deleteFileAttachment(id: string): Promise<boolean> {
try { await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
await db.delete(fileAttachments).where(eq(fileAttachments.id, id)) return true
return true
} catch (error) {
log.error({ err: error }, "deleteFileAttachment failed")
return false
}
} }
/** /**
@@ -181,29 +162,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
if (ids.length === 0) { if (ids.length === 0) {
return { success: true, deletedCount: 0, failedIds: [] } return { success: true, deletedCount: 0, failedIds: [] }
} }
try { await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids)) return { success: true, deletedCount: ids.length, failedIds: [] }
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,
}
}
} }
/** /**
@@ -212,42 +172,37 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
* - search: 在 originalName / filename 中模糊匹配 * - search: 在 originalName / filename 中模糊匹配
*/ */
export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => { 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[] = [] const conditions: SQL[] = []
if (mimeType) { if (mimeType) {
if (mimeType.endsWith("/")) { if (mimeType.endsWith("/")) {
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`)) conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
} else { } else {
conditions.push(eq(fileAttachments.mimeType, mimeType)) 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 []
} }
} }
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, { export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFiltersRaw, {
tags: ["files"], tags: ["files"],
@@ -259,31 +214,26 @@ export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFilte
* 获取文件统计信息(总数、总大小、按类型分组) * 获取文件统计信息(总数、总大小、按类型分组)
*/ */
export const getFileStatsRaw = async (): Promise<FileStats> => { export const getFileStatsRaw = async (): Promise<FileStats> => {
try { const rows = await db
const rows = await db .select({
.select({ mimeType: fileAttachments.mimeType,
mimeType: fileAttachments.mimeType, count: count(),
count: count(), size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`, })
}) .from(fileAttachments)
.from(fileAttachments) .groupBy(fileAttachments.mimeType)
.groupBy(fileAttachments.mimeType)
const byType = rows.map((r) => ({ const byType = rows.map((r) => ({
mimeType: r.mimeType, mimeType: r.mimeType,
count: Number(r.count), count: Number(r.count),
size: Number(r.size), size: Number(r.size),
})) }))
const totalCount = byType.reduce((sum, r) => sum + r.count, 0) const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
const totalSize = byType.reduce((sum, r) => sum + r.size, 0) const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
return { totalCount, totalSize, byType } return { totalCount, totalSize, byType }
} catch (error) { }
log.error({ err: error }, "getFileStats failed")
return { totalCount: 0, totalSize: 0, byType: [] }
}
}
export const getFileStats = cacheFn(getFileStatsRaw, { export const getFileStats = cacheFn(getFileStatsRaw, {
tags: ["files"], tags: ["files"],
@@ -295,19 +245,14 @@ export const getFileStats = cacheFn(getFileStatsRaw, {
* 按 URL 查询文件附件(用于头像等场景的旧文件清理) * 按 URL 查询文件附件(用于头像等场景的旧文件清理)
*/ */
export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | null> => { export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | null> => {
try { const [row] = await db
const [row] = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .where(eq(fileAttachments.url, url))
.where(eq(fileAttachments.url, url)) .limit(1)
.limit(1)
return row ? mapRow(row) : null return row ? mapRow(row) : null
} catch (error) { }
log.error({ err: error }, "getFileByUrl failed")
return null
}
}
export const getFileByUrl = cacheFn(getFileByUrlRaw, { export const getFileByUrl = cacheFn(getFileByUrlRaw, {
tags: ["files"], tags: ["files"],
@@ -319,18 +264,13 @@ export const getFileByUrl = cacheFn(getFileByUrlRaw, {
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/ */
export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise<FileAttachment[]> => { export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise<FileAttachment[]> => {
if (ids.length === 0) return [] if (ids.length === 0) return []
try { const rows = await db
const rows = await db .select(fileAttachmentColumns)
.select() .from(fileAttachments)
.from(fileAttachments) .where(inArray(fileAttachments.id, ids))
.where(inArray(fileAttachments.id, ids)) return rows.map(mapRow)
return rows.map(mapRow) }
} catch (error) {
log.error({ err: error }, "getFileAttachmentsByIds failed")
return []
}
}
export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, { export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, {
tags: ["files"], tags: ["files"],

View File

@@ -2,7 +2,7 @@
import { useCallback, useMemo, useState } from "react" import { useCallback, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { FileAttachment } from "../types" import type { FileAttachment } from "../types"
@@ -101,15 +101,15 @@ export function useFileBatchOperations({
deletedCount?: number deletedCount?: number
} | null } | null
if (!res.ok || !body?.success) { if (!res.ok || !body?.success) {
toast.error(body?.message || t("selection.deleteFailed")) notify.error(body?.message || t("selection.deleteFailed"))
return return
} }
toast.success(t("selection.deleted", { count: body.deletedCount ?? 0 })) notify.success(t("selection.deleted", { count: body.deletedCount ?? 0 }))
setSelectedIds(new Set()) setSelectedIds(new Set())
onAfterDelete?.() onAfterDelete?.()
router.refresh() router.refresh()
} catch { } catch {
toast.error(t("selection.deleteFailed")) notify.error(t("selection.deleteFailed"))
} finally { } finally {
setDeleting(false) setDeleting(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useRef, useState } from "react" import { useCallback, useRef, useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
@@ -83,7 +83,7 @@ export function useFileUpload({
: tk : tk
) )
) )
toast.error(t("error", { name: file.name, message: validationError })) notify.error(t("error", { name: file.name, message: validationError }))
return return
} }
@@ -137,7 +137,7 @@ export function useFileUpload({
) )
) )
onUploaded?.(result) onUploaded?.(result)
toast.success(t("success", { name: file.name })) notify.success(t("success", { name: file.name }))
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : t("networkError") const message = e instanceof Error ? e.message : t("networkError")
setTasks((prev) => setTasks((prev) =>
@@ -145,7 +145,7 @@ export function useFileUpload({
tk.file === file ? { ...tk, status: "error", message } : tk 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] [targetType, targetId, onUploaded, t, validateFile]

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type ClipboardEvent } from "react" 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 { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -208,7 +208,7 @@ export function BatchGradeEntryByExam({
return merged return merged
}) })
e.preventDefault() 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> => { const handleSubmit = async (): Promise<void> => {
if (!exam || !defaultClassId || !defaultExamId) return if (!exam || !defaultClassId || !defaultExamId) return
if (hasInvalidScores) { if (hasInvalidScores) {
toast.error(t("batchByExam.invalidScoresError")) notify.error(t("batchByExam.invalidScoresError"))
return return
} }
@@ -260,7 +260,7 @@ export function BatchGradeEntryByExam({
.filter((r): r is NonNullable<typeof r> => r !== null) .filter((r): r is NonNullable<typeof r> => r !== null)
if (records.length === 0) { if (records.length === 0) {
toast.error(t("batchByExam.enterAtLeastOne")) notify.error(t("batchByExam.enterAtLeastOne"))
return return
} }
@@ -273,7 +273,7 @@ export function BatchGradeEntryByExam({
const result = await safeActionCall( const result = await safeActionCall(
() => batchCreateGradeRecordsByExamAction(null, formData), () => batchCreateGradeRecordsByExamAction(null, formData),
{ {
onError: () => toast.error(t("error.saveFailed")), onError: () => notify.error(t("error.saveFailed")),
onFinally: () => setIsSubmitting(false), onFinally: () => setIsSubmitting(false),
} }
) )
@@ -283,7 +283,7 @@ export function BatchGradeEntryByExam({
const createdIds = result.data ?? [] const createdIds = result.data ?? []
if (createdIds.length > 0) { if (createdIds.length > 0) {
saveUndoToken(createdIds) saveUndoToken(createdIds)
toast.success(result.message, { notify.success(result.message, {
duration: 10000, duration: 10000,
action: { action: {
label: t("batchByExam.undo"), label: t("batchByExam.undo"),
@@ -291,12 +291,12 @@ export function BatchGradeEntryByExam({
}, },
}) })
} else { } else {
toast.success(result.message) notify.success(result.message)
} }
router.push("/teacher/grades") router.push("/teacher/grades")
router.refresh() router.refresh()
} else if (result) { } 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 type { JSX } from "react"
import { useRef, useState } from "react" import { useRef, useState } from "react"
import { Upload, Download, FileSpreadsheet, Loader2, AlertCircle, CheckCircle2 } from "lucide-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 { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -123,22 +123,22 @@ export function ExcelImportDialog({
const handleDownloadTemplate = async () => { const handleDownloadTemplate = async () => {
if (!form.classId) { if (!form.classId) {
toast.error(t("excelImport.errorSelectClass")) notify.error(t("excelImport.errorSelectClass"))
return return
} }
setIsDownloadingTemplate(true) setIsDownloadingTemplate(true)
const r = await safeActionCall( const r = await safeActionCall(
() => downloadGradeImportTemplateAction({ classId: form.classId }), () => downloadGradeImportTemplateAction({ classId: form.classId }),
{ {
onError: () => toast.error(t("excelImport.errorTemplateDownload")), onError: () => notify.error(t("excelImport.errorTemplateDownload")),
onFinally: () => setIsDownloadingTemplate(false), onFinally: () => setIsDownloadingTemplate(false),
} }
) )
if (r?.success && r.data) { if (r?.success && r.data) {
downloadBase64File(r.data.buffer, r.data.filename) downloadBase64File(r.data.buffer, r.data.filename)
toast.success(t("excelImport.templateDownloaded")) notify.success(t("excelImport.templateDownloaded"))
} else if (r) { } 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 () => { const handleSubmit = async () => {
if (!file) { if (!file) {
toast.error(t("excelImport.errorNoFile")) notify.error(t("excelImport.errorNoFile"))
return return
} }
if (!form.classId || !form.subjectId || !form.title) { if (!form.classId || !form.subjectId || !form.title) {
toast.error(t("excelImport.errorMissingFields")) notify.error(t("excelImport.errorMissingFields"))
return return
} }
@@ -172,7 +172,7 @@ export function ExcelImportDialog({
const r = await safeActionCall( const r = await safeActionCall(
() => importGradesFromExcelAction(null, formData), () => importGradesFromExcelAction(null, formData),
{ {
onError: () => toast.error(t("excelImport.errorImport")), onError: () => notify.error(t("excelImport.errorImport")),
onFinally: () => setIsImporting(false), onFinally: () => setIsImporting(false),
} }
) )
@@ -180,16 +180,16 @@ export function ExcelImportDialog({
if (r?.success && r.data) { if (r?.success && r.data) {
setResult(r.data) setResult(r.data)
if (r.data.failedCount === 0) { if (r.data.failedCount === 0) {
toast.success(t("excelImport.successAllImported", { count: r.data.successCount })) notify.success(t("excelImport.successAllImported", { count: r.data.successCount }))
} else { } else {
toast.warning(t("excelImport.successPartial", { notify.warning(t("excelImport.successPartial", {
success: r.data.successCount, success: r.data.successCount,
failed: r.data.failedCount, failed: r.data.failedCount,
})) }))
} }
onSuccess?.() onSuccess?.()
} else if (r) { } else if (r) {
toast.error(r.message ?? t("excelImport.errorImport")) notify.error(r.message ?? t("excelImport.errorImport"))
if (r.data) { if (r.data) {
setResult(r.data) setResult(r.data)
} }

View File

@@ -2,7 +2,7 @@
import type { JSX } from "react" import type { JSX } from "react"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Download, Loader2 } from "lucide-react" import { Download, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -39,7 +39,7 @@ export function ExportButton({
const handleExport = async (reportType: "detail" | "class"): Promise<void> => { const handleExport = async (reportType: "detail" | "class"): Promise<void> => {
if (!classId) { if (!classId) {
toast.error(t("export.selectClassFirst")) notify.error(t("export.selectClassFirst"))
return return
} }
setIsExporting(true) setIsExporting(true)
@@ -53,16 +53,16 @@ export function ExportButton({
reportType, reportType,
}), }),
{ {
onError: () => toast.error(t("export.failed")), onError: () => notify.error(t("export.failed")),
onFinally: () => setIsExporting(false), onFinally: () => setIsExporting(false),
} }
) )
if (result?.success && result.data) { if (result?.success && result.data) {
downloadBase64File(result.data.buffer, result.data.filename) downloadBase64File(result.data.buffer, result.data.filename)
toast.success(t("export.success")) notify.success(t("export.success"))
} else if (result) { } 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 type { JSX } from "react"
import { useState } from "react" import { useState } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -52,7 +52,7 @@ export function GradeRecordForm({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId || !studentId) { if (!classId || !subjectId || !studentId) {
toast.error(t("form.selectPrompt")) notify.error(t("form.selectPrompt"))
return return
} }
formData.set("classId", classId) formData.set("classId", classId)
@@ -65,15 +65,15 @@ export function GradeRecordForm({
const result = await safeActionCall( const result = await safeActionCall(
() => createGradeRecordAction(null, formData), () => createGradeRecordAction(null, formData),
{ {
onError: () => toast.error(t("error.failedToCreate")), onError: () => notify.error(t("error.failedToCreate")),
} }
) )
if (result?.success) { if (result?.success) {
toast.success(result.message) notify.success(result.message)
router.push("/teacher/grades") router.push("/teacher/grades")
router.refresh() router.refresh()
} else if (result) { } 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 type { JSX } from "react"
import { useState, useOptimistic, useTransition } from "react" import { useState, useOptimistic, useTransition } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Trash2 } from "lucide-react" import { Trash2 } from "lucide-react"
@@ -52,16 +52,16 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
const result = await safeActionCall( const result = await safeActionCall(
() => deleteGradeRecordAction(deleteId), () => deleteGradeRecordAction(deleteId),
{ {
onError: () => toast.error(t("error.deleteFailed")), onError: () => notify.error(t("error.deleteFailed")),
onFinally: () => setIsDeleting(false), onFinally: () => setIsDeleting(false),
} }
) )
if (result?.success) { if (result?.success) {
toast.success(result.message) notify.success(result.message)
setDeleteId(null) setDeleteId(null)
router.refresh() router.refresh()
} else if (result) { } 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( const result = await safeActionCall(
() => bulkDeleteGradeRecordsAction(ids), () => bulkDeleteGradeRecordsAction(ids),
{ {
onError: () => toast.error(t("list.bulkDeleteFailed")), onError: () => notify.error(t("list.bulkDeleteFailed")),
onFinally: () => setIsBulkDeleting(false), onFinally: () => setIsBulkDeleting(false),
} }
) )
if (result?.success) { if (result?.success) {
toast.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 })) notify.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 }))
clearSelection() clearSelection()
setIsBulkDeleteDialogOpen(false) setIsBulkDeleteDialogOpen(false)
router.refresh() router.refresh()
} else if (result) { } 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( const result = await safeActionCall(
() => updateGradeRecordAction(targetId, null, formData), () => updateGradeRecordAction(targetId, null, formData),
{ {
onError: () => toast.error(t("edit.failed")), onError: () => notify.error(t("edit.failed")),
} }
) )
if (result?.success) { if (result?.success) {
toast.success(t("edit.success")) notify.success(t("edit.success"))
setEditTarget(null) setEditTarget(null)
// 同步数据源,让 useOptimistic 回滚到最新服务端值 // 同步数据源,让 useOptimistic 回滚到最新服务端值
await router.refresh() await router.refresh()
} else if (result) { } 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(() => { const { chartData, sortedStats, weakPoints, averageMastery, cellColors } = useMemo(() => {
if (data.length === 0) { if (data.length === 0) {
const emptyColors: Record<string, string> = {}
return { return {
chartData: [], chartData: [],
sortedStats: [], sortedStats: [],
weakPoints: [], weakPoints: [],
averageMastery: 0, averageMastery: 0,
cellColors: {} as Record<string, string>, cellColors: emptyColors,
} }
} }

View File

@@ -3,7 +3,7 @@
import type { JSX } from "react" import type { JSX } from "react"
import { useState } from "react" import { useState } from "react"
import { Printer, Loader2 } from "lucide-react" import { Printer, Loader2 } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -24,7 +24,7 @@ export function ReportCardPrintAction(): JSX.Element {
try { try {
window.print() window.print()
} catch { } catch {
toast.error(t("reportCard.errorPrint")) notify.error(t("reportCard.errorPrint"))
} finally { } finally {
// 等待一帧后重置状态,确保用户感知到"准备中"反馈 // 等待一帧后重置状态,确保用户感知到"准备中"反馈
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {

View File

@@ -4,7 +4,7 @@ import type { JSX } from "react"
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Printer } from "lucide-react" import { Printer } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -55,7 +55,7 @@ export function ReportCardPrintButton({
try { try {
router.push(`/teacher/grades/report-card?${params.toString()}`) router.push(`/teacher/grades/report-card?${params.toString()}`)
} catch { } catch {
toast.error(t("reportCard.errorNavigate")) notify.error(t("reportCard.errorNavigate"))
} }
}) })
} }

View File

@@ -1,831 +1,57 @@
import "server-only" 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 重新导出)
* *
* 显著性分析流程: * G2-005 / S-01 治理2026-07-07原 data-access-analytics.ts 831 行超过 800 行建议上限,
* 1. 复用 getClassComparison 获取各班级聚合 stats * 拆分为 4 个按分析维度划分的子文件。本文件仅作为公共 API 入口,
* 2. 找到均分最高与最低班级 * 保持向后兼容,所有消费者无需修改 import 路径。
* 3. 从 allRows 中提取这两个班级的归一化分数数组
* 4. 调用 stats-service.computeSignificance 计算 Cohen's d 与 p 值
* *
* 返回的 significance 为 null 的场景 * 子文件结构
* - 班级数 < 2 * - data-access-analytics-trend.ts: 成绩趋势分析getGradeTrend + 考试选项 getExamOptionsForGrades
* - 任意班级样本量 < 2 * - data-access-analytics-class.ts: 班级/科目/年级维度分析(班级对比 + 显著性 + 科目对比 + 分布 + 年级分布)
* - 方差为 0所有分数相同 * - 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) { // G3-048: 对导出数 < 15 的子文件改为显式 re-export。
return { items, significance: null } export {
} getGradeTrendRaw,
getGradeTrend,
// 找出均分最高与最低班级 getExamOptionsForGradesRaw,
let topClass = items[0] getExamOptionsForGrades,
let bottomClass = items[0] } from "./data-access-analytics-trend"
for (const item of items) { export type { GradeTrendParams } from "./data-access-analytics-trend"
if (item.averageScore > topClass.averageScore) topClass = item
if (item.averageScore < bottomClass.averageScore) bottomClass = item export {
} getClassComparisonRaw,
getClassComparison,
if (topClass.classId === bottomClass.classId) { getClassComparisonWithSignificanceRaw,
return { items, significance: null } getClassComparisonWithSignificance,
} getSubjectComparisonRaw,
getSubjectComparison,
// 重新查询两个班级的原始分数(归一化 0-100用于统计检验 getGradeDistributionRaw,
const conditions = [ getGradeDistribution,
inArray(gradeRecords.classId, [topClass.classId, bottomClass.classId]), getGradeDistributionByGradeIdRaw,
eq(gradeRecords.subjectId, params.subjectId), getGradeDistributionByGradeId,
] } from "./data-access-analytics-class"
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) export type {
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) ClassComparisonParams,
SubjectComparisonParams,
const scopeFilter = buildScopeClassFilter(params.scope) GradeDistributionParams,
if (scopeFilter) conditions.push(scopeFilter) GradeDistributionByGradeParams,
} from "./data-access-analytics-class"
const rawRows = await db
.select({ export {
classId: gradeRecords.classId, getStudentPositionInClassDistributionRaw,
score: gradeRecords.score, getStudentPositionInClassDistribution,
fullScore: gradeRecords.fullScore, getStudentGrowthArchiveRaw,
}) getStudentGrowthArchive,
.from(gradeRecords) } from "./data-access-analytics-student"
.where(and(...conditions))
export {
const topScores: number[] = [] getSchoolWideGradeSummaryRaw,
const bottomScores: number[] = [] getSchoolWideGradeSummary,
for (const r of rawRows) { } from "./data-access-analytics-overview"
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"],
})

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useCallback } from "react" import { useState, useCallback } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -65,32 +65,32 @@ export function useBatchGradeEntryUndo(): {
try { try {
const raw = sessionStorage.getItem(STORAGE_KEY) const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) { if (!raw) {
toast.error(t("batchByExam.undoNoRecord")) notify.error(t("batchByExam.undoNoRecord"))
return return
} }
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw)
// P1-7 修复:使用类型守卫替代 `as` 断言 // P1-7 修复:使用类型守卫替代 `as` 断言
if (!isUndoData(parsed)) { if (!isUndoData(parsed)) {
toast.error(t("batchByExam.undoFailed")) notify.error(t("batchByExam.undoFailed"))
return return
} }
if (Date.now() - parsed.timestamp > UNDO_TTL_MS) { if (Date.now() - parsed.timestamp > UNDO_TTL_MS) {
toast.error(t("batchByExam.undoExpired")) notify.error(t("batchByExam.undoExpired"))
return return
} }
const result = await undoBatchCreateGradeRecordsAction(parsed.ids) const result = await undoBatchCreateGradeRecordsAction(parsed.ids)
if (result.success) { if (result.success) {
sessionStorage.removeItem(STORAGE_KEY) sessionStorage.removeItem(STORAGE_KEY)
toast.success(result.message) notify.success(result.message)
router.refresh() router.refresh()
} else { } else {
toast.error(result.message || t("batchByExam.undoFailed")) notify.error(result.message || t("batchByExam.undoFailed"))
} }
} catch { } catch {
toast.error(t("batchByExam.undoFailed")) notify.error(t("batchByExam.undoFailed"))
} finally { } finally {
setIsUndoing(false) setIsUndoing(false)
} }

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect, useCallback, useRef } from "react" import { useState, useEffect, useCallback, useRef } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { safeActionCall } from "@/shared/lib/action-utils" import { safeActionCall } from "@/shared/lib/action-utils"
@@ -87,7 +87,7 @@ export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn {
setLockToken(null) setLockToken(null)
tokenRef.current = null tokenRef.current = null
} else { } else {
toast.error(t("collabLock.acquireFailed")) notify.error(t("collabLock.acquireFailed"))
} }
}, [classId, subjectId, type, t]) }, [classId, subjectId, type, t])
@@ -125,7 +125,7 @@ export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn {
setStatus(result?.data?.status ?? null) setStatus(result?.data?.status ?? null)
setLockToken(null) setLockToken(null)
tokenRef.current = null tokenRef.current = null
toast.error(t("collabLock.heartbeatError")) notify.error(t("collabLock.heartbeatError"))
} }
}, HEARTBEAT_INTERVAL_MS) }, HEARTBEAT_INTERVAL_MS)

View File

@@ -2,7 +2,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -50,15 +50,15 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (mode === "exam" && !examId) { if (mode === "exam" && !examId) {
toast.error(t("homework.form.selectExamRequired")) notify.error(t("homework.form.selectExamRequired"))
return return
} }
if (mode === "quick" && !formData.get("title")) { if (mode === "quick" && !formData.get("title")) {
toast.error(t("homework.form.titleRequired")) notify.error(t("homework.form.titleRequired"))
return return
} }
if (!classId) { if (!classId) {
toast.error(t("homework.form.selectClassRequired")) notify.error(t("homework.form.selectClassRequired"))
return return
} }
@@ -75,13 +75,13 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
try { try {
const result = await createHomeworkAssignmentAction(null, formData) const result = await createHomeworkAssignmentAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
router.push("/teacher/homework/assignments") router.push("/teacher/homework/assignments")
} else { } else {
toast.error(result.message || t("homework.form.createFailed")) notify.error(result.message || t("homework.form.createFailed"))
} }
} catch { } catch {
toast.error(t("homework.form.createFailed")) notify.error(t("homework.form.createFailed"))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Zap } from "lucide-react" import { Zap } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -68,7 +68,7 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
const handleBatchAutoGrade = () => { const handleBatchAutoGrade = () => {
if (selectedIds.size === 0) { if (selectedIds.size === 0) {
toast.error(t("homework.grade.batchSelectAtLeastOne")) notify.error(t("homework.grade.batchSelectAtLeastOne"))
return return
} }
@@ -77,11 +77,11 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
formData.set("submissionIds", JSON.stringify(Array.from(selectedIds))) formData.set("submissionIds", JSON.stringify(Array.from(selectedIds)))
const result = await batchAutoGradeSubmissionsAction(null, formData) const result = await batchAutoGradeSubmissionsAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message) notify.success(result.message)
setSelectedIds(new Set()) setSelectedIds(new Set())
router.refresh() router.refresh()
} else { } 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 { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { gradeHomeworkSubmissionAction } from "../actions" import { gradeHomeworkSubmissionAction } from "../actions"
import { import {
@@ -105,13 +105,13 @@ export function HomeworkGradingView({
const result = await gradeHomeworkSubmissionAction(null, formData) const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) { if (result.success) {
toast.success(t("homework.grade.gradesSaved")) notify.success(t("homework.grade.gradesSaved"))
router.refresh() router.refresh()
} else { } else {
toast.error(result.message || t("homework.grade.gradesSaveFailed")) notify.error(result.message || t("homework.grade.gradesSaveFailed"))
} }
} catch { } catch {
toast.error(t("homework.grade.gradesSaveFailed")) notify.error(t("homework.grade.gradesSaveFailed"))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" 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 { Save, ChevronLeft, ChevronRight, User, Clock } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -92,12 +92,12 @@ export function HomeworkScanGradingView({
fd.set("answersJson", JSON.stringify(answersPayload)) fd.set("answersJson", JSON.stringify(answersPayload))
const result = await gradeHomeworkSubmissionAction(null, fd) const result = await gradeHomeworkSubmissionAction(null, fd)
if (result.success) { if (result.success) {
toast.success(t("homework.grade.gradesSaved")) notify.success(t("homework.grade.gradesSaved"))
} else { } else {
toast.error(result.message || t("homework.grade.gradesSaveFailed")) notify.error(result.message || t("homework.grade.gradesSaveFailed"))
} }
} catch { } catch {
toast.error(t("homework.grade.saveFailed")) notify.error(t("homework.grade.saveFailed"))
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
@@ -49,7 +49,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
if (!submissionId) return if (!submissionId) return
const result = await deleteScanAction(submissionId, fileId) const result = await deleteScanAction(submissionId, fileId)
if (!result.success) { 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) { if (changed) {
toast.success(t("homework.take.autoSaveRestored")) notify.success(t("homework.take.autoSaveRestored"))
} }
return merged return merged
}) })
@@ -142,13 +142,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
if (res.success && res.data) { if (res.success && res.data) {
setSubmissionId(res.data) setSubmissionId(res.data)
setSubmissionStatus("started") setSubmissionStatus("started")
toast.success(t("homework.take.startSuccess")) notify.success(t("homework.take.startSuccess"))
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("homework.take.startFailed")) notify.error(res.message || t("homework.take.startFailed"))
} }
} catch { } catch {
toast.error(t("homework.take.startFailed")) notify.error(t("homework.take.startFailed"))
} finally { } finally {
setIsBusy(false) setIsBusy(false)
} }
@@ -162,8 +162,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
fd.set("questionId", questionId) fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload })) fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd) const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success(t("homework.take.saved")) if (res.success) notify.success(t("homework.take.saved"))
else toast.error(res.message || t("homework.take.saveFailed")) else notify.error(res.message || t("homework.take.saveFailed"))
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -178,15 +178,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const submitRes = await submitHomeworkAction(null, submitFd) const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) { if (submitRes.success) {
clearOfflineCache(offlineStorageKey) clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess")) notify.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted") setSubmissionStatus("submitted")
// V3-9: 提交后跳转到结果页,展示即时反馈 // V3-9: 提交后跳转到结果页,展示即时反馈
router.push(`/student/learning/assignments/${assignmentId}/result`) router.push(`/student/learning/assignments/${assignmentId}/result`)
} else { } else {
toast.error(submitRes.message || t("homework.take.submitFailed")) notify.error(submitRes.message || t("homework.take.submitFailed"))
} }
} catch { } catch {
toast.error(t("homework.take.submitFailed")) notify.error(t("homework.take.submitFailed"))
} finally { } finally {
setIsBusy(false) setIsBusy(false)
} }
@@ -217,7 +217,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
enabled: isTimedExam, enabled: isTimedExam,
onExpire: () => { onExpire: () => {
if (submissionStatus === "started" && submissionId) { if (submissionStatus === "started" && submissionId) {
toast.warning(t("homework.take.timeUpAutoSubmit")) notify.warning(t("homework.take.timeUpAutoSubmit"))
void handleSubmit() void handleSubmit()
} }
}, },

View File

@@ -3,7 +3,7 @@
import { useState, useTransition, type ChangeEvent } from "react" import { useState, useTransition, type ChangeEvent } from "react"
import Image from "next/image" import Image from "next/image"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Upload, X, Loader2, ImageIcon } from "lucide-react" import { Upload, X, Loader2, ImageIcon } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -55,7 +55,7 @@ export function ScanUploader({
(f) => f.type.startsWith("image/") || f.type === "application/pdf" (f) => f.type.startsWith("image/") || f.type === "application/pdf"
) )
if (fileArray.length === 0) { if (fileArray.length === 0) {
toast.error(t("homework.take.selectImageFiles")) notify.error(t("homework.take.selectImageFiles"))
return return
} }
@@ -80,17 +80,17 @@ export function ScanUploader({
page: basePage + i + 1, page: basePage + i + 1,
}) })
} else { } else {
toast.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`) notify.error(`${t("homework.take.uploadFailed")}: ${data?.message || ""}`)
} }
} catch (e) { } catch (e) {
console.error("[ScanUploader] upload failed", 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) { if (uploaded.length > 0) {
const next = [...images, ...uploaded] const next = [...images, ...uploaded]
onChange(next) 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 * as React from "react"
import { useTranslations } from "next-intl" 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 { Copy, Hash, Mail, GraduationCap, Clock, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -30,6 +30,7 @@ import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { generateInvitationCodesAction } from "../actions" import { generateInvitationCodesAction } from "../actions"
import { INVITATION_ROLE_VALUES } from "../schema" import { INVITATION_ROLE_VALUES } from "../schema"
import type { InvitationCodeRecord, InvitationRole } from "../types" import type { InvitationCodeRecord, InvitationRole } from "../types"
import { isInvitationRole } from "../lib/type-guards"
/** /**
* 生成邀请码对话框audit-P2-3 新增)。 * 生成邀请码对话框audit-P2-3 新增)。
@@ -78,7 +79,7 @@ export function GenerateInvitationCodesDialog({
setGeneratedCodes(data.codes) setGeneratedCodes(data.codes)
setBatchId(data.batchId) setBatchId(data.batchId)
setView("result") 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") const text = generatedCodes.map((c) => c.code).join("\n")
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.success(t("actions.copied")) notify.success(t("actions.copied"))
} catch { } catch {
toast.error(t("actions.copyFailed")) notify.error(t("actions.copyFailed"))
} }
} }
const handleCopyOne = async (code: string): Promise<void> => { const handleCopyOne = async (code: string): Promise<void> => {
try { try {
await navigator.clipboard.writeText(code) await navigator.clipboard.writeText(code)
toast.success(t("actions.copied")) notify.success(t("actions.copied"))
} catch { } catch {
toast.error(t("actions.copyFailed")) notify.error(t("actions.copyFailed"))
} }
} }
@@ -239,7 +240,7 @@ function GenerateForm({
<Label htmlFor="role">{t("generate.role")}</Label> <Label htmlFor="role">{t("generate.role")}</Label>
<Select <Select
value={role} value={role}
onValueChange={(v) => setRole(v as InvitationRole)} onValueChange={(v) => setRole(isInvitationRole(v) ? v : "student")}
> >
<SelectTrigger id="role"> <SelectTrigger id="role">
<SelectValue /> <SelectValue />

View File

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

View File

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

View File

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

View File

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

View File

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