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