feat(exams,homework,proctoring): 长期问题修复与竞品差距补齐

P1-1 跨模块直查消除:
- homework/data-access-classes.ts 移除对 exams/subjects 表的 JOIN 直查
- 改为调用 exams/data-access.getExamSubjectIdMap + school/data-access.getSubjectNameMapByIds
- school/data-access.ts 新增 getSubjectNameMapByIds 批量科目名称映射函数

P1-2 as 断言消除(exam-mode-config.tsx):
- 移除全部 10 处 as 类型断言
- 改用 useFormContext 替代 Control prop,避免 Control<T> 不变型问题
- exam-form.tsx 调用方简化为 <ExamModeConfig />(已集成到考试表单)

P1-3 as 断言消除(proctoring-dashboard.tsx):
- 用类型守卫函数 isProctoringEventType + toProctoringEventTypes
  替代 Object.keys(...) as ProctoringEventType[] 断言

P0-竞品倒计时(对标智学网/猿题库):
- 新增 hooks/use-exam-countdown.ts 考试倒计时 Hook
- homework-take-view.tsx 集成限时/监考模式倒计时显示与到时自动提交
- data-access.ts 的 getStudentHomeworkTakeData 新增 examModeConfig + startedAt 字段
- types.ts 扩展 StudentHomeworkTakeData 类型
- i18n 补充 timedExam/timeRemaining/timeUpAutoSubmit 翻译键

架构文档同步:
- 004/005 更新 homework/proctoring/school/exams 模块导出与依赖关系
- 005 新增 homework.hooks.useExamCountdown 与 school.dataAccess.getSubjectNameMapByIds
- 005 依赖矩阵 homework→school 补充 getSubjectNameMapByIds

验证:tsc --noEmit 零错误,eslint 零错误(3 个预存 warning 无关)
This commit is contained in:
SpecialX
2026-06-23 09:34:24 +08:00
parent 2c0f81391b
commit 036a2f2839
12 changed files with 915 additions and 136 deletions

View File

@@ -21,7 +21,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
@@ -29,6 +29,7 @@ import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomework
import { QuestionRenderer } from "./question-renderer"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
import { useExamCountdown } from "../hooks/use-exam-countdown"
type HomeworkTakeViewProps = {
assignmentId: string
@@ -195,6 +196,38 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
return false
}).length
// P0-竞品修复:限时/监考模式倒计时
const examModeConfig = initialData.examModeConfig
const isTimedExam = canEdit
&& examModeConfig !== null
&& (examModeConfig.examMode === "timed" || examModeConfig.examMode === "proctored")
&& examModeConfig.durationMinutes !== null
&& examModeConfig.durationMinutes > 0
&& initialData.submission?.startedAt !== null
&& initialData.submission?.startedAt !== undefined
const countdown = useExamCountdown({
durationMinutes: examModeConfig?.durationMinutes ?? null,
startedAt: initialData.submission?.startedAt ?? null,
enabled: isTimedExam,
onExpire: () => {
// 到时自动提交(仅触发一次)
if (submissionStatus === "started" && submissionId) {
toast.warning(t("homework.take.timeUpAutoSubmit"))
void handleSubmit()
}
},
})
const formatCountdown = (s: { hours: number; minutes: number; seconds: number } | null): string => {
if (!s) return ""
const parts: string[] = []
if (s.hours > 0) parts.push(`${s.hours}h`)
parts.push(`${s.minutes.toString().padStart(2, "0")}m`)
parts.push(`${s.seconds.toString().padStart(2, "0")}s`)
return parts.join(" ")
}
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">
@@ -222,14 +255,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button>
<div className="flex items-center gap-3">
{isTimedExam && examModeConfig && (
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300">
<Timer className="h-3.5 w-3.5" />
<span className="font-medium">
{t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })}
</span>
</div>
)}
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button>
</div>
) : (
<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 className="flex items-center gap-3">
{countdown && (
<div
className={cn(
"flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-semibold tabular-nums",
countdown.isExpired
? "border-destructive bg-destructive/10 text-destructive"
: countdown.isUrgent
? "border-destructive bg-destructive/5 text-destructive animate-pulse"
: "border-muted-foreground/20 bg-muted/50 text-foreground"
)}
role="timer"
aria-live="polite"
aria-label={t("homework.take.timeRemaining")}
>
<Timer className="h-4 w-4" />
<span>{formatCountdown(countdown)}</span>
</div>
)}
<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>
)}
</div>