fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理

P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
SpecialX
2026-06-22 18:36:46 +08:00
parent f62b8c0f86
commit 682d385ee2
41 changed files with 4387 additions and 1979 deletions

View File

@@ -0,0 +1,433 @@
/**
* 6.5: 单测覆盖权限校验与数据流转
*
* 覆盖范围:
* - question-content-utils 的纯函数(数据流转核心)
* - exam-homework-role-config 的角色特性合并逻辑(权限校验配置层)
* - applyAutoGrades 自动判分流程
*/
import { describe, it, expect } from "vitest"
import {
isRecord,
getQuestionText,
getOptions,
getChoiceCorrectIds,
getJudgmentCorrectAnswer,
getTextCorrectAnswers,
parseSavedAnswer,
extractAnswerValue,
normalizeText,
isAutoGradable,
computeIsCorrect,
getCorrectnessState,
applyAutoGrades,
formatStudentAnswer,
type AutoGradableAnswer,
} from "./question-content-utils"
describe("isRecord", () => {
it("returns true for non-null objects (including arrays)", () => {
expect(isRecord({})).toBe(true)
expect(isRecord({ a: 1 })).toBe(true)
// Note: arrays are objects in JS, so isRecord returns true for them.
// Callers that need to exclude arrays should check Array.isArray separately.
expect(isRecord([1, 2])).toBe(true)
})
it("returns false for null and primitives", () => {
expect(isRecord(null)).toBe(false)
expect(isRecord(undefined)).toBe(false)
expect(isRecord("string")).toBe(false)
expect(isRecord(42)).toBe(false)
})
})
describe("getQuestionText", () => {
it("extracts text from valid content", () => {
expect(getQuestionText({ text: "What is 2+2?" })).toBe("What is 2+2?")
})
it("returns empty string for missing or non-string text", () => {
expect(getQuestionText({})).toBe("")
expect(getQuestionText({ text: 123 })).toBe("")
expect(getQuestionText(null)).toBe("")
expect(getQuestionText("string")).toBe("")
})
})
describe("getOptions", () => {
it("parses valid options array", () => {
const content = {
options: [
{ id: "a", text: "Option A", isCorrect: true },
{ id: "b", text: "Option B" },
],
}
const opts = getOptions(content)
expect(opts).toHaveLength(2)
expect(opts[0]).toEqual({ id: "a", text: "Option A", isCorrect: true })
expect(opts[1]).toEqual({ id: "b", text: "Option B", isCorrect: false })
})
it("filters out options missing id or text", () => {
const content = {
options: [
{ id: "a", text: "Valid" },
{ id: "", text: "No ID" },
{ id: "c", text: "" },
{ id: "d" },
{ text: "No ID" },
],
}
expect(getOptions(content)).toHaveLength(1)
})
it("returns empty array when options is missing or not an array", () => {
expect(getOptions({})).toEqual([])
expect(getOptions({ options: "not-array" })).toEqual([])
expect(getOptions(null)).toEqual([])
})
})
describe("getChoiceCorrectIds", () => {
it("returns IDs of options marked isCorrect", () => {
const content = {
options: [
{ id: "a", text: "A", isCorrect: true },
{ id: "b", text: "B" },
{ id: "c", text: "C", isCorrect: true },
],
}
expect(getChoiceCorrectIds(content)).toEqual(["a", "c"])
})
it("returns empty array when no correct options", () => {
const content = { options: [{ id: "a", text: "A" }] }
expect(getChoiceCorrectIds(content)).toEqual([])
})
})
describe("getJudgmentCorrectAnswer", () => {
it("returns boolean when correctAnswer is boolean", () => {
expect(getJudgmentCorrectAnswer({ correctAnswer: true })).toBe(true)
expect(getJudgmentCorrectAnswer({ correctAnswer: false })).toBe(false)
})
it("returns null for missing or non-boolean", () => {
expect(getJudgmentCorrectAnswer({})).toBeNull()
expect(getJudgmentCorrectAnswer({ correctAnswer: "true" })).toBeNull()
expect(getJudgmentCorrectAnswer(null)).toBeNull()
})
})
describe("getTextCorrectAnswers", () => {
it("returns array from string correctAnswer", () => {
expect(getTextCorrectAnswers({ correctAnswer: "yes" })).toEqual(["yes"])
})
it("returns array from string[] correctAnswer", () => {
expect(getTextCorrectAnswers({ correctAnswer: ["yes", "Yes", "YES"] })).toEqual([
"yes",
"Yes",
"YES",
])
})
it("returns empty array for missing or invalid", () => {
expect(getTextCorrectAnswers({})).toEqual([])
expect(getTextCorrectAnswers({ correctAnswer: 123 })).toEqual([])
expect(getTextCorrectAnswers({ correctAnswer: [1, 2] })).toEqual([])
})
})
describe("parseSavedAnswer", () => {
it("parses object with answer field", () => {
expect(parseSavedAnswer({ answer: "test" }, "text")).toEqual({ answer: "test" })
expect(parseSavedAnswer({ answer: ["a", "b"] }, "multiple_choice")).toEqual({
answer: ["a", "b"],
})
})
it("returns empty answer for null/undefined input", () => {
expect(parseSavedAnswer(null, "text")).toEqual({ answer: "" })
expect(parseSavedAnswer(undefined, "text")).toEqual({ answer: "" })
})
it("coerces non-matching types to defaults", () => {
expect(parseSavedAnswer("raw-string", "text")).toEqual({ answer: "raw-string" })
expect(parseSavedAnswer(123, "text")).toEqual({ answer: "" })
expect(parseSavedAnswer("not-boolean", "judgment")).toEqual({ answer: false })
})
})
describe("extractAnswerValue", () => {
it("extracts answer from object shape", () => {
expect(extractAnswerValue({ answer: "test" })).toBe("test")
expect(extractAnswerValue({ answer: ["a", "b"] })).toEqual(["a", "b"])
})
it("returns raw value for non-object shapes", () => {
expect(extractAnswerValue("test")).toBe("test")
expect(extractAnswerValue(["a", "b"])).toEqual(["a", "b"])
expect(extractAnswerValue(null)).toBeNull()
})
})
describe("normalizeText", () => {
it("trims and lowercases", () => {
expect(normalizeText(" Hello World ")).toBe("hello world")
})
it("collapses internal whitespace", () => {
expect(normalizeText("a b\tc")).toBe("a b c")
})
it("returns empty string for empty input", () => {
expect(normalizeText("")).toBe("")
expect(normalizeText(" ")).toBe("")
})
})
describe("isAutoGradable", () => {
const choiceContent = {
options: [{ id: "a", text: "A", isCorrect: true }],
}
const judgmentContent = { correctAnswer: true }
it("returns true for choice types with correct options", () => {
expect(isAutoGradable({ questionType: "single_choice", questionContent: choiceContent })).toBe(true)
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: choiceContent })).toBe(true)
})
it("returns false for choice types without correct options", () => {
expect(isAutoGradable({ questionType: "single_choice", questionContent: {} })).toBe(false)
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: {} })).toBe(false)
})
it("returns true for judgment with boolean correctAnswer", () => {
expect(isAutoGradable({ questionType: "judgment", questionContent: judgmentContent })).toBe(true)
})
it("returns false for judgment without correctAnswer", () => {
expect(isAutoGradable({ questionType: "judgment", questionContent: {} })).toBe(false)
})
it("returns true for text type with correctAnswer", () => {
expect(
isAutoGradable({ questionType: "text", questionContent: { correctAnswer: "yes" } })
).toBe(true)
})
it("returns false for text type without correctAnswer", () => {
expect(isAutoGradable({ questionType: "text", questionContent: {} })).toBe(false)
})
it("returns false for unknown types", () => {
expect(isAutoGradable({ questionType: "essay", questionContent: {} })).toBe(false)
expect(isAutoGradable({ questionType: "fill_blank", questionContent: {} })).toBe(false)
})
})
describe("computeIsCorrect", () => {
const singleChoiceContent = {
options: [
{ id: "a", text: "A", isCorrect: true },
{ id: "b", text: "B" },
],
}
const multipleChoiceContent = {
options: [
{ id: "a", text: "A", isCorrect: true },
{ id: "b", text: "B" },
{ id: "c", text: "C", isCorrect: true },
],
}
const judgmentContent = { correctAnswer: true }
const textContent = { correctAnswer: ["yes", "Yes"] }
it("single_choice: correct", () => {
expect(
computeIsCorrect({
questionType: "single_choice",
questionContent: singleChoiceContent,
studentAnswer: "a",
})
).toBe(true)
})
it("single_choice: incorrect", () => {
expect(
computeIsCorrect({
questionType: "single_choice",
questionContent: singleChoiceContent,
studentAnswer: "b",
})
).toBe(false)
})
it("multiple_choice: correct (all and only correct)", () => {
expect(
computeIsCorrect({
questionType: "multiple_choice",
questionContent: multipleChoiceContent,
studentAnswer: ["a", "c"],
})
).toBe(true)
})
it("multiple_choice: incorrect (missing one correct)", () => {
expect(
computeIsCorrect({
questionType: "multiple_choice",
questionContent: multipleChoiceContent,
studentAnswer: ["a"],
})
).toBe(false)
})
it("multiple_choice: incorrect (extra wrong option)", () => {
expect(
computeIsCorrect({
questionType: "multiple_choice",
questionContent: multipleChoiceContent,
studentAnswer: ["a", "b", "c"],
})
).toBe(false)
})
it("judgment: correct", () => {
expect(
computeIsCorrect({
questionType: "judgment",
questionContent: judgmentContent,
studentAnswer: true,
})
).toBe(true)
})
it("judgment: incorrect", () => {
expect(
computeIsCorrect({
questionType: "judgment",
questionContent: judgmentContent,
studentAnswer: false,
})
).toBe(false)
})
it("text: correct (case-insensitive)", () => {
expect(
computeIsCorrect({
questionType: "text",
questionContent: textContent,
studentAnswer: " YES ",
})
).toBe(true)
})
it("text: incorrect", () => {
expect(
computeIsCorrect({
questionType: "text",
questionContent: textContent,
studentAnswer: "no",
})
).toBe(false)
})
it("returns null for non-auto-gradable types", () => {
expect(
computeIsCorrect({
questionType: "essay",
questionContent: {},
studentAnswer: "some text",
})
).toBeNull()
})
})
describe("getCorrectnessState", () => {
it("returns 'ungraded' when score is null", () => {
expect(getCorrectnessState({ score: null, maxScore: 10 })).toBe("ungraded")
})
it("returns 'correct' when score equals maxScore", () => {
expect(getCorrectnessState({ score: 10, maxScore: 10 })).toBe("correct")
})
it("returns 'incorrect' when score is 0", () => {
expect(getCorrectnessState({ score: 0, maxScore: 10 })).toBe("incorrect")
})
it("returns 'partial' when 0 < score < maxScore", () => {
expect(getCorrectnessState({ score: 5, maxScore: 10 })).toBe("partial")
})
})
describe("applyAutoGrades", () => {
const baseAnswers: AutoGradableAnswer[] = [
{
id: "a1",
questionId: "q1",
questionType: "single_choice",
questionContent: {
options: [
{ id: "a", text: "A", isCorrect: true },
{ id: "b", text: "B" },
],
},
studentAnswer: "a",
score: null,
maxScore: 5,
feedback: null,
order: 0,
},
{
id: "a2",
questionId: "q2",
questionType: "single_choice",
questionContent: {
options: [
{ id: "a", text: "A", isCorrect: true },
{ id: "b", text: "B" },
],
},
studentAnswer: "b",
score: null,
maxScore: 5,
feedback: null,
order: 1,
},
{
id: "a3",
questionId: "q3",
questionType: "essay",
questionContent: {},
studentAnswer: "some text",
score: null,
maxScore: 10,
feedback: null,
order: 2,
},
{
id: "a4",
questionId: "q4",
questionType: "single_choice",
questionContent: {
options: [{ id: "a", text: "A", isCorrect: true }],
},
studentAnswer: "a",
score: 5,
maxScore: 5,
feedback: null,
order: 3,
},
]
it("auto-grades correct answer to full score", () => {
const result = applyAutoGrades(baseAnswers)
expect(result[0].score).toBe(5)
})
it("auto-grades incorrect answer to 0", () => {
const result = applyAutoGrades(baseAnswers)
expect(result[1].score).toBe(0)
})
it("leaves non-auto-gradable answers with null score", () => {
const result = applyAutoGrades(baseAnswers)
expect(result[2].score).toBeNull()
})
it("does not overwrite already-graded answers", () => {
const result = applyAutoGrades(baseAnswers)
expect(result[3].score).toBe(5)
})
})
describe("formatStudentAnswer", () => {
it("formats string answer as-is", () => {
expect(formatStudentAnswer("my answer")).toBe("my answer")
})
it("formats boolean answer", () => {
expect(formatStudentAnswer(true)).toBe("True")
expect(formatStudentAnswer(false)).toBe("False")
})
it("formats array answer as comma-separated", () => {
expect(formatStudentAnswer(["a", "b", "c"])).toBe("a, b, c")
})
it("formats null answer as dash", () => {
expect(formatStudentAnswer(null)).toBe("—")
expect(formatStudentAnswer(undefined)).toBe("—")
})
it("formats object answer by extracting answer field", () => {
expect(formatStudentAnswer({ answer: "test" })).toBe("test")
})
})

