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)
439 lines
19 KiB
TypeScript
439 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import Link from "next/link"
|
|
import { useTranslations } from "next-intl"
|
|
import { toast } from "sonner"
|
|
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Card, CardHeader } from "@/shared/components/ui/card"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/shared/components/ui/alert-dialog"
|
|
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"
|
|
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
|
|
initialData: StudentHomeworkTakeData
|
|
}
|
|
|
|
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
|
|
const router = useRouter()
|
|
const t = useTranslations("examHomework")
|
|
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
|
|
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
|
|
const [isBusy, setIsBusy] = useState(false)
|
|
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
|
|
|
|
const initialAnswersByQuestionId = useMemo(() => {
|
|
const map = new Map<string, { answer: unknown }>()
|
|
for (const q of initialData.questions) {
|
|
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
|
}
|
|
return map
|
|
}, [initialData.questions])
|
|
|
|
const [answersByQuestionId, setAnswersByQuestionId] = useState(() => {
|
|
const obj: Record<string, { answer: unknown }> = {}
|
|
for (const [k, v] of initialAnswersByQuestionId.entries()) obj[k] = v
|
|
return obj
|
|
})
|
|
|
|
const isStarted = submissionStatus === "started"
|
|
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
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault()
|
|
e.returnValue = ""
|
|
}
|
|
window.addEventListener("beforeunload", handler)
|
|
return () => window.removeEventListener("beforeunload", handler)
|
|
}, [canEdit])
|
|
|
|
// 截止时间与紧急度
|
|
const dueAt = initialData.assignment.dueAt
|
|
const now = new Date()
|
|
const dueDate = dueAt ? new Date(dueAt) : null
|
|
const isOverdue = dueDate ? dueDate < now : false
|
|
const hoursUntilDue = dueDate ? Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60)) : null
|
|
const isUrgent = hoursUntilDue !== null && hoursUntilDue >= 0 && hoursUntilDue < 24
|
|
|
|
// 尝试次数
|
|
const maxAttempts = initialData.assignment.maxAttempts
|
|
const attemptsUsed = initialData.submission?.attemptNo ?? 0
|
|
const attemptsRemaining = Math.max(0, maxAttempts - attemptsUsed)
|
|
|
|
const handleStart = async () => {
|
|
setIsBusy(true)
|
|
const fd = new FormData()
|
|
fd.set("assignmentId", assignmentId)
|
|
const res = await startHomeworkSubmissionAction(null, fd)
|
|
if (res.success && res.data) {
|
|
setSubmissionId(res.data)
|
|
setSubmissionStatus("started")
|
|
toast.success(t("homework.take.startSuccess"))
|
|
router.refresh()
|
|
} else {
|
|
toast.error(res.message || t("homework.take.startFailed"))
|
|
}
|
|
setIsBusy(false)
|
|
}
|
|
|
|
const handleSaveQuestion = async (questionId: string) => {
|
|
if (!submissionId) return
|
|
// setIsBusy(true) // Don't block UI for individual saves
|
|
const payload = answersByQuestionId[questionId]?.answer ?? null
|
|
const fd = new FormData()
|
|
fd.set("submissionId", submissionId)
|
|
fd.set("questionId", questionId)
|
|
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
|
const res = await saveHomeworkAnswerAction(null, fd)
|
|
if (res.success) toast.success(t("homework.take.saved"))
|
|
else toast.error(res.message || t("homework.take.saveFailed"))
|
|
// setIsBusy(false)
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!submissionId) return
|
|
setIsBusy(true)
|
|
// 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")
|
|
} else {
|
|
toast.error(submitRes.message || t("homework.take.submitFailed"))
|
|
}
|
|
setIsBusy(false)
|
|
}
|
|
|
|
// 统计未作答题目数
|
|
const unansweredCount = initialData.questions.filter((q) => {
|
|
const v = answersByQuestionId[q.questionId]?.answer
|
|
if (v === undefined || v === null) return true
|
|
if (typeof v === "string" && v.trim() === "") return true
|
|
if (Array.isArray(v) && v.length === 0) return true
|
|
return false
|
|
}).length
|
|
|
|
return (
|
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
|
<div className="flex items-center gap-3">
|
|
<Button asChild variant="ghost" size="sm" className="mr-1">
|
|
<Link href="/student/learning/assignments">
|
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
{t("homework.take.back")}
|
|
</Link>
|
|
</Button>
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
|
<FileText className="h-4 w-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold leading-none">{t("homework.take.questions")}</h3>
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
|
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
|
</Badge>
|
|
<span>•</span>
|
|
<span>{initialData.questions.length} {t("homework.take.questions")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!canEdit ? (
|
|
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
|
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
|
|
</Button>
|
|
) : (
|
|
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
|
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 bg-muted/10">
|
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
|
{!isStarted && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
|
<Clock className="h-6 w-6 text-muted-foreground" />
|
|
</div>
|
|
<h3 className="text-lg font-medium">{t("homework.take.readyToStart")}</h3>
|
|
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
|
|
{t("homework.take.readyDescription")}
|
|
</p>
|
|
<Button onClick={handleStart} disabled={isBusy}>
|
|
{t("homework.take.startNow")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{showQuestions && initialData.questions.map((q, idx) => {
|
|
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">
|
|
<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>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<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">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
|
|
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{dueAt && (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
|
|
<div className={cn(
|
|
"mt-1 flex items-center gap-2 text-sm font-medium",
|
|
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
|
|
)}>
|
|
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
|
|
<span>{formatDate(dueAt)}</span>
|
|
</div>
|
|
{isOverdue && (
|
|
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
|
|
)}
|
|
{isUrgent && !isOverdue && hoursUntilDue !== null && (
|
|
<p className="mt-1 text-xs text-orange-500">
|
|
{hoursUntilDue === 0
|
|
? t("homework.take.lessThanOneHour")
|
|
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{maxAttempts > 0 && (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
|
|
<div className="mt-1 text-sm">
|
|
<span className="font-medium">{attemptsUsed}</span>
|
|
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
|
|
{attemptsRemaining > 0 && (
|
|
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
|
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
|
{initialData.assignment.description || t("homework.take.noDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
{showQuestions && (
|
|
<div>
|
|
<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 answer = answersByQuestionId[q.questionId]?.answer
|
|
const hasAnswer = answer !== undefined &&
|
|
answer !== "" &&
|
|
(Array.isArray(answer) ? answer.length > 0 : true)
|
|
|
|
return (
|
|
<button
|
|
key={q.questionId}
|
|
type="button"
|
|
onClick={() => {
|
|
const el = document.getElementById(`question-${q.questionId}`)
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
}}
|
|
className={cn(
|
|
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
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>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{canEdit && (
|
|
<div className="border-t p-4 bg-muted/20">
|
|
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
|
|
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
|
|
</Button>
|
|
<p className="mt-2 text-xs text-center text-muted-foreground">
|
|
{t("homework.take.makeSureAnswered")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 提交二次确认对话框 */}
|
|
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{unansweredCount > 0
|
|
? t("homework.take.unansweredWarning", { count: unansweredCount })
|
|
: t("homework.take.confirmSubmitDescription")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
disabled={isBusy}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
setShowSubmitConfirm(false)
|
|
void handleSubmit()
|
|
}}
|
|
>
|
|
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|