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

@@ -233,7 +233,7 @@ export function ExamForm() {
queuedPreviewTaskCount={queuedPreviewTaskCount}
/>
)}
<ExamModeConfig<ExamFormValues> control={form.control} />
<ExamModeConfig />
</form>
</Form>

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>

View File

@@ -5,12 +5,12 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
exams,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
subjects,
} from "@/shared/db/schema"
import { getExamSubjectIdMap } from "@/modules/exams/data-access"
import { getSubjectNameMapByIds } from "@/modules/school/data-access"
/**
* This file exposes homework data needed by the classes module.
@@ -19,6 +19,9 @@ import {
*
* All functions return plain data records; callers are responsible for
* any further aggregation/statistics.
*
* P1-1 修复:不再直接 import exams/subjects 表,改通过 exams/school
* 模块的 data-access 跨模块函数获取科目信息。
*/
export type HomeworkAssignmentWithSubject = {
@@ -79,6 +82,12 @@ export const getAssignmentIdsForStudents = cache(
/**
* Returns homework assignments joined with subject info (via source exam),
* optionally filtered by subject IDs. Used by class-level homework insights.
*
* P1-1 修复:不再 JOIN exams/subjects 表,改为:
* 1. 查 homeworkAssignments含 sourceExamId
* 2. 通过 exams data-access 批量获取 examId→subjectId 映射
* 3. 通过 school data-access 批量获取 subjectId→name 映射
* 4. 在内存中合并与过滤
*/
export const getHomeworkAssignmentsWithSubject = cache(
async (params: {
@@ -87,11 +96,9 @@ export const getHomeworkAssignmentsWithSubject = cache(
limit?: number
}): Promise<HomeworkAssignmentWithSubject[]> => {
if (params.assignmentIds.length === 0) return []
const conditions = [inArray(homeworkAssignments.id, params.assignmentIds)]
if (params.subjectIdFilter && params.subjectIdFilter.length > 0) {
conditions.push(inArray(exams.subjectId, params.subjectIdFilter))
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
// Step 1: 查 homeworkAssignments含 sourceExamId
const rows = await db
.select({
id: homeworkAssignments.id,
@@ -99,16 +106,50 @@ export const getHomeworkAssignmentsWithSubject = cache(
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name,
sourceExamId: homeworkAssignments.sourceExamId,
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(...conditions))
.where(inArray(homeworkAssignments.id, params.assignmentIds))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
if (rows.length === 0) return []
// Step 2: 通过 exams data-access 批量获取 examId→subjectId
const examIds = rows
.map((r) => r.sourceExamId)
.filter((id): id is string => id !== null && id.length > 0)
const examSubjectMap = await getExamSubjectIdMap(examIds)
// Step 3: 通过 school data-access 批量获取 subjectId→name
const subjectIds = Array.from(examSubjectMap.values()).filter(
(id): id is string => id !== null && id.length > 0
)
const subjectNameMap = await getSubjectNameMapByIds(subjectIds)
// Step 4: 在内存中合并与过滤
const subjectIdFilterSet = params.subjectIdFilter && params.subjectIdFilter.length > 0
? new Set(params.subjectIdFilter)
: null
return rows
.map((r) => {
const subjectId = r.sourceExamId ? (examSubjectMap.get(r.sourceExamId) ?? null) : null
const subjectName = subjectId ? (subjectNameMap.get(subjectId) ?? null) : null
return {
id: r.id,
title: r.title,
status: r.status,
createdAt: r.createdAt,
dueAt: r.dueAt,
subjectId,
subjectName,
}
})
.filter((item) => {
if (!subjectIdFilterSet) return true
return item.subjectId !== null && subjectIdFilterSet.has(item.subjectId)
})
}
)
@@ -197,20 +238,21 @@ export const getHomeworkSubmissionsForStudents = cache(
/**
* Returns published homework assignments joined with subject info (via source exam).
* Used by student subject score aggregation.
*
* P1-1 修复:不再 JOIN exams/subjects 表,改用跨模块 data-access。
*/
export const getPublishedHomeworkAssignmentsWithSubject = cache(
async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => {
if (params.assignmentIds.length === 0) return []
// Step 1: 查 published homeworkAssignments含 sourceExamId
const rows = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name,
sourceExamId: homeworkAssignments.sourceExamId,
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(
and(
inArray(homeworkAssignments.id, params.assignmentIds),
@@ -218,7 +260,32 @@ export const getPublishedHomeworkAssignmentsWithSubject = cache(
)
)
.orderBy(desc(homeworkAssignments.createdAt))
return rows
if (rows.length === 0) return []
// Step 2: 通过 exams data-access 批量获取 examId→subjectId
const examIds = rows
.map((r) => r.sourceExamId)
.filter((id): id is string => id !== null && id.length > 0)
const examSubjectMap = await getExamSubjectIdMap(examIds)
// Step 3: 通过 school data-access 批量获取 subjectId→name
const subjectIds = Array.from(examSubjectMap.values()).filter(
(id): id is string => id !== null && id.length > 0
)
const subjectNameMap = await getSubjectNameMapByIds(subjectIds)
// Step 4: 在内存中合并
return rows.map((r) => {
const subjectId = r.sourceExamId ? (examSubjectMap.get(r.sourceExamId) ?? null) : null
const subjectName = subjectId ? (subjectNameMap.get(subjectId) ?? null) : null
return {
id: r.id,
createdAt: r.createdAt,
subjectId,
subjectName,
}
})
}
)

View File

@@ -12,7 +12,7 @@ import {
homeworkSubmissions,
} from "@/shared/db/schema"
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { getExamIdsByGradeIds, getExamSubjectIdMap } from "@/modules/exams/data-access"
import { getExamIdsByGradeIds, getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import type {
@@ -935,6 +935,24 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
}
}
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
if (assignment.sourceExamId) {
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
if (examConfig) {
examModeConfig = {
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
? examConfig.examMode
: "homework",
durationMinutes: examConfig.durationMinutes,
shuffleQuestions: examConfig.shuffleQuestions ?? false,
allowLateStart: examConfig.allowLateStart ?? false,
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
}
}
}
return {
assignment: {
id: assignment.id,
@@ -946,6 +964,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
examModeConfig,
submission: latestSubmission
? {
id: latestSubmission.id,
@@ -953,6 +972,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
}
: null,
questions: assignmentQuestions.map((aq) => {

View File

@@ -0,0 +1,122 @@
"use client"
import { useEffect, useRef, useState } from "react"
/**
* P0-竞品修复:考试倒计时 hook。
*
* 对标智学网/猿题库的限时考试功能:
* - 学生开始作答后,根据 durationMinutes 计算截止时间
* - 每秒更新剩余时间
* - 剩余时间 ≤ 0 时触发 onExpire 回调(自动提交)
* - 剩余时间 ≤ 5 分钟时标记为紧急状态(红色高亮)
*
* 设计要点:
* - 使用 ref 存储 onExpire 回调避免闭包陷阱
* - 使用 setInterval 每秒更新 stateDate.now 仅在 interval 回调中调用,
* 不在 render 阶段调用,符合 react-hooks/purity 规则)
* - setState 仅在 interval 回调中异步调用,不在 effect 体内同步执行
* - 服务端时间偏差由调用方传入 startedAt服务端 ISO 时间)缓解
*/
export interface ExamCountdownState {
/** 剩余毫秒数(≤ 0 表示已到时) */
remainingMs: number
/** 剩余小时数 */
hours: number
/** 剩余分钟数0-59 */
minutes: number
/** 剩余秒数0-59 */
seconds: number
/** 是否已到时 */
isExpired: boolean
/** 是否进入紧急状态(≤ 5 分钟) */
isUrgent: boolean
}
interface UseExamCountdownOptions {
/** 考试时长分钟null 表示无限制 */
durationMinutes: number | null
/** 提交记录创建时间ISO 字符串),用于计算截止时间 */
startedAt: string | null
/** 到时回调(仅触发一次) */
onExpire?: () => void
/** 是否启用(默认 true */
enabled?: boolean
}
const URGENT_THRESHOLD_MS = 5 * 60 * 1000 // 5 分钟
const TICK_INTERVAL_MS = 1000
const computeState = (remainingMs: number): ExamCountdownState => {
const clamped = Math.max(0, remainingMs)
const totalSeconds = Math.floor(clamped / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return {
remainingMs: clamped,
hours,
minutes,
seconds,
isExpired: remainingMs <= 0,
isUrgent: remainingMs > 0 && remainingMs <= URGENT_THRESHOLD_MS,
}
}
const isStartTimeValid = (startedAt: string | null): boolean =>
startedAt !== null && !Number.isNaN(new Date(startedAt).getTime())
export function useExamCountdown({
durationMinutes,
startedAt,
onExpire,
enabled = true,
}: UseExamCountdownOptions): ExamCountdownState | null {
const [state, setState] = useState<ExamCountdownState | null>(null)
const onExpireRef = useRef(onExpire)
const expiredRef = useRef(false)
// 保持 onExpire 回调最新,避免闭包陷阱
useEffect(() => {
onExpireRef.current = onExpire
}, [onExpire])
// 配置有效性(派生计算,无需 setState
const isConfigValid =
enabled &&
durationMinutes !== null &&
durationMinutes > 0 &&
isStartTimeValid(startedAt)
// 启动每秒定时器Date.now() 与 setState 均在 interval 回调中异步调用,
// 不在 effect 体内同步执行,符合 react-hooks/set-state-in-effect 与 purity 规则
useEffect(() => {
if (!isConfigValid || !startedAt || durationMinutes === null) {
return
}
const startTime = new Date(startedAt).getTime()
const deadline = startTime + durationMinutes * 60 * 1000
expiredRef.current = false
const update = () => {
const remaining = deadline - Date.now()
setState(computeState(remaining))
if (remaining <= 0 && !expiredRef.current) {
expiredRef.current = true
onExpireRef.current?.()
}
}
const timer = setInterval(update, TICK_INTERVAL_MS)
return () => clearInterval(timer)
}, [isConfigValid, startedAt, durationMinutes])
// 配置无效时不显示倒计时state 旧值由 isConfigValid 守卫拦截)
if (!isConfigValid) {
return null
}
return state
}

View File

@@ -161,12 +161,26 @@ export type StudentHomeworkTakeData = {
lateDueAt: string | null
maxAttempts: number
}
/**
* 考试模式配置(仅当作业关联考试时存在)。
* P0-竞品修复:限时/监考模式需在答题页展示倒计时并到时自动提交。
*/
examModeConfig: {
examMode: "homework" | "timed" | "proctored"
durationMinutes: number | null
shuffleQuestions: boolean
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
} | null
submission: {
id: string
status: HomeworkSubmissionStatus
attemptNo: number
submittedAt: string | null
score: number | null
/** 提交记录创建时间(用于计算限时考试的剩余时间) */
startedAt: string | null
} | null
questions: StudentHomeworkTakeQuestion[]
}

View File

@@ -1,6 +1,6 @@
"use client"
import { type Control, type FieldPath, useWatch } from "react-hook-form"
import { useFormContext, useWatch } from "react-hook-form"
import { useTranslations } from "next-intl"
import {
FormField,
@@ -37,18 +37,33 @@ export interface ExamModeConfigFieldValues {
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
[key: string]: unknown
}
type ExamModeConfigProps<T extends ExamModeConfigFieldValues> = {
control: Control<T>
}
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
control,
}: ExamModeConfigProps<T>) {
/**
* 考试模式配置卡片homework / timed / proctored
*
* P1-2 修复:移除泛型与所有 as 断言。
* 原方案通过 control: Control<ExamModeConfigFieldValues> 接收控制对象,
* 但 Control<T> 在 react-hook-form 中为不变型invariant
* Control<ExamFormValues> 无法赋值给 Control<ExamModeConfigFieldValues>。
* 改为使用 useFormContext 从 FormProvider 读取表单上下文,
* 避免在调用方使用 as 断言适配 Control 类型。
*
* 使用方需确保:
* 1. 外层已通过 <Form {...form}> / <FormProvider {...form}> 提供上下文
* 2. 表单值类型结构包含 ExamModeConfigFieldValues 的全部字段
*/
export function ExamModeConfig() {
const t = useTranslations("examHomework")
const examMode = useWatch({ control, name: "examMode" as FieldPath<T> }) as ExamMode
const form = useFormContext<ExamModeConfigFieldValues>()
// useWatch 必须在条件返回之前无条件调用Rules of Hooks
// form?.control 为 undefined 时 useWatch 会回退到 useFormContext
const examMode = useWatch<ExamModeConfigFieldValues, "examMode">({
control: form?.control,
name: "examMode",
})
if (!form) return null
const { control } = form
const showDuration = examMode === "timed" || examMode === "proctored"
const showProctorOptions = examMode === "proctored"
@@ -61,13 +76,13 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<CardContent className="space-y-6">
<FormField
control={control}
name={"examMode" as FieldPath<T>}
name="examMode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("proctoring.mode.title")}</FormLabel>
<FormControl>
<RadioGroup
value={field.value as ExamMode}
value={field.value}
onValueChange={field.onChange}
className="grid gap-3 md:grid-cols-3"
>
@@ -102,7 +117,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
{showDuration && (
<FormField
control={control}
name={"durationMinutes" as FieldPath<T>}
name="durationMinutes"
render={({ field }) => (
<FormItem className="space-y-2">
<div className="space-y-0.5">
@@ -117,12 +132,15 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<Input
type="number"
min={1}
value={(field.value as number | null) ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? null : Number(e.target.value),
)
}
value={field.value ?? ""}
onChange={(e) => {
if (e.target.value === "") {
field.onChange(null)
return
}
const n = Number(e.target.value)
field.onChange(Number.isFinite(n) ? n : null)
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
@@ -139,7 +157,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<>
<FormField
control={control}
name={"shuffleQuestions" as FieldPath<T>}
name="shuffleQuestions"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
@@ -148,7 +166,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div>
<FormControl>
<Switch
checked={field.value as boolean}
checked={field.value}
onCheckedChange={field.onChange}
aria-label={t("proctoring.config.shuffleQuestions")}
/>
@@ -159,7 +177,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField
control={control}
name={"antiCheatEnabled" as FieldPath<T>}
name="antiCheatEnabled"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
@@ -168,7 +186,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div>
<FormControl>
<Switch
checked={field.value as boolean}
checked={field.value}
onCheckedChange={field.onChange}
aria-label={t("proctoring.config.antiCheat")}
/>
@@ -179,7 +197,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField
control={control}
name={"allowLateStart" as FieldPath<T>}
name="allowLateStart"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-0.5">
@@ -188,7 +206,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
</div>
<FormControl>
<Switch
checked={field.value as boolean}
checked={field.value}
onCheckedChange={field.onChange}
aria-label={t("proctoring.config.allowLateStart")}
/>
@@ -199,7 +217,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<FormField
control={control}
name={"lateStartGraceMinutes" as FieldPath<T>}
name="lateStartGraceMinutes"
render={({ field }) => (
<FormItem className="space-y-2">
<div className="space-y-0.5">
@@ -210,8 +228,11 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
<Input
type="number"
min={0}
value={(field.value as number) ?? 0}
onChange={(e) => field.onChange(Number(e.target.value))}
value={field.value ?? 0}
onChange={(e) => {
const n = Number(e.target.value)
field.onChange(Number.isFinite(n) ? n : 0)
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}

View File

@@ -40,6 +40,27 @@ import { PROCTORING_EVENT_LABELS, EXAM_MODE_LABELS } from "../types"
const REFRESH_INTERVAL_MS = 10_000
/**
* P1-3 修复:类型守卫替代 `as ProctoringEventType[]` 断言。
* `Object.keys()` 返回 `string[]`,需过滤为合法的 ProctoringEventType。
*/
const PROCTORING_EVENT_TYPE_SET: ReadonlySet<string> = new Set([
"tab_switch",
"window_blur",
"copy_attempt",
"paste_attempt",
"right_click",
"devtools_open",
"fullscreen_exit",
"idle_timeout",
])
const isProctoringEventType = (v: string): v is ProctoringEventType =>
PROCTORING_EVENT_TYPE_SET.has(v)
const toProctoringEventTypes = (keys: string[]): ProctoringEventType[] =>
keys.filter(isProctoringEventType)
const formatTime = (iso: string | null): string => {
if (!iso) return "—"
return formatDateTime(iso)
@@ -176,7 +197,7 @@ export function ProctoringDashboard({
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(Object.keys(summary.eventsByType) as ProctoringEventType[]).map((type) => {
{toProctoringEventTypes(Object.keys(summary.eventsByType)).map((type) => {
const count = summary.eventsByType[type]
if (count === 0) return null
return (
@@ -229,7 +250,7 @@ export function ProctoringDashboard({
<TableCell className="text-muted-foreground">{formatTime(s.lastEventAt)}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{(Object.keys(s.eventsByType) as ProctoringEventType[])
{toProctoringEventTypes(Object.keys(s.eventsByType))
.filter((t) => s.eventsByType[t] > 0)
.map((t) => (
<Badge key={t} variant="outline" className="text-xs">