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,361 @@
"use client"
import { type ReactNode } from "react"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Textarea } from "@/shared/components/ui/textarea"
import { useTranslations } from "next-intl"
import {
extractAnswerValue,
getOptions,
getQuestionText,
isRecord,
type QuestionOption,
type QuestionType,
} from "../lib/question-content-utils"
/**
* 题目渲染模式
* - `take`: 学生作答交互
* - `review`: 学生查看批改结果(只读 + 正确答案高亮)
* - `grade`: 教师批改(只读学生答案 + 正确答案 + 评分面板 slot
*/
export type QuestionRenderMode = "take" | "review" | "grade"
export interface QuestionRendererProps {
questionId: string
questionType: QuestionType
questionContent: unknown
maxScore: number
index: number
mode: QuestionRenderMode
/** 学生答案take 模式下为当前值review/grade 模式下为已提交值) */
value?: unknown
/** take 模式下的禁用状态 */
disabled?: boolean
/** take 模式下答案变更回调 */
onChange?: (answer: unknown) => void
/** review/grade 模式下是否显示正确答案 */
showCorrectAnswer?: boolean
/** review/grade 模式下是否显示批改反馈 */
feedback?: string | null
/** 题目头部右侧额外内容(如分数 Badge */
headerExtra?: ReactNode
/** 题目底部额外内容(如批改面板) */
footerExtra?: ReactNode
/** 题目卡片额外 className */
className?: string
}
/**
* 题目渲染器(只读展示 + 答案输入组合)
*
* 通过 `mode` 切换交互行为:
* - `take`: 渲染可编辑的作答输入控件
* - `review`: 渲染只读答案 + 正确答案高亮
* - `grade`: 渲染只读学生答案 + 正确答案(由父组件通过 `footerExtra` 注入批改面板)
*/
export function QuestionRenderer({
questionId,
questionType,
questionContent,
maxScore,
index,
mode,
value,
disabled,
onChange,
showCorrectAnswer,
feedback,
headerExtra,
footerExtra,
className,
}: QuestionRendererProps) {
const t = useTranslations("examHomework")
const text = getQuestionText(questionContent)
const options = getOptions(questionContent)
const isReadOnly = mode !== "take"
const showFeedback = isReadOnly && Boolean(feedback)
return (
<article
id={`question-${questionId}`}
className={className}
aria-labelledby={`question-${questionId}-title`}
>
<header className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3
id={`question-${questionId}-title`}
className="text-base font-medium"
>
{t("homework.take.question", { index: index + 1 })}
</h3>
<p className="text-xs text-muted-foreground">
{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} {maxScore} {t("homework.take.points")}
</p>
</div>
{headerExtra}
</header>
<div className="mt-4 text-sm font-medium leading-relaxed">{text || "—"}</div>
<div className="mt-4">
<QuestionAnswerInput
questionId={questionId}
questionType={questionType}
options={options}
value={value}
disabled={disabled}
readOnly={isReadOnly}
onChange={onChange}
showCorrectAnswer={showCorrectAnswer === true}
questionContent={questionContent}
/>
</div>
{showFeedback && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
<div className="text-sm space-y-1">
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
{feedback}
</div>
</div>
</div>
)}
{footerExtra}
</article>
)
}
interface QuestionAnswerInputProps {
questionId: string
questionType: QuestionType
options: QuestionOption[]
value: unknown
disabled?: boolean
readOnly?: boolean
onChange?: (answer: unknown) => void
showCorrectAnswer?: boolean
questionContent: unknown
}
function QuestionAnswerInput({
questionId,
questionType,
options,
value,
disabled,
readOnly,
onChange,
showCorrectAnswer,
questionContent,
}: QuestionAnswerInputProps) {
const t = useTranslations("examHomework")
if (questionType === "text") {
if (readOnly) {
const textValue = typeof value === "string" ? value : ""
return (
<div className="grid gap-2">
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{textValue || (
<span className="text-muted-foreground italic">{t("homework.review.noAnswer")}</span>
)}
</div>
{showCorrectAnswer && (
<CorrectAnswerDisplay questionType="text" questionContent={questionContent} />
)}
</div>
)
}
return (
<div className="grid gap-2">
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<Textarea
placeholder={t("homework.take.answerPlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange?.(e.target.value)}
className="min-h-[120px] resize-y"
disabled={disabled}
/>
</div>
)
}
if (questionType === "judgment") {
const boolValue = typeof value === "boolean" ? (value ? "true" : "false") : ""
return (
<div className="grid gap-2">
<RadioGroup
value={boolValue}
onValueChange={(v) => onChange?.(v === "true")}
disabled={disabled || readOnly}
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="true" id={`${questionId}-true`} />
<Label htmlFor={`${questionId}-true`} className="flex-1 cursor-pointer font-normal">
{t("homework.take.true")}
</Label>
</div>
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
<RadioGroupItem value="false" id={`${questionId}-false`} />
<Label htmlFor={`${questionId}-false`} className="flex-1 cursor-pointer font-normal">
{t("homework.take.false")}
</Label>
</div>
</RadioGroup>
{showCorrectAnswer && readOnly && (
<CorrectAnswerDisplay questionType="judgment" questionContent={questionContent} />
)}
</div>
)
}
if (questionType === "single_choice") {
const strValue = typeof value === "string" ? value : ""
return (
<div className="grid gap-2">
<RadioGroup
value={strValue}
onValueChange={(v) => onChange?.(v)}
disabled={disabled || readOnly}
className="flex flex-col gap-2"
>
{options.map((o) => {
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
return (
<div
key={o.id}
className={`flex items-center space-x-2 rounded-md border p-3 ${
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
>
<RadioGroupItem value={o.id} id={`${questionId}-${o.id}`} />
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700">
{t("homework.review.correctMarker")}
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
</div>
)
}
if (questionType === "multiple_choice") {
const selectedIds = Array.isArray(value)
? value.filter((x): x is string => typeof x === "string")
: []
return (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = selectedIds.includes(o.id)
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
return (
<div
key={o.id}
className={`flex items-start space-x-2 rounded-md border p-3 ${
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
>
<Checkbox
id={`${questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
if (readOnly) return
const isChecked = checked === true
const next = isChecked
? Array.from(new Set([...selectedIds, o.id]))
: selectedIds.filter((x) => x !== o.id)
onChange?.(next)
}}
disabled={disabled || readOnly}
/>
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
{o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700">
{t("homework.review.correctMarker")}
</span>
)}
</Label>
</div>
)
})}
</div>
</div>
)
}
return (
<div className="text-sm text-muted-foreground italic">
{t("homework.take.unsupportedType")}
</div>
)
}
/**
* 正确答案展示review/grade 模式)
*/
function CorrectAnswerDisplay({
questionType,
questionContent,
}: {
questionType: QuestionType
questionContent: unknown
}) {
const t = useTranslations("examHomework")
if (questionType === "text") {
const correctTexts = (() => {
if (!isRecord(questionContent)) return []
const raw = questionContent.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
})()
if (correctTexts.length === 0) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<div className="font-medium text-emerald-700 mb-1">{t("homework.review.correctAnswer")}</div>
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
</div>
)
}
if (questionType === "judgment") {
const correct = (() => {
if (!isRecord(questionContent)) return null
return typeof questionContent.correctAnswer === "boolean"
? questionContent.correctAnswer
: null
})()
if (correct === null) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<span className="font-medium text-emerald-700">{t("homework.review.correctAnswer")}: </span>
<span className="text-emerald-900">{correct ? t("homework.take.true") : t("homework.take.false")}</span>
</div>
)
}
return null
}
/**
* 提取学生答案值(便于父组件格式化展示)
*/
export { extractAnswerValue }