View File

@@ -0,0 +1,226 @@
/**
* 题目内容解析纯函数
*
* 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。
* 所有函数均为纯函数,无副作用,便于单测。
*/
export type QuestionOption = {
id: string
text: string
isCorrect?: boolean
}
export const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
export const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
export const getOptions = (content: unknown): QuestionOption[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: QuestionOption[] = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
const isCorrect = item.isCorrect === true
out.push({ id, text, isCorrect })
}
return out
}
export const getChoiceCorrectIds = (content: unknown): string[] => {
return getOptions(content)
.filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true)
.map((o) => o.id)
}
export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
if (!isRecord(content)) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
export const getTextCorrectAnswers = (content: unknown): string[] => {
if (!isRecord(content)) return []
const raw = content.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
}
export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string
export type AnswerShape =
| { answer: string }
| { answer: boolean }
| { answer: string[] }
| { answer: unknown }
export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
if (questionType === "multiple_choice") {
return {
answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [],
}
}
return { answer: v }
}
export const parseSavedAnswer = (saved: unknown, questionType: QuestionType): AnswerShape => {
if (isRecord(saved) && "answer" in saved) {
return toAnswerShape(questionType, saved.answer)
}
return toAnswerShape(questionType, saved)
}
export const extractAnswerValue = (studentAnswer: unknown): unknown => {
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
return studentAnswer
}
export const normalizeText = (v: string): string =>
v.trim().replace(/\s+/g, " ").toLowerCase()
/**
* 判断题目是否可自动判分(有标准答案)
*/
export const isAutoGradable = (input: {
questionType: QuestionType
questionContent: unknown
}): boolean => {
if (input.questionType === "single_choice" || input.questionType === "multiple_choice") {
return getChoiceCorrectIds(input.questionContent).length > 0
}
if (input.questionType === "judgment") {
return getJudgmentCorrectAnswer(input.questionContent) !== null
}
if (input.questionType === "text") {
return getTextCorrectAnswers(input.questionContent).length > 0
}
return false
}
export type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
/**
* 计算单题对错状态
* @returns "correct" | "incorrect" | "partial" | "ungraded";无标准答案返回 null
*/
export const computeIsCorrect = (input: {
questionType: QuestionType
questionContent: unknown
studentAnswer: unknown
}): boolean | null => {
const studentVal = extractAnswerValue(input.studentAnswer)
if (input.questionType === "single_choice") {
const correct = getChoiceCorrectIds(input.questionContent)
if (correct.length === 0) return null
if (typeof studentVal !== "string") return false
return correct.includes(studentVal)
}
if (input.questionType === "multiple_choice") {
const correct = getChoiceCorrectIds(input.questionContent)
if (correct.length === 0) return null
const studentArr = Array.isArray(studentVal)
? studentVal.filter((x): x is string => typeof x === "string")
: []
const correctSet = new Set(correct)
const studentSet = new Set(studentArr)
if (studentSet.size !== correctSet.size) return false
for (const id of correctSet) {
if (!studentSet.has(id)) return false
}
return true
}
if (input.questionType === "judgment") {
const correct = getJudgmentCorrectAnswer(input.questionContent)
if (correct === null) return null
if (typeof studentVal !== "boolean") return false
return studentVal === correct
}
if (input.questionType === "text") {
const correctAnswers = getTextCorrectAnswers(input.questionContent)
if (correctAnswers.length === 0) return null
if (typeof studentVal !== "string") return false
const normalizedStudent = normalizeText(studentVal)
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
}
return null
}
/**
* 根据分数与满分推断对错状态
*/
export const getCorrectnessState = (input: {
score: number | null
maxScore: number
}): CorrectnessState => {
if (input.score === null) return "ungraded"
if (input.score === input.maxScore) return "correct"
if (input.score === 0) return "incorrect"
return "partial"
}
/**
* 自动判分输入项
*/
export interface AutoGradableAnswer {
id: string
questionId: string
questionType: QuestionType
questionContent: unknown
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
/**
* 对未判分的题目应用自动判分
* - 已有分数score !== null的不覆盖
* - 无标准答案的不判分
* - 否则按 computeIsCorrect 给满分或 0 分
*/
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
return incoming.map((a) => {
if (a.score !== null) return a
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
return a
}
const isCorrect = computeIsCorrect({
questionType: a.questionType,
questionContent: a.questionContent,
studentAnswer: a.studentAnswer,
})
if (isCorrect === null) return a
return { ...a, score: isCorrect ? a.maxScore : 0 }
})
}
/**
* 格式化学生答案为可读字符串
*/
export const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v
if (typeof v === "boolean") return v ? "True" : "False"
if (Array.isArray(v)) {
return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
}
if (v == null) return "—"
return JSON.stringify(v)
}