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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user