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

@@ -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
}