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:
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal file
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user