diff --git a/src/modules/grades/actions-analytics.ts b/src/modules/grades/actions-analytics.ts index e12d2f8..c96710a 100644 --- a/src/modules/grades/actions-analytics.ts +++ b/src/modules/grades/actions-analytics.ts @@ -8,9 +8,11 @@ import { handleActionError } from "@/shared/lib/action-utils" import { getClassComparison, getGradeDistribution, + getGradeDistributionByGradeId, getGradeTrend, getSubjectComparison, type ClassComparisonParams, + type GradeDistributionByGradeParams, type GradeDistributionParams, type GradeTrendParams, type SubjectComparisonParams, @@ -18,14 +20,16 @@ import { import { getRankingTrend } from "./data-access-ranking" import { ClassComparisonQuerySchema, + GradeDistributionByGradeQuerySchema, GradeDistributionQuerySchema, GradeTrendQuerySchema, RankingTrendQuerySchema, SubjectComparisonQuerySchema, } from "./schema" -import { assertClassInScope } from "./actions" +import { assertClassInScope } from "./lib/scope-check" import type { ClassComparisonItem, + GradeDistributionByGradeResult, GradeDistributionResult, GradeTrendResult, RankingTrendResult, @@ -191,3 +195,32 @@ export async function getRankingTrendAction( return handleActionError(e) } } + +/** + * 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。 + * grade_managed / all / class_taught scope 均可调用,data-access 层已应用行级过滤。 + */ +export async function getGradeDistributionByGradeIdAction( + params: Omit +): Promise> { + try { + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + + const parsed = GradeDistributionByGradeQuerySchema.safeParse(params) + if (!parsed.success) { + return { + success: false, + message: "Invalid query parameters", + errors: parsed.error.flatten().fieldErrors, + } + } + + const result = await getGradeDistributionByGradeId({ + ...parsed.data, + scope: ctx.dataScope, + }) + return { success: true, data: result } + } catch (e) { + return handleActionError(e) + } +} diff --git a/src/modules/grades/actions.ts b/src/modules/grades/actions.ts index 09a94bc..1cdb5c1 100644 --- a/src/modules/grades/actions.ts +++ b/src/modules/grades/actions.ts @@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access" import { CreateGradeRecordSchema, BatchCreateGradeRecordSchema, + BatchGradeEntryByExamSchema, UpdateGradeRecordSchema, DeleteGradeRecordSchema, GetGradeRecordByIdSchema, @@ -24,6 +25,7 @@ import { import { createGradeRecord, batchCreateGradeRecords, + batchCreateGradeRecordsByExam, undoBatchCreateGradeRecords, updateGradeRecord, deleteGradeRecord, @@ -40,6 +42,7 @@ import { } from "./data-access" import type { PaginatedGradeRecords } from "./data-access" import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access" +import { getExamForGradeEntry } from "@/modules/exams/data-access" import { exportGradeRecordsToExcel, exportClassGradeReportToExcel, @@ -52,37 +55,7 @@ import type { ClassRankingItem, GradeRecord, } from "./types" -import type { DataScope } from "@/shared/types/permissions" - -/** - * Validate that the given classId is within the user's DataScope. - * Returns an error message string when access is denied, otherwise null. - * - * - `all` / `children` / `grade_managed`: allowed (children/grade_managed are - * validated at the data layer via row-level filters; classId scoping would - * require extra cross-module lookups and is intentionally permissive here). - * - `class_taught` / `class_members`: classId must be in scope.classIds. - * - `owned`: never allowed (owned scope has no class context). - */ -export function assertClassInScope( - scope: DataScope, - classId: string -): string | null { - if (scope.type === "all") return null - if (scope.type === "children") return null - if (scope.type === "grade_managed") return null - if (scope.type === "class_taught") { - return scope.classIds.includes(classId) - ? null - : "You can only access classes you teach" - } - if (scope.type === "class_members") { - return scope.classIds.includes(classId) - ? null - : "You can only access your own class" - } - return "Access denied for your scope" -} +import { assertClassInScope } from "./lib/scope-check" /** * v4-P1-6: 成绩录入后通知学生和家长。 @@ -282,6 +255,115 @@ export async function batchCreateGradeRecordsAction( } } +/** + * 按试卷批量录入成绩(每题得分)。 + * + * 必须指定试卷 ID,系统自动从试卷带出科目、标题、满分和题目列表。 + * 教师只需为每个学生填写每题得分,总分自动计算。 + * + * 写入时同步投影到 exam_submissions + submission_answers, + * 使错题集、成绩分析等下游模块无需改造即可读取。 + */ +export async function batchCreateGradeRecordsByExamAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) + + const examId = formData.get("examId") + const classId = formData.get("classId") + const recordsJson = formData.get("recordsJson") + + if (typeof examId !== "string" || examId.length === 0) { + return { success: false, message: "缺少试卷 ID" } + } + if (typeof classId !== "string" || classId.length === 0) { + return { success: false, message: "缺少班级 ID" } + } + if (typeof recordsJson !== "string" || recordsJson.length === 0) { + return { success: false, message: "缺少成绩数据" } + } + + // 获取试卷详情(验证试卷存在 + scope 校验 + 获取 subjectId/fullScore/title) + const exam = await getExamForGradeEntry(examId, ctx.dataScope) + if (!exam) { + return { success: false, message: "试卷不存在或无权访问" } + } + if (exam.questions.length === 0) { + return { success: false, message: "试卷没有题目,无法录入" } + } + if (!exam.subjectId) { + return { success: false, message: "试卷未关联科目" } + } + + // 校验班级在 scope 内 + const scopeError = assertClassInScope(ctx.dataScope, classId) + if (scopeError) { + return { success: false, message: scopeError } + } + + const records = safeJsonParse(recordsJson, "成绩数据格式无效") + + const parsed = BatchGradeEntryByExamSchema.safeParse({ + examId, + classId, + subjectId: exam.subjectId, + title: exam.title, + fullScore: exam.totalScore, + type: formData.get("type") || undefined, + semester: formData.get("semester") || undefined, + records, + }) + + if (!parsed.success) { + return { + success: false, + message: "表单数据无效", + errors: parsed.error.flatten().fieldErrors, + } + } + + const ids = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId) + + // 更新诊断掌握度 + for (const record of parsed.data.records) { + const totalScore = record.answers.reduce((sum, a) => sum + a.score, 0) + try { + await updateMasteryFromExamScore( + record.studentId, + parsed.data.examId, + totalScore, + parsed.data.fullScore, + ) + } catch { + // 掌握度更新失败不阻断成绩录入 + } + } + + // 通知学生和家长 + try { + await notifyGradeEntered({ + studentIds: parsed.data.records.map((r) => r.studentId), + title: parsed.data.title, + subjectId: parsed.data.subjectId, + fullScore: parsed.data.fullScore, + }) + } catch { + // 通知失败不阻断成绩录入 + } + + revalidatePath("/teacher/grades") + return { + success: true, + message: `录入 ${ids.length} 条成绩记录`, + data: ids, + } + } catch (e) { + return handleActionError(e) + } +} + /** * v3-P2-3: 撤销最近一次批量成绩录入。 * 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。 diff --git a/src/modules/grades/components/batch-grade-entry.tsx b/src/modules/grades/components/batch-grade-entry.tsx index 0076902..2819c58 100644 --- a/src/modules/grades/components/batch-grade-entry.tsx +++ b/src/modules/grades/components/batch-grade-entry.tsx @@ -1,17 +1,17 @@ "use client" -import { useState, useRef, useEffect, useCallback, useMemo } from "react" -import { useFormStatus } from "react-dom" +import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type ClipboardEvent } from "react" import { toast } from "sonner" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" -import { Search, TrendingUp, Trophy, AlertCircle, Download, Info, X } from "lucide-react" +import { Search, Info, AlertCircle } from "lucide-react" -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { Badge } from "@/shared/components/ui/badge" import { Table, TableBody, @@ -23,457 +23,247 @@ import { import { cn } from "@/shared/lib/utils" import { safeActionCall } from "@/shared/lib/action-utils" -import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions" -import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types" -import { isGradeType, isSemester } from "../lib/type-guards" +import { batchCreateGradeRecordsByExamAction, undoBatchCreateGradeRecordsAction } from "../actions" +import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types" type Student = { id: string; name: string; email: string } +type ClassOption = { id: string; name: string } -interface DraftData { - scores: Record - timestamp: number +interface Props { + exams: ExamOptionForEntry[] + classes: ClassOption[] + classGradeMap: Record + exam: ExamForGradeEntry | null + students: Student[] + defaultExamId?: string + defaultClassId?: string } -interface UndoData { - ids: string[] - timestamp: number -} - -/** 类型守卫:验证 localStorage 草稿数据结构 */ -function isDraftData(v: unknown): v is DraftData { - if (typeof v !== "object" || v === null) return false - // 从 unknown 转换为 Record 以访问属性(已通过对象检查) - const obj = v as Record - if (typeof obj.timestamp !== "number") return false - if (typeof obj.scores !== "object" || obj.scores === null) return false - // 从 unknown 转换:已通过对象检查 - const scores = obj.scores as Record - return Object.values(scores).every((val) => typeof val === "string") -} - -/** 类型守卫:验证 sessionStorage 撤销数据结构 */ -function isUndoData(v: unknown): v is UndoData { - if (typeof v !== "object" || v === null) return false - // 从 unknown 转换为 Record 以访问属性(已通过对象检查) - const obj = v as Record - if (typeof obj.timestamp !== "number") return false - if (!Array.isArray(obj.ids)) return false - return obj.ids.every((id) => typeof id === "string") -} - -const MAX_SCORE = 100 -const DRAFT_KEY_PREFIX = "grade-draft" - -/** 将创建的记录 ID 序列化为可撤销的 sessionStorage 数据(提取到组件外部避免 purity lint) */ +/** 序列化撤销数据 */ function serializeUndoData(ids: string[]): string { return JSON.stringify({ ids, timestamp: Date.now() }) } -function SubmitButton() { - const t = useTranslations("grades") - const { pending } = useFormStatus() - return ( - - ) -} - -export function BatchGradeEntry({ +export function BatchGradeEntryByExam({ + exams, classes, - subjects, + classGradeMap, + exam, students, + defaultExamId, defaultClassId, - defaultSubjectId, -}: { - classes: SelectOption[] - subjects: SelectOption[] - students: Student[] - defaultClassId?: string - defaultSubjectId?: string -}) { - const t = useTranslations("grades") +}: Props): JSX.Element { const router = useRouter() - const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam` - const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") - const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "") - const [type, setType] = useState("exam") - const [semester, setSemester] = useState("1") - const [scores, setScores] = useState>(() => { - // 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染) - // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 - if (typeof window === "undefined") return {} - try { - const raw = localStorage.getItem(initialDraftKey) - if (raw) { - const parsed: unknown = JSON.parse(raw) - if ( - isDraftData(parsed) && - Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && - Object.keys(parsed.scores).length > 0 - ) { - return parsed.scores - } - } - } catch { - // 解析失败,忽略 - } - return {} - }) - const [draftRestored] = useState(() => { - // 检查是否恢复了草稿(用于显示 toast) - // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 - if (typeof window === "undefined") return false - try { - const raw = localStorage.getItem(initialDraftKey) - if (raw) { - const parsed: unknown = JSON.parse(raw) - if (isDraftData(parsed)) { - return ( - Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && - Object.keys(parsed.scores).length > 0 - ) - } - } - } catch { - // 解析失败,忽略 - } - return false - }) + const t = useTranslations("grades") + const [scores, setScores] = useState>>({}) const [searchQuery, setSearchQuery] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) const inputRefs = useRef>({}) - const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}` + // 按试卷 gradeId 过滤班级 + const filteredClasses = useMemo(() => { + if (!exam?.gradeId) return classes + return classes.filter((c) => classGradeMap[c.id] === exam.gradeId) + }, [classes, classGradeMap, exam]) - // P3-12 修复:使用 ref 保存最新 scores,避免草稿保存 useEffect 依赖 scores 导致每次按键触发重建定时器 - const scoresRef = useRef(scores) - useEffect(() => { - scoresRef.current = scores - }, [scores]) + // 过滤学生 + const filteredStudents = useMemo(() => { + if (!searchQuery) return students + const q = searchQuery.toLowerCase() + return students.filter( + (s) => s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) + ) + }, [students, searchQuery]) - // 草稿恢复提示(仅在首次挂载时显示一次) - useEffect(() => { - if (draftRestored) { - toast.info(t("batch.restored")) + const handleExamChange = (examId: string): void => { + if (Object.keys(scores).length > 0) { + if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return } - }, [draftRestored, t]) - - // v3-P2-10: 服务端草稿恢复(localStorage 无草稿时,尝试从服务端恢复) - useEffect(() => { - if (draftRestored) return // 本地已恢复,跳过 - if (typeof window === "undefined") return - - let cancelled = false - void (async () => { - const result = await safeActionCall(() => - getGradeDraftAction({ classId, subjectId, type }) - ) - if (cancelled || !result || !result.success || !result.data) return - - const draft = result.data - // 24 小时过期检查(服务端已检查,这里双重校验) - if (Date.now() - draft.timestamp > 24 * 60 * 60 * 1000) return - if (Object.keys(draft.scores).length === 0) return - - setScores(draft.scores) - toast.info(t("batch.restoredFromServer")) - })() - - return () => { cancelled = true } - }, [classId, subjectId, type, draftRestored, t]) - - const handleScoreChange = useCallback((studentId: string, value: string) => { - if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { - setScores((prev) => ({ ...prev, [studentId]: value })) - } - }, []) - - const validateScore = useCallback((value: string): boolean => { - if (value === "") return true - const num = Number(value) - return !isNaN(num) && num >= 0 && num <= MAX_SCORE - }, []) - - const restoreDraft = useCallback((key: string): boolean => { - // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 - if (typeof window === "undefined") return false - try { - const raw = localStorage.getItem(key) - if (raw) { - const parsed: unknown = JSON.parse(raw) - if ( - isDraftData(parsed) && - Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && - Object.keys(parsed.scores).length > 0 - ) { - setScores(parsed.scores) - return true - } - } - } catch { - // 解析失败,忽略 - } - return false - }, []) - - const handleClassChange = (newClassId: string) => { - const hasUnsaved = Object.keys(scores).length > 0 - if (hasUnsaved && newClassId !== classId) { - if (!window.confirm(t("batch.confirmSwitchClass"))) { - return - } - } - setClassId(newClassId) setScores({}) - // 切换班级后尝试恢复该班级的草稿 - const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}` - if (restoreDraft(newDraftKey)) { - toast.info(t("batch.restored")) - } - const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry" - router.push(newUrl) + router.push(`/teacher/grades/entry?examId=${examId}`) } - const filteredStudents = useMemo( - () => students.filter((s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())), - [students, searchQuery] - ) + const handleClassChange = (classId: string): void => { + if (Object.keys(scores).length > 0) { + if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return + } + setScores({}) + router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`) + } - /** - * Excel 粘贴处理器:从剪贴板解析一列分数,按当前学生列表顺序填充。 - * 支持格式: - * - 单列分数(每行一个数字) - * - 多列(取第一列或第一个可解析为数字的列) - * - Tab/逗号/空格分隔 - */ - const handlePaste = useCallback((e: React.ClipboardEvent, startIndex: number) => { + const handleScoreChange = (studentId: string, questionId: string, value: string): void => { + setScores((prev) => ({ + ...prev, + [studentId]: { + ...(prev[studentId] ?? {}), + [questionId]: value, + }, + })) + } + + // 计算学生总分 + const computeTotal = (studentId: string): number => { + const studentScores = scores[studentId] + if (!studentScores || !exam) return 0 + return exam.questions.reduce((sum, q) => { + const raw = studentScores[q.id] + if (raw === undefined || raw === "") return sum + const n = parseFloat(raw) + return sum + (isNaN(n) ? 0 : n) + }, 0) + } + + // 检查分数是否超出满分 + const isScoreInvalid = (studentId: string, questionId: string): boolean => { + if (!exam) return false + const question = exam.questions.find((q) => q.id === questionId) + if (!question) return false + const raw = scores[studentId]?.[questionId] + if (raw === undefined || raw === "") return false + const n = parseFloat(raw) + if (isNaN(n)) return true + return n < 0 || n > question.score + } + + // 统计 + const stats = useMemo(() => { + const entered = students.filter((s) => { + if (!exam) return false + const ss = scores[s.id] + if (!ss) return false + return exam.questions.every((q) => ss[q.id] !== undefined && ss[q.id] !== "") + }).length + const totals = students + .map((s) => computeTotal(s.id)) + .filter((n) => n > 0) + const avg = totals.length > 0 ? totals.reduce((a, b) => a + b, 0) / totals.length : 0 + const max = totals.length > 0 ? Math.max(...totals) : 0 + const min = totals.length > 0 ? Math.min(...totals) : 0 + return { entered, total: students.length, avg, max, min } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scores, students, exam]) + + // Excel 粘贴:支持多行多列 + const handlePaste = (e: ClipboardEvent, startStudentId: string, startQuestionId: string): void => { + if (!exam) return const text = e.clipboardData.getData("text") - if (!text) return - - // 按行分割(兼容 \r\n 和 \n) - const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "") + const lines = text.split(/\r?\n/).filter((l) => l.trim()) if (lines.length === 0) return - // 尝试从每行提取数字(支持单列和多列格式) - const values: string[] = [] - for (const line of lines) { - const trimmed = line.trim() - // 尝试 Tab/逗号分隔,取第一个可解析为数字的部分 - const parts = trimmed.split(/[\t,]/).map((p) => p.trim()) - let found: string | null = null - for (const part of parts) { - if (part !== "" && /^\d*\.?\d+$/.test(part)) { - found = part - break + const startStudentIdx = students.findIndex((s) => s.id === startStudentId) + const startQuestionIdx = exam.questions.findIndex((q) => q.id === startQuestionId) + if (startStudentIdx === -1 || startQuestionIdx === -1) return + + const newScores: Record> = {} + let pastedCount = 0 + + lines.forEach((line, lineIdx) => { + const student = students[startStudentIdx + lineIdx] + if (!student) return + const cells = line.split(/\t/).filter((c) => c.trim() || c === "0") + if (cells.length === 1) { + // 单列:按行填充当前题目 + newScores[student.id] = { + ...(newScores[student.id] ?? {}), + [exam.questions[startQuestionIdx].id]: cells[0].trim(), } + pastedCount++ + } else { + // 多列:按行×列填充 + cells.forEach((cell, colIdx) => { + const question = exam.questions[startQuestionIdx + colIdx] + if (question) { + newScores[student.id] = { + ...(newScores[student.id] ?? {}), + [question.id]: cell.trim(), + } + } + }) + pastedCount++ } - // 如果多列没匹配到,尝试整行作为数字 - if (found === null && /^\d*\.?\d+$/.test(trimmed)) { - found = trimmed - } - if (found !== null) values.push(found) - } + }) - if (values.length === 0) { - toast.error(t("batch.pasteNoMatch")) - e.preventDefault() - return - } - - // 按当前过滤后的学生列表顺序填充 - const targetStudents = filteredStudents.slice(startIndex) - const newScores: Record = {} - let appliedCount = 0 - for (let i = 0; i < Math.min(values.length, targetStudents.length); i += 1) { - const student = targetStudents[i] - if (!student) break - const val = values[i] - // 校验分数格式(与 handleScoreChange 一致) - if (/^\d*\.?\d{0,2}$/.test(val)) { - newScores[student.id] = val - appliedCount += 1 - } - } - - if (appliedCount > 0) { - setScores((prev) => ({ ...prev, ...newScores })) - toast.success(t("batch.pasteApplied", { count: appliedCount })) - e.preventDefault() - } - }, [filteredStudents, t]) - - const stats = useMemo(() => { - const validScores = students - .map((s) => scores[s.id]) - .filter((v): v is string => v !== undefined && v !== "" && validateScore(v)) - .map(Number) - const entered = validScores.length - if (entered === 0) return { entered: 0, average: 0, max: 0, min: 0, total: students.length } - const sum = validScores.reduce((acc, v) => acc + v, 0) - return { - entered, - average: Math.round((sum / entered) * 100) / 100, - max: Math.max(...validScores), - min: Math.min(...validScores), - total: students.length, - } - }, [scores, students, validateScore]) - - // v3-P3-1: 下载成绩录入模板(CSV,含学生姓名列表,支持 Excel 粘贴) - const handleDownloadTemplate = useCallback(() => { - const headers = [t("batch.templateStudentName"), t("batch.templateScore"), t("batch.templateRemark")] - const rows = students.map((s) => [s.name, "", ""]) - const csv = [headers, ...rows] - .map((r) => r.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) - .join("\n") - // 添加 BOM 以支持 Excel 正确识别 UTF-8 - const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }) - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = t("batch.templateFilename") - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - }, [students, t]) - - // v4-P3-2: 新手引导提示框,使用 localStorage 记住用户是否已关闭 - const [guideVisible, setGuideVisible] = useState(() => { - if (typeof window === "undefined") return true - try { - return localStorage.getItem("grade-entry-guide-dismissed") !== "true" - } catch { - return true - } - }) - - const handleDismissGuide = useCallback(() => { - setGuideVisible(false) - try { - localStorage.setItem("grade-entry-guide-dismissed", "true") - } catch { - // localStorage 不可用时静默失败 - } - }, []) - - const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v)) - - // v3-P2-10: 草稿保存到 localStorage(30秒间隔)+ 服务端同步(60秒间隔) - // P3-12 修复:使用 scoresRef 读取最新 scores,定时器不再依赖 scores,避免每次按键重建定时器 - useEffect(() => { - // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 - if (typeof window === "undefined") return - - // 本地草稿:30 秒间隔,快速恢复 - const localInterval = setInterval(() => { - const currentScores = scoresRef.current - if (Object.keys(currentScores).length > 0) { - try { - localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() })) - } catch { - // localStorage 可能已满或不可用,静默失败 + if (pastedCount > 0) { + setScores((prev) => { + const merged = { ...prev } + for (const [sid, qs] of Object.entries(newScores)) { + merged[sid] = { ...(merged[sid] ?? {}), ...qs } } - } - }, 30000) - - // 服务端草稿:60 秒间隔,跨设备同步 - const serverInterval = setInterval(() => { - const currentScores = scoresRef.current - if (Object.keys(currentScores).length > 0) { - void safeActionCall(() => - saveGradeDraftAction({ - classId, - subjectId, - type, - scores: currentScores, - }) - ) - } - }, 60000) - - return () => { - clearInterval(localInterval) - clearInterval(serverInterval) + return merged + }) + e.preventDefault() + toast.success(t("batchByExam.pasteApplied", { count: pastedCount })) } - }, [draftKey, classId, subjectId, type]) + } - // 清除草稿(本地 + 服务端) - const clearDraft = useCallback(() => { - // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 - if (typeof window === "undefined") return - try { - localStorage.removeItem(draftKey) - } catch { - // 忽略 - } - // v3-P2-10: 同步清除服务端草稿 - void safeActionCall(() => - deleteGradeDraftAction({ classId, subjectId, type }) - ) - }, [draftKey, classId, subjectId, type]) - - const handleKeyDown = (e: React.KeyboardEvent, studentId: string) => { + // Enter 跳下一行同一列 + const handleKeyDown = (e: KeyboardEvent, studentId: string, questionId: string): void => { if (e.key === "Enter") { e.preventDefault() - const currentIndex = filteredStudents.findIndex((s) => s.id === studentId) - const nextStudent = filteredStudents[currentIndex + 1] - if (nextStudent && inputRefs.current[nextStudent.id]) { - inputRefs.current[nextStudent.id]?.focus() - inputRefs.current[nextStudent.id]?.select() + const studentIdx = students.findIndex((s) => s.id === studentId) + const nextStudent = students[studentIdx + 1] + if (nextStudent) { + const ref = inputRefs.current[`${nextStudent.id}-${questionId}`] + ref?.focus() + ref?.select() } } } - const handleSubmit = async (formData: FormData) => { - if (!classId || !subjectId) { - toast.error(t("batch.selectClassAndSubject")) - return - } + const hasInvalidScores = useMemo(() => { + if (!exam) return false + return students.some((s) => + exam.questions.some((q) => isScoreInvalid(s.id, q.id)) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scores, students, exam]) + const handleSubmit = async (): Promise => { + if (!exam || !defaultClassId || !defaultExamId) return if (hasInvalidScores) { - toast.error(t("batch.invalidScoresError")) + toast.error(t("batchByExam.invalidScoresError")) return } - // P3 修复:区分"未输入"和"输入 0" - // 只有当 scores[studentId] 有值且非空字符串时才视为已输入 - // 这样输入 0 会被正确提交,而未输入会被跳过 const records = students .map((s) => { - const raw = scores[s.id] - if (raw === undefined || raw === "") return null + const studentScores = scores[s.id] + if (!studentScores) return null + const hasAny = exam.questions.some( + (q) => studentScores[q.id] !== undefined && studentScores[q.id] !== "" + ) + if (!hasAny) return null return { studentId: s.id, - score: Number(raw), + answers: exam.questions.map((q) => ({ + questionId: q.id, + score: parseFloat(studentScores[q.id] ?? "0") || 0, + })), } }) - .filter((r): r is { studentId: string; score: number } => r !== null) + .filter((r): r is NonNullable => r !== null) if (records.length === 0) { - toast.error(t("batch.enterAtLeastOne")) + toast.error(t("batchByExam.enterAtLeastOne")) return } - setIsSubmitting(true) - formData.set("classId", classId) - formData.set("subjectId", subjectId) - formData.set("type", type) - formData.set("semester", semester) + const formData = new FormData() + formData.set("examId", defaultExamId) + formData.set("classId", defaultClassId) formData.set("recordsJson", JSON.stringify(records)) - // P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态 + setIsSubmitting(true) const result = await safeActionCall( - () => batchCreateGradeRecordsAction(null, formData), + () => batchCreateGradeRecordsByExamAction(null, formData), { onError: () => toast.error(t("error.saveFailed")), onFinally: () => setIsSubmitting(false), } ) + if (result?.success) { - clearDraft() - // v3-P2-3:保存创建的记录 ID 到 sessionStorage,供撤销使用 + setScores({}) const createdIds = result.data ?? [] if (createdIds.length > 0) { try { @@ -482,13 +272,12 @@ export function BatchGradeEntry({ serializeUndoData(createdIds) ) } catch { - // sessionStorage 不可用时静默失败(不影响主流程) + // sessionStorage 不可用时静默失败 } - // 显示带撤销按钮的 toast,10 秒内可撤销 toast.success(result.message, { duration: 10000, action: { - label: t("batch.undo"), + label: t("batchByExam.undo"), onClick: () => handleUndo(), }, }) @@ -502,282 +291,215 @@ export function BatchGradeEntry({ } } - /** - * v3-P2-3: 撤销最近一次批量录入。 - * 从 sessionStorage 读取上次创建的记录 ID 列表,调用 undo action 删除。 - */ - const handleUndo = useCallback(async () => { - if (typeof window === "undefined") return - let ids: string[] = [] + const handleUndo = async (): Promise => { try { const raw = sessionStorage.getItem("lastBatchGradeRecordIds") if (!raw) { - toast.error(t("batch.undoNoRecord")) + toast.error(t("batchByExam.undoNoRecord")) return } - const parsed: unknown = JSON.parse(raw) - if (!isUndoData(parsed)) { - toast.error(t("batch.undoNoRecord")) + const data = JSON.parse(raw) as { ids: string[]; timestamp: number } + if (Date.now() - data.timestamp > 5 * 60 * 1000) { + toast.error(t("batchByExam.undoExpired")) return } - // 仅允许 5 分钟内的撤销 - if (Date.now() - parsed.timestamp > 5 * 60 * 1000) { + const result = await undoBatchCreateGradeRecordsAction(data.ids) + if (result.success) { sessionStorage.removeItem("lastBatchGradeRecordIds") - toast.error(t("batch.undoExpired")) - return + toast.success(result.message) + router.refresh() + } else { + toast.error(result.message || t("batchByExam.undoFailed")) } - ids = parsed.ids } catch { - toast.error(t("batch.undoNoRecord")) - return + toast.error(t("batchByExam.undoFailed")) } + } - if (ids.length === 0) { - toast.error(t("batch.undoNoRecord")) - return - } - - const result = await safeActionCall( - () => undoBatchCreateGradeRecordsAction(ids), - { - onError: () => toast.error(t("batch.undoFailed")), - } + // 未选试卷 + if (!exam) { + return ( + + + {t("batchByExam.selectExam")} + + +
+ + +
+
+ + {t("batchByExam.guideSelectExam")} +
+
+
) - if (result?.success) { - try { - sessionStorage.removeItem("lastBatchGradeRecordIds") - } catch { - // 忽略 - } - toast.success(result.message) - router.refresh() - } else if (result) { - toast.error(result.message || t("batch.undoFailed")) - } - }, [router, t]) + } return ( - - {isSubmitting && ( -
-
-
- {t("batch.savingGrades")} +
+ {/* 试卷 + 班级选择器 */} + + + {t("batchByExam.title")} + + +
+ +
-
- )} - - {t("batch.title")} -

- {t("batch.fullScoreHint", { max: MAX_SCORE })} -

-
- - {/* v4-P3-2: 新手引导提示框 */} - {guideVisible && ( -
-
-
+
+ +
- )} -
-
-
- - -
+ {/* 试卷信息 */} +
+ + {t("batchByExam.questionCount", { count: exam.questions.length })} + + + {t("batchByExam.fullScore", { score: exam.totalScore })} + +
+ + -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - + {/* 未选班级 */} + {!defaultClassId ? ( + + + {t("batchByExam.selectClassFirst")} + + + ) : students.length === 0 ? ( + + + {t("batchByExam.noStudents")} + + + ) : ( + <> + {/* 统计栏 */} +
+ + {t("batchByExam.entered")}: {stats.entered}/{stats.total} + + {stats.entered > 0 && ( + <> + + {t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10} + + + {t("batchByExam.max")}: {stats.max} + + + {t("batchByExam.min")}: {stats.min} + + + )} + {hasInvalidScores && ( + + + {t("batchByExam.invalidScoresBadge")} + + )} +
+ + setSearchQuery(e.target.value)} + className="h-8 w-48 pl-8" + />
- {students.length === 0 ? ( -

{t("batch.noStudentsInClass")}

- ) : ( - <> - {/* 实时统计栏 */} -
-
- - {t("batch.entered")} - {stats.entered} - / {stats.total} - - {stats.entered > 0 && ( - <> - - - - - - {t("batch.min")} - {stats.min} - - - )} - {t("batch.pasteHint")} -
-
- {hasInvalidScores && ( - - - )} - {/* v3-P3-1: 下载成绩录入模板 */} - -
- - setSearchQuery(e.target.value)} - className="h-8 w-40 pl-8 text-sm" - /> -
-
-
-
+ {/* Excel 式表格 */} + + +
- - - # - {t("list.columns.student")} - {t("batch.emailColumn")} - {t("batch.score")} + + # + {t("batchByExam.studentName")} + {exam.questions.map((q, idx) => ( + +
{t("batchByExam.question", { n: idx + 1 })}
+
({q.score}{t("batchByExam.points")})
+
+ ))} + {t("batchByExam.totalScore")}
- {filteredStudents.map((s, idx) => { - const scoreValue = scores[s.id] ?? "" - const isInvalid = scoreValue !== "" && !validateScore(scoreValue) + {filteredStudents.map((s, sIdx) => { + const total = computeTotal(s.id) return ( - {idx + 1} + {sIdx + 1} {s.name} - {s.email} - - { inputRefs.current[s.id] = el }} - type="number" - step="0.01" - min="0" - max={MAX_SCORE} - placeholder="0" - value={scoreValue} - onChange={(e) => handleScoreChange(s.id, e.target.value)} - onKeyDown={(e) => handleKeyDown(e, s.id)} - onPaste={(e) => handlePaste(e, idx)} - className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")} - aria-invalid={isInvalid} - /> + {exam.questions.map((q) => { + const invalid = isScoreInvalid(s.id, q.id) + const key = `${s.id}-${q.id}` + return ( + + { inputRefs.current[key] = el }} + type="number" + step="0.5" + min="0" + max={q.score} + value={scores[s.id]?.[q.id] ?? ""} + onChange={(e) => handleScoreChange(s.id, q.id, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, s.id, q.id)} + onPaste={(e) => handlePaste(e, s.id, q.id)} + onFocus={(e) => e.target.select()} + className={cn( + "h-8 text-center tabular-nums", + invalid && "border-destructive focus-visible:ring-destructive" + )} + aria-label={`${s.name} - ${t("batchByExam.question", { n: exam.questions.findIndex(qq => qq.id === q.id) + 1 })}`} + /> + + ) + })} + + {Math.round(total * 10) / 10} ) @@ -785,17 +507,35 @@ export function BatchGradeEntry({
{t("batch.caption")}
- - )} +
+
- - - - - - - + +
+ + {/* 提示 */} +
+ + {t("batchByExam.pasteHint")} +
+ + )} +
) } diff --git a/src/modules/grades/data-access-analytics.ts b/src/modules/grades/data-access-analytics.ts index 2e0dbdd..7c60faa 100644 --- a/src/modules/grades/data-access-analytics.ts +++ b/src/modules/grades/data-access-analytics.ts @@ -24,6 +24,7 @@ import { } from "./stats-service" import type { ClassComparisonItem, + GradeDistributionByGradeResult, GradeDistributionResult, GradeTrendResult, SchoolWideGradeSummary, @@ -391,3 +392,90 @@ export const getSchoolWideGradeSummary = cache( } } ) + +/** + * 年级仪表盘 - 维度1:获取年级整体 + 按班级拆分的成绩分布。 + * 通过 getClassesByGradeId 获取年级下所有班级,再用 inArray 查询成绩记录, + * 应用 buildScopeClassFilter 行级权限过滤,复用 computeGradeDistribution / computeGradeStats 纯函数。 + */ +export interface GradeDistributionByGradeParams { + gradeId: string + subjectId?: string + examId?: string + semester?: "1" | "2" + scope: DataScope +} + +export const getGradeDistributionByGradeId = cache( + async (params: GradeDistributionByGradeParams): Promise => { + const classRows = await getClassesByGradeId(params.gradeId) + + if (classRows.length === 0) { + return { + overall: computeGradeDistribution([]), + stats: null, + byClass: [], + } + } + + // scope 过滤:class_taught 仅保留当前教师所教班级 + const scope = params.scope + const scopeClassIdSet = + scope.type === "class_taught" ? new Set(scope.classIds) : null + const allowedClassRows = scopeClassIdSet + ? classRows.filter((c) => scopeClassIdSet.has(c.id)) + : classRows + + if (allowedClassRows.length === 0) { + return { + overall: computeGradeDistribution([]), + stats: null, + byClass: [], + } + } + + const allowedClassIds = allowedClassRows.map((c) => c.id) + + const conditions = [inArray(gradeRecords.classId, allowedClassIds)] + if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) + if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) + if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester)) + + const scopeFilter = buildScopeClassFilter(params.scope) + if (scopeFilter) conditions.push(scopeFilter) + + const rows = await db + .select({ + classId: gradeRecords.classId, + score: gradeRecords.score, + fullScore: gradeRecords.fullScore, + }) + .from(gradeRecords) + .where(and(...conditions)) + + const overall = computeGradeDistribution(rows) + const stats = computeGradeStats(rows) + + const byClassMap = new Map() + for (const r of rows) { + const existing = byClassMap.get(r.classId) + if (existing) { + existing.push(r) + } else { + byClassMap.set(r.classId, [r]) + } + } + + const byClass = allowedClassRows.map((cls) => { + const classRowsForClass = byClassMap.get(cls.id) ?? [] + return { + classId: cls.id, + className: cls.name, + distribution: computeGradeDistribution(classRowsForClass), + stats: computeGradeStats(classRowsForClass), + } + }) + + return { overall, stats, byClass } + } +) diff --git a/src/modules/grades/data-access.ts b/src/modules/grades/data-access.ts index d7fd334..f08eb30 100644 --- a/src/modules/grades/data-access.ts +++ b/src/modules/grades/data-access.ts @@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2" import { and, count, desc, eq, inArray, sql } from "drizzle-orm" import { db } from "@/shared/db" -import { gradeDrafts, gradeRecords } from "@/shared/db/schema" +import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema" import { getActiveStudentIdsByClassId, getClassExists, @@ -32,6 +32,7 @@ import type { } from "./types" import type { BatchCreateGradeRecordInput, + BatchGradeEntryByExamInput, CreateGradeRecordInput, UpdateGradeRecordInput, } from "./schema" @@ -196,6 +197,163 @@ export async function batchCreateGradeRecords( return rows.map((r) => r.id) } +/** + * 按试卷批量录入成绩(每题得分)。 + * + * 写入流程(单事务): + * 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷 + * 2. 查询已有 exam_submissions(避免主键冲突) + * 3. 为每个学生: + * - 创建 grade_records(score = 各题得分之和) + * - 创建 grade_record_answers(每题得分) + * - 创建/更新 exam_submissions(status='graded',投影供下游模块使用) + * - 创建/替换 submission_answers(answerContent=null,投影每题得分) + * + * 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现, + * 这样错题集、成绩分析等下游模块无需改造即可读取。 + */ +export async function batchCreateGradeRecordsByExam( + data: BatchGradeEntryByExamInput, + recordedBy: string +): Promise { + if (data.records.length === 0) return [] + + const now = new Date() + const gradeRecordIds: string[] = [] + + await db.transaction(async (tx) => { + // 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属) + const examQuestionRows = await tx + .select({ + questionId: examQuestions.questionId, + score: examQuestions.score, + }) + .from(examQuestions) + .where(eq(examQuestions.examId, data.examId)) + + if (examQuestionRows.length === 0) { + throw new Error("试卷没有题目,无法录入成绩") + } + + const questionFullScoreMap = new Map( + examQuestionRows.map((q) => [q.questionId, q.score ?? 0]) + ) + + // 校验所有 questionId 都属于该试卷 + for (const record of data.records) { + for (const answer of record.answers) { + if (!questionFullScoreMap.has(answer.questionId)) { + throw new Error( + `题目 ${answer.questionId} 不属于试卷 ${data.examId}` + ) + } + } + } + + // 2. 查询已有 exam_submissions(避免冲突) + const studentIds = data.records.map((r) => r.studentId) + const existingSubmissions = await tx + .select({ + id: examSubmissions.id, + studentId: examSubmissions.studentId, + }) + .from(examSubmissions) + .where( + and( + eq(examSubmissions.examId, data.examId), + inArray(examSubmissions.studentId, studentIds) + ) + ) + const existingSubmissionMap = new Map( + existingSubmissions.map((s) => [s.studentId, s.id]) + ) + + // 3. 为每个学生创建记录 + for (const record of data.records) { + const gradeRecordId = createId() + gradeRecordIds.push(gradeRecordId) + + const totalScore = record.answers.reduce( + (sum, a) => sum + a.score, + 0 + ) + + // 3a. grade_records + await tx.insert(gradeRecords).values({ + id: gradeRecordId, + studentId: record.studentId, + classId: data.classId, + subjectId: data.subjectId, + examId: data.examId, + academicYearId: null, + title: data.title, + score: String(totalScore), + fullScore: String(data.fullScore), + type: data.type ?? "exam", + semester: data.semester ?? "1", + recordedBy, + remark: record.remark ?? null, + }) + + // 3b. grade_record_answers + const answerRows = record.answers.map((a) => ({ + id: createId(), + gradeRecordId, + questionId: a.questionId, + score: String(a.score), + fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0), + feedback: null, + })) + await tx.insert(gradeRecordAnswers).values(answerRows) + + // 3c. 投影到 exam_submissions + const existingSubmissionId = existingSubmissionMap.get(record.studentId) + let submissionId: string + + if (existingSubmissionId) { + await tx + .update(examSubmissions) + .set({ + score: totalScore, + status: "graded", + submittedAt: now, + updatedAt: now, + }) + .where(eq(examSubmissions.id, existingSubmissionId)) + submissionId = existingSubmissionId + + // 删除旧 submission_answers + await tx + .delete(submissionAnswers) + .where(eq(submissionAnswers.submissionId, submissionId)) + } else { + submissionId = createId() + await tx.insert(examSubmissions).values({ + id: submissionId, + examId: data.examId, + studentId: record.studentId, + score: totalScore, + status: "graded", + submittedAt: now, + }) + } + + // 3d. submission_answers 投影(answerContent=null,只有得分) + const submissionAnswerRows = record.answers.map((a) => ({ + id: createId(), + submissionId, + questionId: a.questionId, + answerContent: null, + score: a.score, + feedback: null, + })) + await tx.insert(submissionAnswers).values(submissionAnswerRows) + } + }) + + return gradeRecordIds +} + /** * v3-P2-3: 批量撤销成绩录入。 * 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。 diff --git a/src/modules/grades/lib/scope-check.ts b/src/modules/grades/lib/scope-check.ts new file mode 100644 index 0000000..d2a4df7 --- /dev/null +++ b/src/modules/grades/lib/scope-check.ts @@ -0,0 +1,32 @@ +import type { DataScope } from "@/shared/types/permissions" + +/** + * 校验给定 classId 是否在用户 DataScope 范围内。 + * 返回错误消息字符串表示拒绝访问;返回 null 表示允许。 + * + * - `all` / `children` / `grade_managed`: 允许(children/grade_managed 在数据层做行级过滤) + * - `class_taught` / `class_members`: classId 必须在 scope.classIds 中 + * - `owned`: 永远拒绝(owned scope 无 class 上下文) + * + * 注意:本函数为纯同步函数,**不能**放在 "use server" 文件中直接 export, + * 否则 Next.js 会将其视为 Server Action 并要求 async。故独立到此文件。 + */ +export function assertClassInScope( + scope: DataScope, + classId: string, +): string | null { + if (scope.type === "all") return null + if (scope.type === "children") return null + if (scope.type === "grade_managed") return null + if (scope.type === "class_taught") { + return scope.classIds.includes(classId) + ? null + : "You can only access classes you teach" + } + if (scope.type === "class_members") { + return scope.classIds.includes(classId) + ? null + : "You can only access your own class" + } + return "Access denied for your scope" +} diff --git a/src/modules/grades/schema.ts b/src/modules/grades/schema.ts index 7a02841..dbf5c42 100644 --- a/src/modules/grades/schema.ts +++ b/src/modules/grades/schema.ts @@ -130,8 +130,41 @@ export const GradeDistributionQuerySchema = z.object({ semester: GradeRecordSemesterEnum.optional(), }) +export const GradeDistributionByGradeQuerySchema = z.object({ + gradeId: z.string().min(1), + subjectId: z.string().optional(), + examId: z.string().optional(), + semester: GradeRecordSemesterEnum.optional(), +}) + export const RankingTrendQuerySchema = z.object({ studentId: z.string().min(1), subjectId: z.string().optional(), semester: GradeRecordSemesterEnum.optional(), }) + +// --- 按试卷批量录入(每题得分)--- + +export const BatchGradeEntryByExamQuestionSchema = z.object({ + questionId: z.string().min(1), + score: z.coerce.number().min(0).max(1000), +}) + +export const BatchGradeEntryByExamItemSchema = z.object({ + studentId: z.string().min(1), + answers: z.array(BatchGradeEntryByExamQuestionSchema).min(1), + remark: z.string().optional(), +}) + +export const BatchGradeEntryByExamSchema = z.object({ + examId: z.string().min(1), + classId: z.string().min(1), + subjectId: z.string().min(1), + title: z.string().min(1).max(255), + fullScore: z.coerce.number().min(1).max(1000), + type: GradeRecordTypeEnum.optional(), + semester: GradeRecordSemesterEnum.optional(), + records: z.array(BatchGradeEntryByExamItemSchema).min(1).max(500), +}) + +export type BatchGradeEntryByExamInput = z.infer diff --git a/src/modules/grades/types.ts b/src/modules/grades/types.ts index e9a21a5..98ff054 100644 --- a/src/modules/grades/types.ts +++ b/src/modules/grades/types.ts @@ -194,6 +194,26 @@ export interface GradeDistributionResult { totalCount: number } +/** + * 年级维度成绩分布(按班级拆分)。 + * 用于年级主任/教学主任仪表盘的"成绩分布"维度。 + */ +export interface GradeDistributionByGradeClassItem { + classId: string + className: string + distribution: GradeDistributionResult + stats: GradeStats | null +} + +export interface GradeDistributionByGradeResult { + /** 年级整体分布(已应用 scope 过滤) */ + overall: GradeDistributionResult + /** 年级整体统计(均分/及格率/优秀率等) */ + stats: GradeStats | null + /** 按班级拆分的分布 + 统计 */ + byClass: GradeDistributionByGradeClassItem[] +} + export interface RankingTrendPoint { /** Title of the exam/assessment */ title: string @@ -238,3 +258,30 @@ export interface SelectOption { id: string name: string } + +// --- 按试卷批量录入(每题得分)--- + +/** 成绩每题得分记录(对应 grade_record_answers 表) */ +export interface GradeRecordAnswer { + id: string + gradeRecordId: string + questionId: string + score: number + fullScore: number + feedback: string | null + createdAt: string + updatedAt: string +} + +/** 单个学生的每题得分录入项 */ +export interface BatchGradeEntryByExamQuestion { + questionId: string + score: number +} + +/** 单个学生的录入项 */ +export interface BatchGradeEntryByExamItem { + studentId: string + answers: BatchGradeEntryByExamQuestion[] + remark?: string +}