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,189 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { saveHomeworkAnswerAction } from "../actions"
type AutoSaveStatus = "idle" | "saving" | "saved" | "error"
type AnswerMap = Record<string, { answer: unknown } | undefined>
type UseDebouncedAutoSaveOptions = {
submissionId: string | null
answers: AnswerMap
enabled: boolean
debounceMs?: number
storageKey?: string
}
type UseDebouncedAutoSaveResult = {
status: AutoSaveStatus
lastSavedAt: number | null
flush: () => Promise<void>
}
/**
* P2-9: 学生答案自动保存 + 离线缓存
*
* - 答案变更后 debounce默认 3 秒)自动保存到服务端
* - 同时写入 localStorage 作为离线缓存
* - 网络异常时标记 error恢复后自动重试
* - 组件卸载时 flush 未保存的答案
*/
export function useDebouncedAutoSave({
submissionId,
answers,
enabled,
debounceMs = 3000,
storageKey,
}: UseDebouncedAutoSaveOptions): UseDebouncedAutoSaveResult {
const [status, setStatus] = useState<AutoSaveStatus>("idle")
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingRef = useRef<Map<string, unknown>>(new Map())
const savingRef = useRef(false)
const lastSavedAnswersRef = useRef<string>("")
// Persist to localStorage for offline recovery
const cacheToLocalStorage = useCallback(
(snapshot: AnswerMap) => {
if (!storageKey) return
try {
const serialized = JSON.stringify({
submissionId,
answers: snapshot,
timestamp: Date.now(),
})
window.localStorage.setItem(storageKey, serialized)
} catch {
// localStorage may be full or unavailable; silently ignore
}
},
[storageKey, submissionId]
)
// Save a batch of pending answers to the server
const savePending = useCallback(async () => {
if (!submissionId || savingRef.current) return
const pending = Array.from(pendingRef.current.entries())
if (pending.length === 0) return
savingRef.current = true
setStatus("saving")
let allOk = true
for (const [questionId, answer] of pending) {
const fd = new FormData()
fd.set("submissionId", submissionId)
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
allOk = false
}
}
savingRef.current = false
if (allOk) {
pendingRef.current.clear()
setStatus("saved")
setLastSavedAt(Date.now())
} else {
setStatus("error")
// Keep pending items for retry on next change or manual flush
}
}, [submissionId])
// Schedule debounced save when answers change
useEffect(() => {
if (!enabled || !submissionId) return
const currentSnapshot = JSON.stringify(answers)
if (currentSnapshot === lastSavedAnswersRef.current) return
// Cache to localStorage immediately (offline safety net)
cacheToLocalStorage(answers)
// Collect changed question IDs
for (const questionId of Object.keys(answers)) {
const entry = answers[questionId]
if (entry !== undefined) {
pendingRef.current.set(questionId, entry.answer)
}
}
// Clear existing timer and set a new one
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
void savePending()
lastSavedAnswersRef.current = currentSnapshot
}, debounceMs)
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [answers, enabled, submissionId, debounceMs, cacheToLocalStorage, savePending])
// Flush on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
// Fire-and-forget final save
void savePending()
}
}, [savePending])
// Retry on window focus (network may have recovered)
useEffect(() => {
if (status !== "error") return
const handleFocus = () => {
void savePending()
}
window.addEventListener("focus", handleFocus)
return () => window.removeEventListener("focus", handleFocus)
}, [status, savePending])
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
await savePending()
lastSavedAnswersRef.current = JSON.stringify(answers)
}, [answers, savePending])
return { status, lastSavedAt, flush }
}
/**
* 从 localStorage 恢复离线缓存的答案
*/
export function loadOfflineCache(storageKey: string): AnswerMap | null {
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as { answers?: AnswerMap }
if (!parsed.answers || typeof parsed.answers !== "object") return null
return parsed.answers
} catch {
return null
}
}
/**
* 清除 localStorage 中的离线缓存
*/
export function clearOfflineCache(storageKey: string): void {
try {
window.localStorage.removeItem(storageKey)
} catch {
// ignore
}
}