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

@@ -5,14 +5,11 @@ import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardHeader } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
@@ -24,48 +21,14 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string }
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
const getOptions = (content: unknown): Option[] => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Option[] = []
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
out.push({ id, text })
}
return out
}
const toAnswerShape = (questionType: string, v: unknown) => {
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 }
}
const parseSavedAnswer = (saved: unknown, questionType: string) => {
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
return toAnswerShape(questionType, saved)
}
import { QuestionRenderer } from "./question-renderer"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
type HomeworkTakeViewProps = {
assignmentId: string
@@ -98,6 +61,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started"
// P2-9: 自动保存 + 离线缓存
const offlineStorageKey = `homework-draft-${assignmentId}`
const autoSave = useDebouncedAutoSave({
submissionId,
answers: answersByQuestionId,
enabled: canEdit,
storageKey: offlineStorageKey,
})
// 挂载时尝试从 localStorage 恢复未提交的答案
useEffect(() => {
if (!canEdit) return
const cached = loadOfflineCache(offlineStorageKey)
if (!cached) return
setAnswersByQuestionId((prev) => {
const merged: Record<string, { answer: unknown }> = { ...prev }
let changed = false
for (const questionId of Object.keys(cached)) {
const cachedEntry = cached[questionId]
if (!cachedEntry) continue
const prevEntry = prev[questionId]
const cachedJson = JSON.stringify(cachedEntry.answer)
const prevJson = prevEntry ? JSON.stringify(prevEntry.answer) : ""
if (cachedJson !== prevJson) {
merged[questionId] = { answer: cachedEntry.answer }
changed = true
}
}
if (changed) {
toast.success(t("homework.take.autoSaveRestored"))
}
return merged
})
// 仅恢复一次,恢复后清除缓存(避免重复提示)
clearOfflineCache(offlineStorageKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canEdit])
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
useEffect(() => {
if (!canEdit) return
@@ -155,25 +156,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
// Save all first
for (const q of initialData.questions) {
const payload = answersByQuestionId[q.questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", q.questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || t("homework.take.saveFailed"))
setIsBusy(false)
return
}
}
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
await autoSave.flush()
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
// 提交成功后清除离线缓存
clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
@@ -248,155 +239,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
)}
{showQuestions && initialData.questions.map((q, idx) => {
const text = getQuestionText(q.questionContent)
const options = getOptions(q.questionContent)
const value = answersByQuestionId[q.questionId]?.answer
return (
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium">
{t("homework.take.question", { index: idx + 1 })}
</CardTitle>
<CardDescription className="text-xs">
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} {t("homework.take.points")}
</CardDescription>
</div>
</div>
<QuestionRenderer
questionId={q.questionId}
questionType={q.questionType}
questionContent={q.questionContent}
maxScore={q.maxScore}
index={idx}
mode={submissionStatus === "graded" ? "review" : "take"}
value={value}
disabled={!canEdit}
onChange={(answer) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer },
}))
}
showCorrectAnswer={submissionStatus === "graded"}
feedback={submissionStatus === "graded" ? q.feedback : null}
footerExtra={
canEdit ? (
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null
}
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
{q.questionType === "text" ? (
<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) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: e.target.value },
}))
}
className="min-h-[120px] resize-y"
disabled={!canEdit}
/>
</div>
) : q.questionType === "judgment" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v === "true" },
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
<Label htmlFor={`${q.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 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">{t("homework.take.false")}</Label>
</div>
</RadioGroup>
</div>
) : q.questionType === "single_choice" ? (
<div className="grid gap-2">
<RadioGroup
value={typeof value === "string" ? value : ""}
onValueChange={(v) =>
setAnswersByQuestionId((prev) => ({
...prev,
[q.questionId]: { answer: v },
}))
}
disabled={!canEdit}
className="flex flex-col gap-2"
>
{options.map((o) => (
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
{o.text}
</Label>
</div>
))}
</RadioGroup>
</div>
) : q.questionType === "multiple_choice" ? (
<div className="grid gap-2">
<div className="flex flex-col gap-2">
{options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false
return (
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<Checkbox
id={`${q.questionId}-${o.id}`}
checked={selected}
onCheckedChange={(checked) => {
const isChecked = checked === true
setAnswersByQuestionId((prev) => {
const current = Array.isArray(prev[q.questionId]?.answer)
? (prev[q.questionId]?.answer as string[])
: []
const next = isChecked
? Array.from(new Set([...current, o.id]))
: current.filter((x) => x !== o.id)
return { ...prev, [q.questionId]: { answer: next } }
})
}}
disabled={!canEdit}
/>
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
{o.text}
</Label>
</div>
)
})}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">{t("homework.take.unsupportedType")}</div>
)}
{submissionStatus === "graded" && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
{q.feedback ? (
<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">
{q.feedback}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">{t("homework.take.noFeedback")}</div>
)}
</div>
)}
{canEdit ? (
<div className="flex justify-end pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleSaveQuestion(q.questionId)}
disabled={isBusy}
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null}
</CardContent>
</Card>
)
})}
@@ -407,6 +289,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
{canEdit && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
<span className={
autoSave.status === "saved" ? "text-green-600" :
autoSave.status === "error" ? "text-destructive" :
"text-muted-foreground"
}>
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
</span>
</div>
)}
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
@@ -467,9 +364,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
answersByQuestionId[q.questionId]?.answer !== "" &&
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
const answer = answersByQuestionId[q.questionId]?.answer
const hasAnswer = answer !== undefined &&
answer !== "" &&
(Array.isArray(answer) ? answer.length > 0 : true)
return (
<button
@@ -484,6 +382,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
aria-pressed={hasAnswer}
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
>
{i + 1}
</button>