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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
122
src/modules/homework/hooks/use-exam-countdown.ts
Normal file
122
src/modules/homework/hooks/use-exam-countdown.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
/**
|
||||
* P0-竞品修复:考试倒计时 hook。
|
||||
*
|
||||
* 对标智学网/猿题库的限时考试功能:
|
||||
* - 学生开始作答后,根据 durationMinutes 计算截止时间
|
||||
* - 每秒更新剩余时间
|
||||
* - 剩余时间 ≤ 0 时触发 onExpire 回调(自动提交)
|
||||
* - 剩余时间 ≤ 5 分钟时标记为紧急状态(红色高亮)
|
||||
*
|
||||
* 设计要点:
|
||||
* - 使用 ref 存储 onExpire 回调避免闭包陷阱
|
||||
* - 使用 setInterval 每秒更新 state(Date.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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user