fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理
P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)
P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签
P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式
同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
persistExamDraft,
|
||||
resolveSubjectGradeNames,
|
||||
updateExamWithQuestions,
|
||||
type ExamModeConfig,
|
||||
} from "./data-access"
|
||||
import {
|
||||
AiGeneratedStructureSchema,
|
||||
@@ -58,6 +59,30 @@ const getStringValue = (formData: FormData, key: string) => {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => {
|
||||
const value = formData.get(key)
|
||||
if (typeof value !== "string") return fallback
|
||||
return value === "true"
|
||||
}
|
||||
|
||||
const parseExamModeConfig = (formData: FormData): ExamModeConfig => {
|
||||
const rawMode = getStringValue(formData, "examMode")
|
||||
const examMode: ExamModeConfig["examMode"] =
|
||||
rawMode === "timed" || rawMode === "proctored" ? rawMode : "homework"
|
||||
const rawDuration = getStringValue(formData, "durationMinutes")
|
||||
const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration))
|
||||
? Number(rawDuration)
|
||||
: null
|
||||
return {
|
||||
examMode,
|
||||
durationMinutes,
|
||||
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
|
||||
allowLateStart: getBoolValue(formData, "allowLateStart", false),
|
||||
lateStartGraceMinutes: Number(getStringValue(formData, "lateStartGraceMinutes") ?? "0") || 0,
|
||||
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
|
||||
}
|
||||
}
|
||||
|
||||
const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
|
||||
success: false,
|
||||
message,
|
||||
@@ -317,6 +342,7 @@ export async function createExamAction(
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
@@ -436,6 +462,7 @@ export async function createAiExamAction(
|
||||
description,
|
||||
structure,
|
||||
generated,
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
|
||||
@@ -1,927 +0,0 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { z } from "zod"
|
||||
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
|
||||
import { env } from "@/env.mjs"
|
||||
|
||||
const AiSubQuestionSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
text: z.string().min(1),
|
||||
answer: z.string().min(1).optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
const AiQuestionContentSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
text: z.string().min(1),
|
||||
isCorrect: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
subQuestions: z.array(AiSubQuestionSchema).optional(),
|
||||
})
|
||||
|
||||
export const AiQuestionSchema = z.object({
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
|
||||
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
content: AiQuestionContentSchema,
|
||||
})
|
||||
|
||||
export const AiInsertQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
|
||||
difficulty: z.coerce.number().int().min(1).max(5),
|
||||
score: z.coerce.number().int().min(0),
|
||||
content: AiQuestionContentSchema.extend({
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
isCorrect: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
subQuestions: z.array(
|
||||
AiSubQuestionSchema.extend({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const AiSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
questions: z.array(AiQuestionSchema).min(1),
|
||||
})
|
||||
|
||||
const AiExamResponseSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
questions: z.array(AiQuestionSchema).optional(),
|
||||
sections: z.array(AiSectionSchema).optional(),
|
||||
})
|
||||
|
||||
const sanitizeJsonCandidate = (value: string): string => value
|
||||
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
|
||||
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
|
||||
.trim()
|
||||
|
||||
const tryParseJson = (value: string): unknown | null => {
|
||||
const sanitized = sanitizeJsonCandidate(value)
|
||||
if (!sanitized) return null
|
||||
try {
|
||||
return JSON.parse(sanitized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const extractBalancedJsonSegment = (value: string): string | null => {
|
||||
const startBrace = value.indexOf("{")
|
||||
const startBracket = value.indexOf("[")
|
||||
const start =
|
||||
startBrace === -1
|
||||
? startBracket
|
||||
: startBracket === -1
|
||||
? startBrace
|
||||
: Math.min(startBrace, startBracket)
|
||||
if (start === -1) return null
|
||||
const opening = value[start]
|
||||
const closing = opening === "{" ? "}" : "]"
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let i = start; i < value.length; i += 1) {
|
||||
const char = value[i]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (char === "\\") {
|
||||
escaped = true
|
||||
} else if (char === "\"") {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (char === opening) {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (char === closing) {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return value.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extractJson = (raw: string): unknown => {
|
||||
const trimmed = raw.trim()
|
||||
const candidates: string[] = []
|
||||
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)]
|
||||
if (fencedMatches.length > 0) {
|
||||
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
|
||||
}
|
||||
candidates.push(trimmed)
|
||||
for (const candidate of candidates) {
|
||||
const direct = tryParseJson(candidate)
|
||||
if (direct !== null) return direct
|
||||
const segment = extractBalancedJsonSegment(candidate)
|
||||
if (!segment) continue
|
||||
const parsed = tryParseJson(segment)
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
throw new Error("Invalid AI response")
|
||||
}
|
||||
|
||||
const AI_JSON_REPAIR_PROMPT = [
|
||||
"You are a JSON repair engine.",
|
||||
"Fix the provided invalid JSON into valid JSON only.",
|
||||
"Keep the original structure and values as much as possible.",
|
||||
"Do not use placeholders such as ... or [...].",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
|
||||
const repairJson = async (raw: string, providerId?: string) => {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_JSON_REPAIR_PROMPT },
|
||||
{ role: "user" as const, content: raw },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
return extractJson(aiResult.content)
|
||||
}
|
||||
|
||||
const parseAiResponse = async (raw: string, providerId?: string) => {
|
||||
try {
|
||||
return extractJson(raw)
|
||||
} catch {
|
||||
return repairJson(raw, providerId)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeScores = (scores: number[], totalScore: number): number[] => {
|
||||
if (scores.length === 0) return []
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0)
|
||||
if (sum <= 0) {
|
||||
const base = Math.floor(totalScore / scores.length)
|
||||
const remainder = totalScore - base * scores.length
|
||||
return scores.map((_, idx) => base + (idx < remainder ? 1 : 0))
|
||||
}
|
||||
const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore)))
|
||||
let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0)
|
||||
let i = 0
|
||||
while (diff !== 0 && i < scaled.length * 2) {
|
||||
const idx = i % scaled.length
|
||||
if (diff > 0) {
|
||||
scaled[idx] += 1
|
||||
diff -= 1
|
||||
} else if (scaled[idx] > 0) {
|
||||
scaled[idx] -= 1
|
||||
diff += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
const AI_EXAM_SYSTEM_PROMPT = [
|
||||
"You are an exam parsing engine.",
|
||||
"Parse the provided exam text and output JSON only.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"Preserve the original order and sectioning if present.",
|
||||
"Escape double quotes inside string values.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "sections": [',
|
||||
' { "title": "Section Title", "questions": [',
|
||||
' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }',
|
||||
" ] }",
|
||||
" ]",
|
||||
"}",
|
||||
"For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.",
|
||||
'content.subQuestions item schema: { "id": "1", "text": "lǎn duò( )", "answer": "懒惰", "score": 1 }',
|
||||
"If you do not need sections, return { \"questions\": [] } or include real question items.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
|
||||
const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [
|
||||
"You are a question rewriting engine.",
|
||||
"Rewrite exactly one question based on teacher instruction.",
|
||||
"Return JSON only without markdown.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 1,',
|
||||
' "score": 5,',
|
||||
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
|
||||
"}",
|
||||
"For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
const AiStructureQuestionSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
const AiStructureSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
questions: z.array(AiStructureQuestionSchema).min(1),
|
||||
})
|
||||
|
||||
const AiStructureResponseSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
sections: z.array(AiStructureSectionSchema).optional(),
|
||||
questions: z.array(AiStructureQuestionSchema).optional(),
|
||||
})
|
||||
|
||||
const AiSourceValidationSchema = z.object({
|
||||
valid: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [
|
||||
"You are an exam splitter engine.",
|
||||
"Split the provided exam text into ordered question units quickly.",
|
||||
"Do not deeply analyze choices or answers in this step.",
|
||||
"Keep original sectioning and question order.",
|
||||
"If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.",
|
||||
"Do not split one parent question into several child-only units.",
|
||||
"Output JSON only.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "title": "Optional title",',
|
||||
' "sections": [',
|
||||
' { "title": "Section Title", "questions": [',
|
||||
' { "text": "Original full question text", "score": 5 }',
|
||||
" ] }",
|
||||
" ]",
|
||||
"}",
|
||||
"If no sections, return:",
|
||||
'{ "questions": [ { "text": "Original full question text", "score": 5 } ] }',
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [
|
||||
"You are an exam text validator.",
|
||||
"Judge whether the input text is readable and likely a normal exam/question text.",
|
||||
"Reject garbled text, random symbols, severely disordered fragments, or meaningless content.",
|
||||
"Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
'{ "valid": true, "reason": "short reason" }',
|
||||
].join("\n")
|
||||
|
||||
const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
|
||||
"You are an exam question detail parser.",
|
||||
"Given one split question text, output one structured question JSON only.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.",
|
||||
"Use exact key name content.subQuestions (camelCase).",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 1,',
|
||||
' "score": 5,',
|
||||
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
|
||||
"}",
|
||||
"For judgment/text, options can be omitted.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
type AiChatMessage = { role: "system" | "user"; content: string }
|
||||
|
||||
const buildAiMessages = (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
}): AiChatMessage[] => {
|
||||
const userLines = [
|
||||
input.title ? `Title: ${input.title}` : "",
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "",
|
||||
typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "",
|
||||
typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "",
|
||||
input.questionCount ? `Question Count: ${input.questionCount}` : "",
|
||||
`Source Exam Text:\n${input.sourceText}`,
|
||||
]
|
||||
const userContent = userLines.filter((l) => l.length > 0).join("\n")
|
||||
return [
|
||||
{ role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
}
|
||||
|
||||
type AiDraftResult =
|
||||
| { ok: true; data: z.infer<typeof AiExamResponseSchema>; rawOutput: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
type AiStructureDraftResult =
|
||||
| { ok: true; data: z.infer<typeof AiStructureResponseSchema>; rawOutput: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
const requestAiExamDraft = async (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}): Promise<AiDraftResult> => {
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: buildAiMessages(input),
|
||||
temperature: 0.7,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
const rawOutput = aiResult.content
|
||||
const data = await parseAiResponse(rawOutput, input.aiProviderId)
|
||||
const validated = AiExamResponseSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { ok: false, message: "AI response format invalid" }
|
||||
}
|
||||
return { ok: true, data: validated.data, rawOutput }
|
||||
} catch (error) {
|
||||
return { ok: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const requestAiExamStructureDraft = async (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}): Promise<AiStructureDraftResult> => {
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: buildAiMessages(input)[1].content },
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
const rawOutput = aiResult.content
|
||||
const data = await parseAiResponse(rawOutput, input.aiProviderId)
|
||||
const validated = AiStructureResponseSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { ok: false, message: "AI response format invalid" }
|
||||
}
|
||||
return { ok: true, data: validated.data, rawOutput }
|
||||
} catch (error) {
|
||||
return { ok: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
type SplitQuestionItem = {
|
||||
sectionIndex: number | null
|
||||
sectionTitle?: string
|
||||
text: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
|
||||
const text = input.sourceText.trim()
|
||||
if (!text) {
|
||||
return { ok: false as const, message: "请先粘贴试卷文本" }
|
||||
}
|
||||
const userContent = [
|
||||
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
|
||||
`文本内容:\n${text}`,
|
||||
].join("\n\n")
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 300,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const validated = AiSourceValidationSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return { ok: false as const, message: "试卷文本校验失败,请重试" }
|
||||
}
|
||||
if (!validated.data.valid) {
|
||||
return {
|
||||
ok: false as const,
|
||||
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
|
||||
}
|
||||
}
|
||||
return { ok: true as const }
|
||||
} catch (error) {
|
||||
return { ok: false as const, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>): SplitQuestionItem[] => {
|
||||
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
|
||||
if (!hasSections) {
|
||||
return (draft.questions ?? []).map((q) => ({
|
||||
sectionIndex: null,
|
||||
sectionTitle: undefined,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
} satisfies SplitQuestionItem))
|
||||
}
|
||||
const rows: SplitQuestionItem[] = []
|
||||
const sections = draft.sections
|
||||
if (sections) {
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
section.questions.forEach((q) => {
|
||||
rows.push({
|
||||
sectionIndex,
|
||||
sectionTitle: section.title,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const results = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
const runWorker = async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
const parseQuestionDetail = async (input: {
|
||||
item: SplitQuestionItem
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
aiProviderId?: string
|
||||
}): Promise<z.infer<typeof AiQuestionSchema>> => {
|
||||
const normalizeQuestionCandidate = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== "object") return value
|
||||
const record = value as Record<string, unknown>
|
||||
const contentRaw = record.content
|
||||
if (!contentRaw || typeof contentRaw !== "object") return value
|
||||
const content = contentRaw as Record<string, unknown>
|
||||
const normalizedSubQuestions = Array.isArray(content.subQuestions)
|
||||
? content.subQuestions
|
||||
: Array.isArray(content.subquestions)
|
||||
? content.subquestions
|
||||
: Array.isArray(content.sub_questions)
|
||||
? content.sub_questions
|
||||
: undefined
|
||||
if (!normalizedSubQuestions) return value
|
||||
return {
|
||||
...record,
|
||||
content: {
|
||||
...content,
|
||||
subQuestions: normalizedSubQuestions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const userContent = [
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
`Question Text:\n${input.item.text}`,
|
||||
].filter((line) => line.length > 0).join("\n\n")
|
||||
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0.4,
|
||||
maxTokens: 1200,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const candidate = parsed && typeof parsed === "object" && "question" in parsed
|
||||
? (parsed as { question: unknown }).question
|
||||
: parsed
|
||||
const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate))
|
||||
if (validated.success) {
|
||||
const q = validated.data
|
||||
return {
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? input.difficulty,
|
||||
score: q.score ?? input.item.score ?? 0,
|
||||
content: q.content,
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
difficulty: input.difficulty,
|
||||
score: input.item.score ?? 0,
|
||||
content: { text: input.item.text },
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
|
||||
type QuestionContentResult = {
|
||||
text: string
|
||||
options?: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
}
|
||||
|
||||
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>): QuestionContentResult => {
|
||||
const base = { text: q.content.text }
|
||||
const subQuestions = Array.isArray(q.content.subQuestions)
|
||||
? q.content.subQuestions.map((item, index) => ({
|
||||
id: item.id ?? String(index + 1),
|
||||
text: item.text,
|
||||
answer: item.answer,
|
||||
score: item.score,
|
||||
}))
|
||||
: []
|
||||
if (q.type === "single_choice" || q.type === "multiple_choice") {
|
||||
const options = (q.content.options ?? []).map((opt, idx) => ({
|
||||
id: opt.id ?? String.fromCharCode(65 + idx),
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
}))
|
||||
if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions }
|
||||
if (options.length > 0) return { ...base, options }
|
||||
if (subQuestions.length > 0) return { ...base, subQuestions }
|
||||
return base
|
||||
}
|
||||
if (subQuestions.length > 0) return { ...base, subQuestions }
|
||||
return base
|
||||
}
|
||||
|
||||
type AiPreviewQuestion = {
|
||||
id: string
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: ReturnType<typeof buildQuestionContent>
|
||||
}
|
||||
|
||||
export type AiPreviewData = {
|
||||
title: string
|
||||
rawOutput?: string
|
||||
sections?: Array<{
|
||||
id: string
|
||||
title: string
|
||||
questions: AiPreviewQuestion[]
|
||||
}>
|
||||
questions?: AiPreviewQuestion[]
|
||||
}
|
||||
|
||||
export type AiRewriteQuestionData = {
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: ReturnType<typeof buildQuestionContent>
|
||||
}
|
||||
|
||||
export type AiGeneratedQuestion = {
|
||||
id: string
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: ReturnType<typeof buildQuestionContent>
|
||||
}
|
||||
|
||||
export type AiGeneratedStructureNode = {
|
||||
id: string
|
||||
type: "group" | "question"
|
||||
title?: string
|
||||
questionId?: string
|
||||
score?: number
|
||||
children?: AiGeneratedStructureNode[]
|
||||
}
|
||||
|
||||
export const AiGeneratedStructureNodeSchema: z.ZodType<AiGeneratedStructureNode> = z.lazy(() => z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["group", "question"]),
|
||||
title: z.string().optional(),
|
||||
questionId: z.string().optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
children: z.array(AiGeneratedStructureNodeSchema).optional(),
|
||||
}))
|
||||
|
||||
export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema)
|
||||
|
||||
const buildPreviewPayload = (
|
||||
aiParsed: z.infer<typeof AiExamResponseSchema>,
|
||||
input: {
|
||||
title: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
questionCount?: number
|
||||
}
|
||||
): AiPreviewData => {
|
||||
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
|
||||
const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? []
|
||||
const limit = input.questionCount
|
||||
let sections = aiParsed.sections
|
||||
let flatQuestions = baseQuestions
|
||||
|
||||
if (typeof limit === "number" && limit > 0) {
|
||||
if (hasSections) {
|
||||
const parsedSections = aiParsed.sections
|
||||
let remaining = limit
|
||||
sections = (parsedSections ?? []).map((s) => {
|
||||
if (remaining <= 0) return { ...s, questions: [] }
|
||||
const sliced = s.questions.slice(0, remaining)
|
||||
remaining -= sliced.length
|
||||
return { ...s, questions: sliced }
|
||||
}).filter((s) => s.questions.length > 0)
|
||||
flatQuestions = sections.flatMap((s) => s.questions)
|
||||
} else {
|
||||
flatQuestions = baseQuestions.slice(0, limit)
|
||||
}
|
||||
}
|
||||
|
||||
const scores = normalizeScores(
|
||||
flatQuestions.map((q) => q.score ?? 0),
|
||||
input.totalScore
|
||||
)
|
||||
|
||||
let scoreIndex = 0
|
||||
const toPreviewQuestion = (q: z.infer<typeof AiQuestionSchema>): AiPreviewQuestion => ({
|
||||
id: createId(),
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? input.difficulty,
|
||||
score: scores[scoreIndex++] ?? 0,
|
||||
content: buildQuestionContent(q),
|
||||
})
|
||||
|
||||
if (hasSections && sections && sections.length > 0) {
|
||||
return {
|
||||
title: aiParsed.title ?? input.title,
|
||||
sections: sections.map((section) => ({
|
||||
id: createId(),
|
||||
title: section.title,
|
||||
questions: section.questions.map((q) => toPreviewQuestion(q)),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: aiParsed.title ?? input.title,
|
||||
questions: flatQuestions.map((q) => toPreviewQuestion(q)),
|
||||
}
|
||||
}
|
||||
|
||||
const previewToDraft = (preview: AiPreviewData): {
|
||||
generated: AiGeneratedQuestion[]
|
||||
structure: AiGeneratedStructureNode[]
|
||||
} => {
|
||||
const generated: AiGeneratedQuestion[] = []
|
||||
const structure: AiGeneratedStructureNode[] = []
|
||||
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
|
||||
for (const section of preview.sections) {
|
||||
const children: AiGeneratedStructureNode[] = []
|
||||
for (const question of section.questions) {
|
||||
generated.push({
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
difficulty: question.difficulty,
|
||||
score: question.score,
|
||||
content: question.content,
|
||||
})
|
||||
children.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: question.score,
|
||||
})
|
||||
}
|
||||
structure.push({
|
||||
id: section.id || createId(),
|
||||
type: "group",
|
||||
title: section.title,
|
||||
children,
|
||||
})
|
||||
}
|
||||
return { generated, structure }
|
||||
}
|
||||
for (const question of preview.questions ?? []) {
|
||||
generated.push({
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
difficulty: question.difficulty,
|
||||
score: question.score,
|
||||
content: question.content,
|
||||
})
|
||||
structure.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: question.score,
|
||||
})
|
||||
}
|
||||
return { generated, structure }
|
||||
}
|
||||
|
||||
export async function generateAiPreviewData(input: {
|
||||
title: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
const sourceValidation = await validateExamSourceText({
|
||||
sourceText: input.sourceText,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!sourceValidation.ok) {
|
||||
return { ok: false as const, message: sourceValidation.message }
|
||||
}
|
||||
const structureDraft = await requestAiExamStructureDraft(input)
|
||||
if (!structureDraft.ok) return structureDraft
|
||||
const splitItems = splitStructureItems(structureDraft.data)
|
||||
const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0
|
||||
? splitItems.slice(0, input.questionCount)
|
||||
: splitItems
|
||||
if (limitedItems.length === 0) {
|
||||
return { ok: false as const, message: "AI returned no questions" }
|
||||
}
|
||||
const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({
|
||||
item,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
aiProviderId: input.aiProviderId,
|
||||
}))
|
||||
const hasSectionStructure = limitedItems.some((item) => item.sectionIndex !== null)
|
||||
const aiParsed: z.infer<typeof AiExamResponseSchema> = hasSectionStructure
|
||||
? {
|
||||
title: structureDraft.data.title ?? input.title,
|
||||
sections: (() => {
|
||||
const sectionMap = new Map<number, { title: string; questions: z.infer<typeof AiQuestionSchema>[] }>()
|
||||
limitedItems.forEach((item, index) => {
|
||||
if (item.sectionIndex === null) return
|
||||
const existed = sectionMap.get(item.sectionIndex)
|
||||
const question = detailedQuestions[index]
|
||||
if (existed) {
|
||||
existed.questions.push(question)
|
||||
return
|
||||
}
|
||||
sectionMap.set(item.sectionIndex, {
|
||||
title: item.sectionTitle || `Section ${item.sectionIndex + 1}`,
|
||||
questions: [question],
|
||||
})
|
||||
})
|
||||
return Array.from(sectionMap.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, section]) => section)
|
||||
})(),
|
||||
questions: undefined,
|
||||
}
|
||||
: {
|
||||
title: structureDraft.data.title ?? input.title,
|
||||
questions: detailedQuestions,
|
||||
sections: undefined,
|
||||
}
|
||||
const payload = buildPreviewPayload(aiParsed, input)
|
||||
return {
|
||||
ok: true as const,
|
||||
data: payload,
|
||||
rawOutput: structureDraft.rawOutput,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAiCreateDraftFromSource(input: {
|
||||
title: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
const preview = await generateAiPreviewData(input)
|
||||
if (!preview.ok) {
|
||||
return preview
|
||||
}
|
||||
const draft = previewToDraft(preview.data)
|
||||
return {
|
||||
ok: true as const,
|
||||
generated: draft.generated,
|
||||
structure: draft.structure,
|
||||
rawOutput: preview.rawOutput,
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAiQuestionByInstruction(input: {
|
||||
instruction: string
|
||||
originalQuestion: z.infer<typeof AiQuestionSchema>
|
||||
sourceText?: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
const originalDifficulty = input.originalQuestion.difficulty ?? 3
|
||||
const originalScore = input.originalQuestion.score ?? 0
|
||||
const contextLines = [
|
||||
`Instruction:\n${input.instruction}`,
|
||||
`Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
|
||||
input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "",
|
||||
]
|
||||
const userContent = contextLines.filter((line) => line.length > 0).join("\n\n")
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const candidate = parsed && typeof parsed === "object" && "question" in parsed
|
||||
? (parsed as { question: unknown }).question
|
||||
: parsed
|
||||
const validated = AiQuestionSchema.safeParse(candidate)
|
||||
if (!validated.success) {
|
||||
return { ok: false as const, message: "AI question format invalid" }
|
||||
}
|
||||
const question = validated.data
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
type: question.type,
|
||||
difficulty: question.difficulty ?? originalDifficulty,
|
||||
score: question.score ?? originalScore,
|
||||
content: buildQuestionContent(question),
|
||||
} satisfies AiRewriteQuestionData,
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false as const, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAiExamDraft(input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
return requestAiExamDraft(input)
|
||||
}
|
||||
172
src/modules/exams/ai-pipeline/index.ts
Normal file
172
src/modules/exams/ai-pipeline/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* AI 试卷生成管线(入口模块)
|
||||
*
|
||||
* 本目录由三个子模块组成:
|
||||
* - parse.ts: Zod schema、JSON 解析、纯转换函数、提示词
|
||||
* - request.ts: AI 请求构造与发送
|
||||
* - structure.ts: 结构生成与预览/草稿转换
|
||||
*
|
||||
* 本文件负责:
|
||||
* - 重新导出公共 API(schema、类型、函数)
|
||||
* - 编排高层流程(generateAiPreviewData / generateAiCreateDraftFromSource)
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
AiExamResponseSchema,
|
||||
AiQuestionSchema,
|
||||
} from "./parse"
|
||||
import {
|
||||
parseQuestionDetail,
|
||||
requestAiExamDraft,
|
||||
requestAiExamStructureDraft,
|
||||
validateExamSourceText,
|
||||
type SplitQuestionItem,
|
||||
} from "./request"
|
||||
import {
|
||||
buildPreviewPayload,
|
||||
mapWithConcurrency,
|
||||
previewToDraft,
|
||||
splitStructureItems,
|
||||
} from "./structure"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export public schemas & types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
AiGeneratedStructureSchema,
|
||||
AiGeneratedStructureNodeSchema,
|
||||
AiInsertQuestionSchema,
|
||||
AiQuestionSchema,
|
||||
} from "./parse"
|
||||
|
||||
export type {
|
||||
AiGeneratedQuestion,
|
||||
AiGeneratedStructureNode,
|
||||
AiPreviewData,
|
||||
AiPreviewQuestion,
|
||||
AiRewriteQuestionData,
|
||||
QuestionContentResult,
|
||||
} from "./parse"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export public functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { regenerateAiQuestionByInstruction } from "./request"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// High-level orchestration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateAiPreviewData(input: {
|
||||
title: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
const sourceValidation = await validateExamSourceText({
|
||||
sourceText: input.sourceText,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!sourceValidation.ok) {
|
||||
return { ok: false as const, message: sourceValidation.message }
|
||||
}
|
||||
const structureDraft = await requestAiExamStructureDraft(input)
|
||||
if (!structureDraft.ok) return structureDraft
|
||||
const splitItems = splitStructureItems(structureDraft.data)
|
||||
const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0
|
||||
? splitItems.slice(0, input.questionCount)
|
||||
: splitItems
|
||||
if (limitedItems.length === 0) {
|
||||
return { ok: false as const, message: "AI returned no questions" }
|
||||
}
|
||||
const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({
|
||||
item,
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
aiProviderId: input.aiProviderId,
|
||||
}))
|
||||
const hasSectionStructure = limitedItems.some((item: SplitQuestionItem) => item.sectionIndex !== null)
|
||||
const aiParsed: z.infer<typeof AiExamResponseSchema> = hasSectionStructure
|
||||
? {
|
||||
title: structureDraft.data.title ?? input.title,
|
||||
sections: (() => {
|
||||
const sectionMap = new Map<number, { title: string; questions: z.infer<typeof AiQuestionSchema>[] }>()
|
||||
limitedItems.forEach((item, index) => {
|
||||
if (item.sectionIndex === null) return
|
||||
const existed = sectionMap.get(item.sectionIndex)
|
||||
const question = detailedQuestions[index]
|
||||
if (existed) {
|
||||
existed.questions.push(question)
|
||||
return
|
||||
}
|
||||
sectionMap.set(item.sectionIndex, {
|
||||
title: item.sectionTitle || `Section ${item.sectionIndex + 1}`,
|
||||
questions: [question],
|
||||
})
|
||||
})
|
||||
return Array.from(sectionMap.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, section]) => section)
|
||||
})(),
|
||||
questions: undefined,
|
||||
}
|
||||
: {
|
||||
title: structureDraft.data.title ?? input.title,
|
||||
questions: detailedQuestions,
|
||||
sections: undefined,
|
||||
}
|
||||
const payload = buildPreviewPayload(aiParsed, input)
|
||||
return {
|
||||
ok: true as const,
|
||||
data: payload,
|
||||
rawOutput: structureDraft.rawOutput,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAiCreateDraftFromSource(input: {
|
||||
title: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
durationMin: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
const preview = await generateAiPreviewData(input)
|
||||
if (!preview.ok) {
|
||||
return preview
|
||||
}
|
||||
const draft = previewToDraft(preview.data)
|
||||
return {
|
||||
ok: true as const,
|
||||
generated: draft.generated,
|
||||
structure: draft.structure,
|
||||
rawOutput: preview.rawOutput,
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAiExamDraft(input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}) {
|
||||
return requestAiExamDraft(input)
|
||||
}
|
||||
426
src/modules/exams/ai-pipeline/parse.ts
Normal file
426
src/modules/exams/ai-pipeline/parse.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* AI 响应解析与 Zod 校验
|
||||
*
|
||||
* 职责:
|
||||
* - JSON 提取与修复(从 AI 返回的原始文本中提取合法 JSON)
|
||||
* - Zod schema 定义(题目、试卷、结构等)
|
||||
* - 共享类型导出
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
import { createAiChatCompletion } from "@/shared/lib/ai"
|
||||
import { env } from "@/env.mjs"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AiSubQuestionSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
text: z.string().min(1),
|
||||
answer: z.string().min(1).optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
const AiQuestionContentSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
text: z.string().min(1),
|
||||
isCorrect: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
subQuestions: z.array(AiSubQuestionSchema).optional(),
|
||||
})
|
||||
|
||||
export const AiQuestionSchema = z.object({
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
|
||||
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
content: AiQuestionContentSchema,
|
||||
})
|
||||
|
||||
export const AiInsertQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]),
|
||||
difficulty: z.coerce.number().int().min(1).max(5),
|
||||
score: z.coerce.number().int().min(0),
|
||||
content: AiQuestionContentSchema.extend({
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
isCorrect: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
subQuestions: z.array(
|
||||
AiSubQuestionSchema.extend({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
).optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const AiSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
questions: z.array(AiQuestionSchema).min(1),
|
||||
})
|
||||
|
||||
export const AiExamResponseSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
questions: z.array(AiQuestionSchema).optional(),
|
||||
sections: z.array(AiSectionSchema).optional(),
|
||||
})
|
||||
|
||||
const AiStructureQuestionSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
const AiStructureSectionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
questions: z.array(AiStructureQuestionSchema).min(1),
|
||||
})
|
||||
|
||||
export const AiStructureResponseSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
sections: z.array(AiStructureSectionSchema).optional(),
|
||||
questions: z.array(AiStructureQuestionSchema).optional(),
|
||||
})
|
||||
|
||||
const AiSourceValidationSchema = z.object({
|
||||
valid: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure node schema (recursive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiGeneratedStructureNode = {
|
||||
id: string
|
||||
type: "group" | "question"
|
||||
title?: string
|
||||
questionId?: string
|
||||
score?: number
|
||||
children?: AiGeneratedStructureNode[]
|
||||
}
|
||||
|
||||
export const AiGeneratedStructureNodeSchema: z.ZodType<AiGeneratedStructureNode> = z.lazy(() => z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["group", "question"]),
|
||||
title: z.string().optional(),
|
||||
questionId: z.string().optional(),
|
||||
score: z.coerce.number().int().min(0).optional(),
|
||||
children: z.array(AiGeneratedStructureNodeSchema).optional(),
|
||||
}))
|
||||
|
||||
export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON extraction & repair
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sanitizeJsonCandidate = (value: string): string => value
|
||||
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
|
||||
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
|
||||
.trim()
|
||||
|
||||
const tryParseJson = (value: string): unknown | null => {
|
||||
const sanitized = sanitizeJsonCandidate(value)
|
||||
if (!sanitized) return null
|
||||
try {
|
||||
return JSON.parse(sanitized)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const extractBalancedJsonSegment = (value: string): string | null => {
|
||||
const startBrace = value.indexOf("{")
|
||||
const startBracket = value.indexOf("[")
|
||||
const start =
|
||||
startBrace === -1
|
||||
? startBracket
|
||||
: startBracket === -1
|
||||
? startBrace
|
||||
: Math.min(startBrace, startBracket)
|
||||
if (start === -1) return null
|
||||
const opening = value[start]
|
||||
const closing = opening === "{" ? "}" : "]"
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let i = start; i < value.length; i += 1) {
|
||||
const char = value[i]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (char === "\\") {
|
||||
escaped = true
|
||||
} else if (char === "\"") {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "\"") {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (char === opening) {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (char === closing) {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return value.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extractJson = (raw: string): unknown => {
|
||||
const trimmed = raw.trim()
|
||||
const candidates: string[] = []
|
||||
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)]
|
||||
if (fencedMatches.length > 0) {
|
||||
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
|
||||
}
|
||||
candidates.push(trimmed)
|
||||
for (const candidate of candidates) {
|
||||
const direct = tryParseJson(candidate)
|
||||
if (direct !== null) return direct
|
||||
const segment = extractBalancedJsonSegment(candidate)
|
||||
if (!segment) continue
|
||||
const parsed = tryParseJson(segment)
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
throw new Error("Invalid AI response")
|
||||
}
|
||||
|
||||
const AI_JSON_REPAIR_PROMPT = [
|
||||
"You are a JSON repair engine.",
|
||||
"Fix the provided invalid JSON into valid JSON only.",
|
||||
"Keep the original structure and values as much as possible.",
|
||||
"Do not use placeholders such as ... or [...].",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
|
||||
const repairJson = async (raw: string, providerId?: string) => {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_JSON_REPAIR_PROMPT },
|
||||
{ role: "user" as const, content: raw },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
return extractJson(aiResult.content)
|
||||
}
|
||||
|
||||
export const parseAiResponse = async (raw: string, providerId?: string) => {
|
||||
try {
|
||||
return extractJson(raw)
|
||||
} catch {
|
||||
return repairJson(raw, providerId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type QuestionContentResult = {
|
||||
text: string
|
||||
options?: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
}
|
||||
|
||||
export type AiPreviewQuestion = {
|
||||
id: string
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: QuestionContentResult
|
||||
}
|
||||
|
||||
export type AiPreviewData = {
|
||||
title: string
|
||||
rawOutput?: string
|
||||
sections?: Array<{
|
||||
id: string
|
||||
title: string
|
||||
questions: AiPreviewQuestion[]
|
||||
}>
|
||||
questions?: AiPreviewQuestion[]
|
||||
}
|
||||
|
||||
export type AiRewriteQuestionData = {
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: QuestionContentResult
|
||||
}
|
||||
|
||||
export type AiGeneratedQuestion = {
|
||||
id: string
|
||||
type: z.infer<typeof AiQuestionSchema>["type"]
|
||||
difficulty: number
|
||||
score: number
|
||||
content: QuestionContentResult
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure transformation functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const normalizeScores = (scores: number[], totalScore: number): number[] => {
|
||||
if (scores.length === 0) return []
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0)
|
||||
if (sum <= 0) {
|
||||
const base = Math.floor(totalScore / scores.length)
|
||||
const remainder = totalScore - base * scores.length
|
||||
return scores.map((_, idx) => base + (idx < remainder ? 1 : 0))
|
||||
}
|
||||
const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore)))
|
||||
let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0)
|
||||
let i = 0
|
||||
while (diff !== 0 && i < scaled.length * 2) {
|
||||
const idx = i % scaled.length
|
||||
if (diff > 0) {
|
||||
scaled[idx] += 1
|
||||
diff -= 1
|
||||
} else if (scaled[idx] > 0) {
|
||||
scaled[idx] -= 1
|
||||
diff += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
export const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>): QuestionContentResult => {
|
||||
const base = { text: q.content.text }
|
||||
const subQuestions = Array.isArray(q.content.subQuestions)
|
||||
? q.content.subQuestions.map((item, index) => ({
|
||||
id: item.id ?? String(index + 1),
|
||||
text: item.text,
|
||||
answer: item.answer,
|
||||
score: item.score,
|
||||
}))
|
||||
: []
|
||||
if (q.type === "single_choice" || q.type === "multiple_choice") {
|
||||
const options = (q.content.options ?? []).map((opt, idx) => ({
|
||||
id: opt.id ?? String.fromCharCode(65 + idx),
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
}))
|
||||
if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions }
|
||||
if (options.length > 0) return { ...base, options }
|
||||
if (subQuestions.length > 0) return { ...base, subQuestions }
|
||||
return base
|
||||
}
|
||||
if (subQuestions.length > 0) return { ...base, subQuestions }
|
||||
return base
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AI_EXAM_SYSTEM_PROMPT = [
|
||||
"You are an exam parsing engine.",
|
||||
"Parse the provided exam text and output JSON only.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"Preserve the original order and sectioning if present.",
|
||||
"Escape double quotes inside string values.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "sections": [',
|
||||
' { "title": "Section Title", "questions": [',
|
||||
' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }',
|
||||
" ] }",
|
||||
" ]",
|
||||
"}",
|
||||
"For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.",
|
||||
'content.subQuestions item schema: { "id": "1", "text": "lǎn duò( )", "answer": "懒惰", "score": 1 }',
|
||||
"If you do not need sections, return { \"questions\": [] } or include real question items.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
|
||||
export const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [
|
||||
"You are a question rewriting engine.",
|
||||
"Rewrite exactly one question based on teacher instruction.",
|
||||
"Return JSON only without markdown.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 1,',
|
||||
' "score": 5,',
|
||||
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
|
||||
"}",
|
||||
"For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
export const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [
|
||||
"You are an exam splitter engine.",
|
||||
"Split the provided exam text into ordered question units quickly.",
|
||||
"Do not deeply analyze choices or answers in this step.",
|
||||
"Keep original sectioning and question order.",
|
||||
"If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.",
|
||||
"Do not split one parent question into several child-only units.",
|
||||
"Output JSON only.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "title": "Optional title",',
|
||||
' "sections": [',
|
||||
' { "title": "Section Title", "questions": [',
|
||||
' { "text": "Original full question text", "score": 5 }',
|
||||
" ] }",
|
||||
" ]",
|
||||
"}",
|
||||
"If no sections, return:",
|
||||
'{ "questions": [ { "text": "Original full question text", "score": 5 } ] }',
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
export const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [
|
||||
"You are an exam text validator.",
|
||||
"Judge whether the input text is readable and likely a normal exam/question text.",
|
||||
"Reject garbled text, random symbols, severely disordered fragments, or meaningless content.",
|
||||
"Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
'{ "valid": true, "reason": "short reason" }',
|
||||
].join("\n")
|
||||
|
||||
export const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
|
||||
"You are an exam question detail parser.",
|
||||
"Given one split question text, output one structured question JSON only.",
|
||||
"Allowed question types: single_choice, multiple_choice, judgment, text.",
|
||||
"For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.",
|
||||
"Use exact key name content.subQuestions (camelCase).",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 1,',
|
||||
' "score": 5,',
|
||||
' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }',
|
||||
"}",
|
||||
"For judgment/text, options can be omitted.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
export { AiSourceValidationSchema }
|
||||
305
src/modules/exams/ai-pipeline/request.ts
Normal file
305
src/modules/exams/ai-pipeline/request.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* AI 请求构造
|
||||
*
|
||||
* 职责:
|
||||
* - 构造 AI 聊天消息
|
||||
* - 发送 AI 请求(试卷解析、结构拆分、题目详情、源文本校验、题目重写)
|
||||
* - 依赖 parse.ts 中的 schema、parseAiResponse 与 buildQuestionContent
|
||||
*/
|
||||
|
||||
import { z } from "zod"
|
||||
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
|
||||
import { env } from "@/env.mjs"
|
||||
|
||||
import {
|
||||
AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT,
|
||||
AI_EXAM_STRUCTURE_SYSTEM_PROMPT,
|
||||
AI_EXAM_SYSTEM_PROMPT,
|
||||
AI_QUESTION_DETAIL_SYSTEM_PROMPT,
|
||||
AI_REWRITE_QUESTION_SYSTEM_PROMPT,
|
||||
AiExamResponseSchema,
|
||||
AiQuestionSchema,
|
||||
AiSourceValidationSchema,
|
||||
AiStructureResponseSchema,
|
||||
buildQuestionContent,
|
||||
parseAiResponse,
|
||||
} from "./parse"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AiChatMessage = { role: "system" | "user"; content: string }
|
||||
|
||||
type AiDraftResult =
|
||||
| { ok: true; data: z.infer<typeof AiExamResponseSchema>; rawOutput: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
type AiStructureDraftResult =
|
||||
| { ok: true; data: z.infer<typeof AiStructureResponseSchema>; rawOutput: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
export type SplitQuestionItem = {
|
||||
sectionIndex: number | null
|
||||
sectionTitle?: string
|
||||
text: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buildAiMessages = (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
}): AiChatMessage[] => {
|
||||
const userLines = [
|
||||
input.title ? `Title: ${input.title}` : "",
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "",
|
||||
typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "",
|
||||
typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "",
|
||||
input.questionCount ? `Question Count: ${input.questionCount}` : "",
|
||||
`Source Exam Text:\n${input.sourceText}`,
|
||||
]
|
||||
const userContent = userLines.filter((l) => l.length > 0).join("\n")
|
||||
return [
|
||||
{ role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI request functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const requestAiExamDraft = async (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}): Promise<AiDraftResult> => {
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: buildAiMessages(input),
|
||||
temperature: 0.7,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
const rawOutput = aiResult.content
|
||||
const data = await parseAiResponse(rawOutput, input.aiProviderId)
|
||||
const validated = AiExamResponseSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { ok: false, message: "AI response format invalid" }
|
||||
}
|
||||
return { ok: true, data: validated.data, rawOutput }
|
||||
} catch (error) {
|
||||
return { ok: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const requestAiExamStructureDraft = async (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty?: number
|
||||
totalScore?: number
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
aiProviderId?: string
|
||||
}): Promise<AiStructureDraftResult> => {
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: buildAiMessages(input)[1].content },
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxTokens: 4000,
|
||||
})
|
||||
const rawOutput = aiResult.content
|
||||
const data = await parseAiResponse(rawOutput, input.aiProviderId)
|
||||
const validated = AiStructureResponseSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { ok: false, message: "AI response format invalid" }
|
||||
}
|
||||
return { ok: true, data: validated.data, rawOutput }
|
||||
} catch (error) {
|
||||
return { ok: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
|
||||
const text = input.sourceText.trim()
|
||||
if (!text) {
|
||||
return { ok: false as const, message: "请先粘贴试卷文本" }
|
||||
}
|
||||
const userContent = [
|
||||
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
|
||||
`文本内容:\n${text}`,
|
||||
].join("\n\n")
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0,
|
||||
maxTokens: 300,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const validated = AiSourceValidationSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return { ok: false as const, message: "试卷文本校验失败,请重试" }
|
||||
}
|
||||
if (!validated.data.valid) {
|
||||
return {
|
||||
ok: false as const,
|
||||
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
|
||||
}
|
||||
}
|
||||
return { ok: true as const }
|
||||
} catch (error) {
|
||||
return { ok: false as const, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const parseQuestionDetail = async (input: {
|
||||
item: SplitQuestionItem
|
||||
subject?: string
|
||||
grade?: string
|
||||
difficulty: number
|
||||
aiProviderId?: string
|
||||
}): Promise<z.infer<typeof AiQuestionSchema>> => {
|
||||
const normalizeQuestionCandidate = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== "object") return value
|
||||
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
|
||||
const record = value as Record<string, unknown>
|
||||
const contentRaw = record.content
|
||||
if (!contentRaw || typeof contentRaw !== "object") return value
|
||||
const content = contentRaw as Record<string, unknown>
|
||||
const normalizedSubQuestions = Array.isArray(content.subQuestions)
|
||||
? content.subQuestions
|
||||
: Array.isArray(content.subquestions)
|
||||
? content.subquestions
|
||||
: Array.isArray(content.sub_questions)
|
||||
? content.sub_questions
|
||||
: undefined
|
||||
if (!normalizedSubQuestions) return value
|
||||
return {
|
||||
...record,
|
||||
content: {
|
||||
...content,
|
||||
subQuestions: normalizedSubQuestions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const userContent = [
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
`Question Text:\n${input.item.text}`,
|
||||
].filter((line) => line.length > 0).join("\n\n")
|
||||
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0.4,
|
||||
maxTokens: 1200,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const candidate = parsed && typeof parsed === "object" && "question" in parsed
|
||||
? (parsed as { question: unknown }).question
|
||||
: parsed
|
||||
const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate))
|
||||
if (validated.success) {
|
||||
const q = validated.data
|
||||
return {
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? input.difficulty,
|
||||
score: q.score ?? input.item.score ?? 0,
|
||||
content: q.content,
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
difficulty: input.difficulty,
|
||||
score: input.item.score ?? 0,
|
||||
content: { text: input.item.text },
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
|
||||
export const regenerateAiQuestionByInstruction = async (input: {
|
||||
instruction: string
|
||||
originalQuestion: z.infer<typeof AiQuestionSchema>
|
||||
sourceText?: string
|
||||
aiProviderId?: string
|
||||
}) => {
|
||||
const originalDifficulty = input.originalQuestion.difficulty ?? 3
|
||||
const originalScore = input.originalQuestion.score ?? 0
|
||||
const contextLines = [
|
||||
`Instruction:\n${input.instruction}`,
|
||||
`Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
|
||||
input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "",
|
||||
]
|
||||
const userContent = contextLines.filter((line) => line.length > 0).join("\n\n")
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||
providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined,
|
||||
messages: [
|
||||
{ role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT },
|
||||
{ role: "user" as const, content: userContent },
|
||||
],
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
})
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const candidate = parsed && typeof parsed === "object" && "question" in parsed
|
||||
? (parsed as { question: unknown }).question
|
||||
: parsed
|
||||
const validated = AiQuestionSchema.safeParse(candidate)
|
||||
if (!validated.success) {
|
||||
return { ok: false as const, message: "AI question format invalid" }
|
||||
}
|
||||
const question = validated.data
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
type: question.type,
|
||||
difficulty: question.difficulty ?? originalDifficulty,
|
||||
score: question.score ?? originalScore,
|
||||
content: buildQuestionContent(question),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false as const, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
203
src/modules/exams/ai-pipeline/structure.ts
Normal file
203
src/modules/exams/ai-pipeline/structure.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 结构生成
|
||||
*
|
||||
* 职责:
|
||||
* - 将 AI 解析结果转换为预览数据(buildPreviewPayload)
|
||||
* - 将预览数据转换为持久化草稿(previewToDraft)
|
||||
* - 拆分结构条目(splitStructureItems)
|
||||
* - 并发映射工具(mapWithConcurrency)
|
||||
* - 依赖 parse.ts 中的类型与纯函数
|
||||
*/
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
AiExamResponseSchema,
|
||||
AiQuestionSchema,
|
||||
AiStructureResponseSchema,
|
||||
buildQuestionContent,
|
||||
normalizeScores,
|
||||
type AiGeneratedQuestion,
|
||||
type AiGeneratedStructureNode,
|
||||
type AiPreviewData,
|
||||
type AiPreviewQuestion,
|
||||
} from "./parse"
|
||||
import type { SplitQuestionItem } from "./request"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const splitStructureItems = (
|
||||
draft: z.infer<typeof AiStructureResponseSchema>
|
||||
): SplitQuestionItem[] => {
|
||||
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
|
||||
if (!hasSections) {
|
||||
return (draft.questions ?? []).map((q) => ({
|
||||
sectionIndex: null,
|
||||
sectionTitle: undefined,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
} satisfies SplitQuestionItem))
|
||||
}
|
||||
const rows: SplitQuestionItem[] = []
|
||||
const sections = draft.sections
|
||||
if (sections) {
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
section.questions.forEach((q) => {
|
||||
rows.push({
|
||||
sectionIndex,
|
||||
sectionTitle: section.title,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const results = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
const runWorker = async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview payload building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const buildPreviewPayload = (
|
||||
aiParsed: z.infer<typeof AiExamResponseSchema>,
|
||||
input: {
|
||||
title: string
|
||||
difficulty: number
|
||||
totalScore: number
|
||||
questionCount?: number
|
||||
}
|
||||
): AiPreviewData => {
|
||||
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
|
||||
const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? []
|
||||
const limit = input.questionCount
|
||||
let sections = aiParsed.sections
|
||||
let flatQuestions = baseQuestions
|
||||
|
||||
if (typeof limit === "number" && limit > 0) {
|
||||
if (hasSections) {
|
||||
const parsedSections = aiParsed.sections
|
||||
let remaining = limit
|
||||
sections = (parsedSections ?? []).map((s) => {
|
||||
if (remaining <= 0) return { ...s, questions: [] }
|
||||
const sliced = s.questions.slice(0, remaining)
|
||||
remaining -= sliced.length
|
||||
return { ...s, questions: sliced }
|
||||
}).filter((s) => s.questions.length > 0)
|
||||
flatQuestions = sections.flatMap((s) => s.questions)
|
||||
} else {
|
||||
flatQuestions = baseQuestions.slice(0, limit)
|
||||
}
|
||||
}
|
||||
|
||||
const scores = normalizeScores(
|
||||
flatQuestions.map((q) => q.score ?? 0),
|
||||
input.totalScore
|
||||
)
|
||||
|
||||
let scoreIndex = 0
|
||||
const toPreviewQuestion = (q: z.infer<typeof AiQuestionSchema>): AiPreviewQuestion => ({
|
||||
id: createId(),
|
||||
type: q.type,
|
||||
difficulty: q.difficulty ?? input.difficulty,
|
||||
score: scores[scoreIndex++] ?? 0,
|
||||
content: buildQuestionContent(q),
|
||||
})
|
||||
|
||||
if (hasSections && sections && sections.length > 0) {
|
||||
return {
|
||||
title: aiParsed.title ?? input.title,
|
||||
sections: sections.map((section) => ({
|
||||
id: createId(),
|
||||
title: section.title,
|
||||
questions: section.questions.map((q) => toPreviewQuestion(q)),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: aiParsed.title ?? input.title,
|
||||
questions: flatQuestions.map((q) => toPreviewQuestion(q)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview → Draft conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const previewToDraft = (preview: AiPreviewData): {
|
||||
generated: AiGeneratedQuestion[]
|
||||
structure: AiGeneratedStructureNode[]
|
||||
} => {
|
||||
const generated: AiGeneratedQuestion[] = []
|
||||
const structure: AiGeneratedStructureNode[] = []
|
||||
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
|
||||
for (const section of preview.sections) {
|
||||
const children: AiGeneratedStructureNode[] = []
|
||||
for (const question of section.questions) {
|
||||
generated.push({
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
difficulty: question.difficulty,
|
||||
score: question.score,
|
||||
content: question.content,
|
||||
})
|
||||
children.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: question.score,
|
||||
})
|
||||
}
|
||||
structure.push({
|
||||
id: section.id || createId(),
|
||||
type: "group",
|
||||
title: section.title,
|
||||
children,
|
||||
})
|
||||
}
|
||||
return { generated, structure }
|
||||
}
|
||||
for (const question of preview.questions ?? []) {
|
||||
generated.push({
|
||||
id: question.id,
|
||||
type: question.type,
|
||||
difficulty: question.difficulty,
|
||||
score: question.score,
|
||||
content: question.content,
|
||||
})
|
||||
structure.push({
|
||||
id: createId(),
|
||||
type: "question",
|
||||
questionId: question.id,
|
||||
score: question.score,
|
||||
})
|
||||
}
|
||||
return { generated, structure }
|
||||
}
|
||||
@@ -36,7 +36,27 @@ import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreview
|
||||
import { Exam } from "../types"
|
||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
|
||||
// Raw structure node shape returned from the DB before hydration
|
||||
type RawStructureNode = {
|
||||
id?: string
|
||||
type?: string
|
||||
questionId?: string
|
||||
score?: number
|
||||
title?: string
|
||||
children?: RawStructureNode[]
|
||||
}
|
||||
|
||||
// Type guard to narrow unknown structure payload to raw nodes
|
||||
const isRawStructureNode = (v: unknown): v is RawStructureNode => {
|
||||
if (typeof v !== "object" || v === null) return false
|
||||
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
|
||||
const obj = v as Record<string, unknown>
|
||||
return typeof obj.type === "string"
|
||||
}
|
||||
|
||||
const isRawStructureArray = (v: unknown): v is RawStructureNode[] =>
|
||||
Array.isArray(v) && v.every((item) => isRawStructureNode(item))
|
||||
|
||||
interface ExamActionsProps {
|
||||
exam: Exam
|
||||
@@ -57,25 +77,39 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
try {
|
||||
const result = await getExamPreviewAction(exam.id)
|
||||
if (result.success && result.data) {
|
||||
const { structure, questions } = result.data
|
||||
const questionById = new Map<string, Question>()
|
||||
for (const q of questions) questionById.set(q.id, q as unknown as Question)
|
||||
const { structure } = result.data
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hydrate = (nodes: any[]): ExamNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "question") {
|
||||
const q = node.questionId ? questionById.get(node.questionId) : undefined
|
||||
return { ...node, question: q }
|
||||
}
|
||||
if (node.type === "group") {
|
||||
return { ...node, children: hydrate(node.children || []) }
|
||||
}
|
||||
return node
|
||||
})
|
||||
const hydrate = (nodes: RawStructureNode[]): ExamNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.type === "question") {
|
||||
return {
|
||||
id: node.id ?? node.questionId ?? "",
|
||||
type: "question" as const,
|
||||
questionId: node.questionId,
|
||||
score: node.score,
|
||||
// Question content is not available in preview payload; left undefined
|
||||
}
|
||||
}
|
||||
if (node.type === "group") {
|
||||
return {
|
||||
id: node.id ?? "",
|
||||
type: "group" as const,
|
||||
title: node.title,
|
||||
score: node.score,
|
||||
children: hydrate(node.children ?? []),
|
||||
}
|
||||
}
|
||||
// Unknown node type: treat as group with no children to avoid runtime crash
|
||||
return {
|
||||
id: node.id ?? "",
|
||||
type: "group" as const,
|
||||
title: node.title,
|
||||
children: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const nodes = Array.isArray(structure) ? hydrate(structure) : []
|
||||
|
||||
const nodes = isRawStructureArray(structure) ? hydrate(structure) : []
|
||||
setPreviewNodes(nodes)
|
||||
} else {
|
||||
toast.error(t("exam.actions.previewFailed"))
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
import type { AiProviderSummary } from "@/modules/settings/actions"
|
||||
import { formatDateTime } from "@/shared/lib/utils"
|
||||
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
|
||||
import { aiProviderLabels } from "./exam-form-types"
|
||||
|
||||
@@ -78,12 +79,7 @@ export function ExamAiGenerator({
|
||||
runningPreviewTaskCount,
|
||||
queuedPreviewTaskCount,
|
||||
}: ExamAiGeneratorProps) {
|
||||
const formatTaskTime = (value: number) => new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
const formatTaskTime = (value: number) => formatDateTime(new Date(value))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { Control } from "react-hook-form"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -24,6 +8,8 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { TextField } from "@/shared/components/form-fields/text-field"
|
||||
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||
import type { ExamFormValues } from "./exam-form-types"
|
||||
|
||||
type ExamBasicInfoFormProps = {
|
||||
@@ -34,6 +20,14 @@ type ExamBasicInfoFormProps = {
|
||||
loadingGrades: boolean
|
||||
}
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "1", label: "Level 1 (Easy)" },
|
||||
{ value: "2", label: "Level 2" },
|
||||
{ value: "3", label: "Level 3 (Medium)" },
|
||||
{ value: "4", label: "Level 4" },
|
||||
{ value: "5", label: "Level 5 (Hard)" },
|
||||
]
|
||||
|
||||
export function ExamBasicInfoForm({
|
||||
control,
|
||||
subjects,
|
||||
@@ -50,139 +44,60 @@ export function ExamBasicInfoForm({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<FormField
|
||||
<TextField
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Midterm Mathematics Exam" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Title"
|
||||
placeholder="e.g. Midterm Mathematics Exam"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
<SelectField
|
||||
control={control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Subject</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingSubjects}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{subjects.map((subject) => (
|
||||
<SelectItem key={subject.id} value={subject.id}>
|
||||
{subject.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Subject"
|
||||
placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
disabled={loadingSubjects}
|
||||
/>
|
||||
<FormField
|
||||
<SelectField
|
||||
control={control}
|
||||
name="grade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Grade Level</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingGrades}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{grades.map((grade) => (
|
||||
<SelectItem key={grade.id} value={grade.id}>
|
||||
{grade.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Grade Level"
|
||||
placeholder={loadingGrades ? "Loading grades..." : "Select grade level"}
|
||||
options={grades.map((g) => ({ value: g.id, label: g.name }))}
|
||||
disabled={loadingGrades}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
<SelectField
|
||||
control={control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select level" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Level 1 (Easy)</SelectItem>
|
||||
<SelectItem value="2">Level 2</SelectItem>
|
||||
<SelectItem value="3">Level 3 (Medium)</SelectItem>
|
||||
<SelectItem value="4">Level 4</SelectItem>
|
||||
<SelectItem value="5">Level 5 (Hard)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Difficulty"
|
||||
placeholder="Select level"
|
||||
options={DIFFICULTY_OPTIONS}
|
||||
/>
|
||||
<FormField
|
||||
<TextField
|
||||
control={control}
|
||||
name="totalScore"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Score</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Total Score"
|
||||
type="number"
|
||||
/>
|
||||
<FormField
|
||||
<TextField
|
||||
control={control}
|
||||
name="durationMin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (min)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Duration (min)"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
<TextField
|
||||
control={control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule Start Time (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If set, this exam will be scheduled for a specific time.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
label="Schedule Start Time (Optional)"
|
||||
type="datetime-local"
|
||||
description="If set, this exam will be scheduled for a specific time."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -108,7 +108,11 @@ export function createExamColumns(t: TranslationFn): ColumnDef<Exam>[] {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-0.5">
|
||||
<div
|
||||
className="flex gap-0.5"
|
||||
role="img"
|
||||
aria-label={t("exam.difficulty.ariaLabel", { level: diff, label: t(`exam.difficulty.${diff}`) })}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
|
||||
@@ -15,7 +15,30 @@ export const formSchema = z.object({
|
||||
aiSourceText: z.string().optional(),
|
||||
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
|
||||
aiProviderId: z.string().optional(),
|
||||
// Exam mode + proctoring config
|
||||
examMode: z.enum(["homework", "timed", "proctored"]).default("homework"),
|
||||
durationMinutes: z.coerce.number().int().min(1).nullable().optional(),
|
||||
shuffleQuestions: z.boolean().default(false),
|
||||
allowLateStart: z.boolean().default(false),
|
||||
lateStartGraceMinutes: z.coerce.number().int().min(0).default(0),
|
||||
antiCheatEnabled: z.boolean().default(false),
|
||||
}).superRefine((data, ctx) => {
|
||||
// 监考模式必须设置考试时长
|
||||
if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["durationMinutes"],
|
||||
message: "Duration is required in proctored mode.",
|
||||
})
|
||||
}
|
||||
// 限时/监考模式必须设置考试时长
|
||||
if (data.examMode === "timed" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["durationMinutes"],
|
||||
message: "Duration is required in timed mode.",
|
||||
})
|
||||
}
|
||||
if (data.mode === "ai") {
|
||||
if (!data.aiSourceText?.trim()) {
|
||||
ctx.addIssue({
|
||||
@@ -128,6 +151,12 @@ export const defaultValues: Partial<ExamFormValues> = {
|
||||
aiSourceText: "",
|
||||
aiQuestionCount: undefined,
|
||||
aiProviderId: "",
|
||||
examMode: "homework",
|
||||
durationMinutes: null,
|
||||
shuffleQuestions: false,
|
||||
allowLateStart: false,
|
||||
lateStartGraceMinutes: 0,
|
||||
antiCheatEnabled: false,
|
||||
}
|
||||
|
||||
export const previewTaskStorageKey = "exam-preview-background-tasks:v1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTransition, useEffect, useState, type FormEvent } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useForm, type Resolver } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ExamBasicInfoForm } from "./exam-basic-info-form"
|
||||
import { ExamAiGenerator } from "./exam-ai-generator"
|
||||
import { ExamPreviewDialog } from "./exam-preview-dialog"
|
||||
import { ExamModeSelector } from "./exam-mode-selector"
|
||||
import { ExamModeConfig } from "@/modules/proctoring/components/exam-mode-config"
|
||||
|
||||
// Re-export formSchema for backward compatibility
|
||||
export { formSchema } from "./exam-form-types"
|
||||
@@ -33,10 +34,11 @@ export function ExamForm() {
|
||||
const [aiProviders, setAiProviders] = useState<AiProviderSummary[]>([])
|
||||
const [loadingAiProviders, setLoadingAiProviders] = useState(true)
|
||||
|
||||
// zodResolver 与 useForm 在含 superRefine + coerce + default 时的输入/输出类型存在协变差异,
|
||||
// 使用 Resolver<ExamFormValues> 显式标注以替代 as any(从 zodResolver 返回类型收窄)
|
||||
const form = useForm<ExamFormValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: defaultValues as unknown as ExamFormValues,
|
||||
resolver: zodResolver(formSchema) as Resolver<ExamFormValues>,
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
const preview = useExamPreview(form)
|
||||
@@ -124,6 +126,15 @@ export function ExamForm() {
|
||||
formData.append("difficulty", Number.isFinite(resolvedDifficulty) && resolvedDifficulty >= 1 && resolvedDifficulty <= 5 ? String(resolvedDifficulty) : "3")
|
||||
formData.append("totalScore", String(resolvedTotalScore))
|
||||
formData.append("durationMin", String(resolvedDurationMin))
|
||||
// P0-3: append exam mode + proctoring config fields
|
||||
formData.append("examMode", data.examMode ?? "homework")
|
||||
if (data.durationMinutes !== null && data.durationMinutes !== undefined) {
|
||||
formData.append("durationMinutes", String(data.durationMinutes))
|
||||
}
|
||||
formData.append("shuffleQuestions", String(data.shuffleQuestions ?? false))
|
||||
formData.append("allowLateStart", String(data.allowLateStart ?? false))
|
||||
formData.append("lateStartGraceMinutes", String(data.lateStartGraceMinutes ?? 0))
|
||||
formData.append("antiCheatEnabled", String(data.antiCheatEnabled ?? false))
|
||||
if (data.mode === "manual" && data.scheduledAt) {
|
||||
formData.append("scheduledAt", data.scheduledAt)
|
||||
}
|
||||
@@ -159,13 +170,11 @@ export function ExamForm() {
|
||||
preview.handleBackgroundPreview()
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.handleSubmit(onSubmit as any)()
|
||||
form.handleSubmit(onSubmit)()
|
||||
}
|
||||
|
||||
const handleConfirmCreate = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form.handleSubmit(onSubmit as any)()
|
||||
form.handleSubmit(onSubmit)()
|
||||
}
|
||||
|
||||
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -224,6 +233,7 @@ export function ExamForm() {
|
||||
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
||||
/>
|
||||
)}
|
||||
<ExamModeConfig<ExamFormValues> control={form.control} />
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -238,6 +238,15 @@ export const buildExamDescription = (input: {
|
||||
questionCount: input.questionCount,
|
||||
})
|
||||
|
||||
export type ExamModeConfig = {
|
||||
examMode: "homework" | "timed" | "proctored"
|
||||
durationMinutes: number | null
|
||||
shuffleQuestions: boolean
|
||||
allowLateStart: boolean
|
||||
lateStartGraceMinutes: number
|
||||
antiCheatEnabled: boolean
|
||||
}
|
||||
|
||||
export const persistExamDraft = async (input: {
|
||||
examId: string
|
||||
title: string
|
||||
@@ -246,6 +255,7 @@ export const persistExamDraft = async (input: {
|
||||
gradeId: string
|
||||
scheduledAt?: string
|
||||
description: string
|
||||
examModeConfig?: ExamModeConfig
|
||||
}) => {
|
||||
await db.insert(exams).values({
|
||||
id: input.examId,
|
||||
@@ -256,6 +266,12 @@ export const persistExamDraft = async (input: {
|
||||
gradeId: input.gradeId,
|
||||
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
||||
status: "draft",
|
||||
examMode: input.examModeConfig?.examMode ?? "homework",
|
||||
durationMinutes: input.examModeConfig?.durationMinutes ?? null,
|
||||
shuffleQuestions: input.examModeConfig?.shuffleQuestions ?? false,
|
||||
allowLateStart: input.examModeConfig?.allowLateStart ?? false,
|
||||
lateStartGraceMinutes: input.examModeConfig?.lateStartGraceMinutes ?? 0,
|
||||
antiCheatEnabled: input.examModeConfig?.antiCheatEnabled ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,6 +310,7 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
description: string
|
||||
structure: AiGeneratedStructureNode[]
|
||||
generated: AiGeneratedQuestion[]
|
||||
examModeConfig?: ExamModeConfig
|
||||
}): Promise<void> => {
|
||||
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
|
||||
|
||||
@@ -330,6 +347,12 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
||||
status: "draft",
|
||||
structure: input.structure,
|
||||
examMode: input.examModeConfig?.examMode ?? "homework",
|
||||
durationMinutes: input.examModeConfig?.durationMinutes ?? null,
|
||||
shuffleQuestions: input.examModeConfig?.shuffleQuestions ?? false,
|
||||
allowLateStart: input.examModeConfig?.allowLateStart ?? false,
|
||||
lateStartGraceMinutes: input.examModeConfig?.lateStartGraceMinutes ?? 0,
|
||||
antiCheatEnabled: input.examModeConfig?.antiCheatEnabled ?? false,
|
||||
})
|
||||
|
||||
if (remappedOrderedQuestions.length > 0) {
|
||||
|
||||
50
src/modules/homework/components/assignment-filters.tsx
Normal file
50
src/modules/homework/components/assignment-filters.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function AssignmentFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||
|
||||
const hasFilters = Boolean(search || status !== "all")
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
layout="between"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
}}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search assignments..."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="submitted">Submitted</SelectItem>
|
||||
<SelectItem value="graded">Graded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
@@ -26,8 +26,15 @@ import { Separator } from "@/shared/components/ui/separator"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { gradeHomeworkSubmissionAction } from "../actions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import {
|
||||
applyAutoGrades as applyAutoGradesUtil,
|
||||
extractAnswerValue,
|
||||
getCorrectnessState as getCorrectnessStateUtil,
|
||||
getOptions,
|
||||
getTextCorrectAnswers,
|
||||
isAutoGradable as isAutoGradableUtil,
|
||||
} from "../lib/question-content-utils"
|
||||
|
||||
type QuestionContent = { text?: string } & Record<string, unknown>
|
||||
|
||||
@@ -154,180 +161,186 @@ export function HomeworkGradingView({
|
||||
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||
{answers.map((ans, index) => (
|
||||
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${
|
||||
ans.score === ans.maxScore ? "border-l-4 border-l-emerald-500" :
|
||||
ans.score === 0 && ans.maxScore > 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted"
|
||||
}`}>
|
||||
<CardHeader className="bg-card pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-6 w-6 shrink-0 justify-center rounded-full p-0">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{ans.questionType.replace("_", " ")}
|
||||
</span>
|
||||
{isAutoGradable(ans) && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
||||
{answers.map((ans, index) => {
|
||||
const correctness = getCorrectnessState(ans)
|
||||
const borderClass =
|
||||
correctness === "correct"
|
||||
? "border-l-4 border-l-emerald-500"
|
||||
: correctness === "incorrect"
|
||||
? "border-l-4 border-l-red-500"
|
||||
: "border-l-4 border-l-muted"
|
||||
return (
|
||||
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${borderClass}`}>
|
||||
<CardHeader className="bg-card pb-4">
|
||||
<QuestionRenderer
|
||||
questionId={ans.id}
|
||||
questionType={ans.questionType}
|
||||
questionContent={ans.questionContent}
|
||||
maxScore={ans.maxScore}
|
||||
index={index}
|
||||
mode="grade"
|
||||
value={extractAnswerValue(ans.studentAnswer)}
|
||||
showCorrectAnswer={true}
|
||||
headerExtra={
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className="whitespace-nowrap">
|
||||
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
|
||||
{ans.score ?? 0} / {ans.maxScore} pts
|
||||
</Badge>
|
||||
{isAutoGradable(ans) && (
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||
{/* Student Answer Display */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
|
||||
</Label>
|
||||
|
||||
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||
Array.isArray(ans.questionContent?.options) ? (
|
||||
<div className="space-y-2">
|
||||
{getOptions(ans.questionContent).map((opt) => {
|
||||
const answerValue = extractAnswerValue(ans.studentAnswer)
|
||||
const isSelected = Array.isArray(answerValue)
|
||||
? answerValue.filter((x): x is string => typeof x === "string").includes(opt.id)
|
||||
: typeof answerValue === "string" && answerValue === opt.id
|
||||
|
||||
const isCorrect = opt.isCorrect === true
|
||||
|
||||
let containerClass = "border-transparent hover:bg-muted/50"
|
||||
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
|
||||
|
||||
if (isSelected) {
|
||||
if (isCorrect) {
|
||||
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
|
||||
} else {
|
||||
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
|
||||
indicatorClass = "border-red-500 bg-red-500 text-white"
|
||||
}
|
||||
} else if (isCorrect) {
|
||||
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
|
||||
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.id}
|
||||
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||
>
|
||||
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||
{opt.id}
|
||||
</div>
|
||||
<span className="flex-1">{opt.text}</span>
|
||||
<span className="sr-only">
|
||||
{isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""}
|
||||
</span>
|
||||
{isCorrect && <Check className="h-4 w-4 text-emerald-600" aria-hidden="true" />}
|
||||
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" aria-hidden="true" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{formatStudentAnswer(ans.studentAnswer)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base font-medium leading-relaxed pt-2">
|
||||
{ans.questionContent?.text || t("homework.grade.noQuestionText")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className="whitespace-nowrap">
|
||||
{ans.score ?? 0} / {ans.maxScore} pts
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||
{/* Student Answer Display */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
|
||||
</Label>
|
||||
|
||||
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||
Array.isArray(ans.questionContent?.options) ? (
|
||||
<div className="space-y-2">
|
||||
{(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => {
|
||||
const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer))
|
||||
? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string)
|
||||
: extractAnswerValue(ans.studentAnswer) === opt.id
|
||||
|
||||
const isCorrect = opt.isCorrect === true
|
||||
|
||||
// Visual logic:
|
||||
// If selected and correct -> Green + Check
|
||||
// If selected and wrong -> Red + X
|
||||
// If not selected but correct -> Green outline (show missed correct answer)
|
||||
|
||||
let containerClass = "border-transparent hover:bg-muted/50"
|
||||
let indicatorClass = "border-muted-foreground/30 text-muted-foreground"
|
||||
|
||||
if (isSelected) {
|
||||
if (isCorrect) {
|
||||
containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20"
|
||||
indicatorClass = "border-emerald-500 bg-emerald-500 text-white"
|
||||
} else {
|
||||
containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20"
|
||||
indicatorClass = "border-red-500 bg-red-500 text-white"
|
||||
}
|
||||
} else if (isCorrect) {
|
||||
containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10"
|
||||
indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={opt.id as string}
|
||||
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||
>
|
||||
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||
{opt.id as string}
|
||||
</div>
|
||||
<span className="flex-1">{opt.text}</span>
|
||||
{isCorrect && <Check className="h-4 w-4 text-emerald-600" />}
|
||||
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{formatStudentAnswer(ans.studentAnswer)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference Answer (for text/non-choice questions) */}
|
||||
{ans.questionType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
|
||||
</Label>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||
{/* Grading Controls */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={getCorrectnessState(ans) === "correct" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
className="w-20 h-8"
|
||||
value={ans.score ?? ""}
|
||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||
</div>
|
||||
{/* Reference Answer (for text/non-choice questions) */}
|
||||
{ans.questionType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
|
||||
</Label>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Feedback Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
|
||||
</Button>
|
||||
</div>
|
||||
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||
{/* Grading Controls */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={correctness === "correct" ? "default" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={correctness === "correct"}
|
||||
className={correctness === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||
onClick={() => handleMarkCorrect(ans.id)}
|
||||
>
|
||||
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={correctness === "incorrect" ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={correctness === "incorrect"}
|
||||
className={correctness === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||
onClick={() => handleMarkIncorrect(ans.id)}
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feedback Textarea */}
|
||||
{showFeedbackByAnswerId[ans.id] && (
|
||||
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Textarea
|
||||
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
className="min-h-[80px] bg-background"
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
|
||||
<Input
|
||||
id={`score-${ans.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ans.maxScore}
|
||||
className="w-20 h-8"
|
||||
value={ans.score ?? ""}
|
||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={Boolean(showFeedbackByAnswerId[ans.id])}
|
||||
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Feedback Textarea */}
|
||||
{showFeedbackByAnswerId[ans.id] && (
|
||||
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Textarea
|
||||
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
|
||||
value={ans.feedback ?? ""}
|
||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
||||
className="min-h-[80px] bg-background"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -482,108 +495,22 @@ export function HomeworkGradingView({
|
||||
)
|
||||
}
|
||||
|
||||
type ChoiceOption = { id?: unknown; isCorrect?: unknown; text?: string }
|
||||
// Delegate to shared pure functions in lib/question-content-utils
|
||||
// (kept here only as thin wrappers to preserve existing call sites)
|
||||
|
||||
const normalizeText = (v: string) => v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
const extractAnswerValue = (studentAnswer: unknown): unknown => {
|
||||
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
|
||||
return studentAnswer
|
||||
}
|
||||
|
||||
const getChoiceCorrectIds = (content: QuestionContent | null): string[] => {
|
||||
if (!content) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const ids: string[] = []
|
||||
for (const item of raw) {
|
||||
const opt = item as ChoiceOption
|
||||
const id = typeof opt.id === "string" ? opt.id : null
|
||||
const isCorrect = opt.isCorrect === true
|
||||
if (id && isCorrect) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const getTextCorrectAnswers = (content: QuestionContent | null): string[] => {
|
||||
if (!content) return []
|
||||
const raw = content.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
}
|
||||
|
||||
const getJudgmentCorrectAnswer = (content: QuestionContent | null): boolean | null => {
|
||||
if (!content) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
const isAutoGradable = (ans: Answer): boolean => {
|
||||
if (ans.questionType === "single_choice" || ans.questionType === "multiple_choice") return getChoiceCorrectIds(ans.questionContent).length > 0
|
||||
if (ans.questionType === "judgment") return getJudgmentCorrectAnswer(ans.questionContent) !== null
|
||||
if (ans.questionType === "text") return getTextCorrectAnswers(ans.questionContent).length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
const computeIsCorrect = (ans: Answer): boolean | null => {
|
||||
const studentVal = extractAnswerValue(ans.studentAnswer)
|
||||
|
||||
if (ans.questionType === "single_choice") {
|
||||
const correct = getChoiceCorrectIds(ans.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
return correct.includes(studentVal)
|
||||
}
|
||||
|
||||
if (ans.questionType === "multiple_choice") {
|
||||
const correct = getChoiceCorrectIds(ans.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
const studentArr = Array.isArray(studentVal) ? studentVal.filter((x): x is string => typeof x === "string") : []
|
||||
const correctSet = new Set(correct)
|
||||
const studentSet = new Set(studentArr)
|
||||
if (studentSet.size !== correctSet.size) return false
|
||||
for (const id of correctSet) {
|
||||
if (!studentSet.has(id)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (ans.questionType === "judgment") {
|
||||
const correct = getJudgmentCorrectAnswer(ans.questionContent)
|
||||
if (correct === null) return null
|
||||
if (typeof studentVal !== "boolean") return false
|
||||
return studentVal === correct
|
||||
}
|
||||
|
||||
if (ans.questionType === "text") {
|
||||
const correctAnswers = getTextCorrectAnswers(ans.questionContent)
|
||||
if (correctAnswers.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
const normalizedStudent = normalizeText(studentVal)
|
||||
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const applyAutoGrades = (incoming: Answer[]): Answer[] => {
|
||||
return incoming.map((a) => {
|
||||
if (a.score !== null) return a
|
||||
if (!isAutoGradable(a)) return a
|
||||
const isCorrect = computeIsCorrect(a)
|
||||
if (isCorrect === null) return a
|
||||
return { ...a, score: isCorrect ? a.maxScore : 0 }
|
||||
const isAutoGradable = (ans: Answer): boolean =>
|
||||
isAutoGradableUtil({
|
||||
questionType: ans.questionType,
|
||||
questionContent: ans.questionContent,
|
||||
})
|
||||
}
|
||||
|
||||
const applyAutoGrades = (incoming: Answer[]): Answer[] =>
|
||||
applyAutoGradesUtil(incoming)
|
||||
|
||||
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
|
||||
|
||||
const getCorrectnessState = (ans: Answer): CorrectnessState => {
|
||||
if (ans.score === null) return "ungraded"
|
||||
if (ans.score === ans.maxScore) return "correct"
|
||||
if (ans.score === 0) return "incorrect"
|
||||
return "partial"
|
||||
}
|
||||
const getCorrectnessState = (ans: Answer): CorrectnessState =>
|
||||
getCorrectnessStateUtil({ score: ans.score, maxScore: ans.maxScore })
|
||||
|
||||
const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
const v = extractAnswerValue(studentAnswer)
|
||||
|
||||
@@ -5,14 +5,11 @@ import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Card, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -24,48 +21,14 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react"
|
||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2 } from "lucide-react"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type Option = { id: string; text: string }
|
||||
|
||||
const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
const getOptions = (content: unknown): Option[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: Option[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
out.push({ id, text })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { parseSavedAnswer } from "../lib/question-content-utils"
|
||||
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
||||
|
||||
type HomeworkTakeViewProps = {
|
||||
assignmentId: string
|
||||
@@ -98,6 +61,44 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const canEdit = isStarted && Boolean(submissionId)
|
||||
const showQuestions = submissionStatus !== "not_started"
|
||||
|
||||
// P2-9: 自动保存 + 离线缓存
|
||||
const offlineStorageKey = `homework-draft-${assignmentId}`
|
||||
const autoSave = useDebouncedAutoSave({
|
||||
submissionId,
|
||||
answers: answersByQuestionId,
|
||||
enabled: canEdit,
|
||||
storageKey: offlineStorageKey,
|
||||
})
|
||||
|
||||
// 挂载时尝试从 localStorage 恢复未提交的答案
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
const cached = loadOfflineCache(offlineStorageKey)
|
||||
if (!cached) return
|
||||
setAnswersByQuestionId((prev) => {
|
||||
const merged: Record<string, { answer: unknown }> = { ...prev }
|
||||
let changed = false
|
||||
for (const questionId of Object.keys(cached)) {
|
||||
const cachedEntry = cached[questionId]
|
||||
if (!cachedEntry) continue
|
||||
const prevEntry = prev[questionId]
|
||||
const cachedJson = JSON.stringify(cachedEntry.answer)
|
||||
const prevJson = prevEntry ? JSON.stringify(prevEntry.answer) : ""
|
||||
if (cachedJson !== prevJson) {
|
||||
merged[questionId] = { answer: cachedEntry.answer }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
toast.success(t("homework.take.autoSaveRestored"))
|
||||
}
|
||||
return merged
|
||||
})
|
||||
// 仅恢复一次,恢复后清除缓存(避免重复提示)
|
||||
clearOfflineCache(offlineStorageKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [canEdit])
|
||||
|
||||
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
@@ -155,25 +156,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
const handleSubmit = async () => {
|
||||
if (!submissionId) return
|
||||
setIsBusy(true)
|
||||
// Save all first
|
||||
for (const q of initialData.questions) {
|
||||
const payload = answersByQuestionId[q.questionId]?.answer ?? null
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", q.questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer: payload }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
toast.error(res.message || t("homework.take.saveFailed"))
|
||||
setIsBusy(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
|
||||
await autoSave.flush()
|
||||
|
||||
const submitFd = new FormData()
|
||||
submitFd.set("submissionId", submissionId)
|
||||
const submitRes = await submitHomeworkAction(null, submitFd)
|
||||
if (submitRes.success) {
|
||||
// 提交成功后清除离线缓存
|
||||
clearOfflineCache(offlineStorageKey)
|
||||
toast.success(t("homework.take.submitSuccess"))
|
||||
setSubmissionStatus("submitted")
|
||||
router.push("/student/learning/assignments")
|
||||
@@ -248,155 +239,46 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
)}
|
||||
|
||||
{showQuestions && initialData.questions.map((q, idx) => {
|
||||
const text = getQuestionText(q.questionContent)
|
||||
const options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
{t("homework.take.question", { index: idx + 1 })}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} {t("homework.take.points")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionRenderer
|
||||
questionId={q.questionId}
|
||||
questionType={q.questionType}
|
||||
questionContent={q.questionContent}
|
||||
maxScore={q.maxScore}
|
||||
index={idx}
|
||||
mode={submissionStatus === "graded" ? "review" : "take"}
|
||||
value={value}
|
||||
disabled={!canEdit}
|
||||
onChange={(answer) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer },
|
||||
}))
|
||||
}
|
||||
showCorrectAnswer={submissionStatus === "graded"}
|
||||
feedback={submissionStatus === "graded" ? q.feedback : null}
|
||||
footerExtra={
|
||||
canEdit ? (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSaveQuestion(q.questionId)}
|
||||
disabled={isBusy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
{t("homework.take.saveAnswer")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("homework.take.answerPlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="min-h-[120px] resize-y"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: v === "true" },
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">{t("homework.take.true")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">{t("homework.take.false")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(v) =>
|
||||
setAnswersByQuestionId((prev) => ({
|
||||
...prev,
|
||||
[q.questionId]: { answer: v },
|
||||
}))
|
||||
}
|
||||
disabled={!canEdit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
return (
|
||||
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
|
||||
<Checkbox
|
||||
id={`${q.questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true
|
||||
setAnswersByQuestionId((prev) => {
|
||||
const current = Array.isArray(prev[q.questionId]?.answer)
|
||||
? (prev[q.questionId]?.answer as string[])
|
||||
: []
|
||||
const next = isChecked
|
||||
? Array.from(new Set([...current, o.id]))
|
||||
: current.filter((x) => x !== o.id)
|
||||
return { ...prev, [q.questionId]: { answer: next } }
|
||||
})
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||
{o.text}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">{t("homework.take.unsupportedType")}</div>
|
||||
)}
|
||||
|
||||
{submissionStatus === "graded" && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
{q.feedback ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{q.feedback}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">{t("homework.take.noFeedback")}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSaveQuestion(q.questionId)}
|
||||
disabled={isBusy}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
{t("homework.take.saveAnswer")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
@@ -407,6 +289,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 bg-muted/30">
|
||||
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
||||
{canEdit && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
|
||||
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
|
||||
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
|
||||
<span className={
|
||||
autoSave.status === "saved" ? "text-green-600" :
|
||||
autoSave.status === "error" ? "text-destructive" :
|
||||
"text-muted-foreground"
|
||||
}>
|
||||
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
@@ -467,9 +364,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
const answer = answersByQuestionId[q.questionId]?.answer
|
||||
const hasAnswer = answer !== undefined &&
|
||||
answer !== "" &&
|
||||
(Array.isArray(answer) ? answer.length > 0 : true)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -484,6 +382,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
|
||||
)}
|
||||
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
|
||||
aria-pressed={hasAnswer}
|
||||
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
|
||||
361
src/modules/homework/components/question-renderer.tsx
Normal file
361
src/modules/homework/components/question-renderer.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import {
|
||||
extractAnswerValue,
|
||||
getOptions,
|
||||
getQuestionText,
|
||||
isRecord,
|
||||
type QuestionOption,
|
||||
type QuestionType,
|
||||
} from "../lib/question-content-utils"
|
||||
|
||||
/**
|
||||
* 题目渲染模式
|
||||
* - `take`: 学生作答交互
|
||||
* - `review`: 学生查看批改结果(只读 + 正确答案高亮)
|
||||
* - `grade`: 教师批改(只读学生答案 + 正确答案 + 评分面板 slot)
|
||||
*/
|
||||
export type QuestionRenderMode = "take" | "review" | "grade"
|
||||
|
||||
export interface QuestionRendererProps {
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
index: number
|
||||
mode: QuestionRenderMode
|
||||
/** 学生答案(take 模式下为当前值,review/grade 模式下为已提交值) */
|
||||
value?: unknown
|
||||
/** take 模式下的禁用状态 */
|
||||
disabled?: boolean
|
||||
/** take 模式下答案变更回调 */
|
||||
onChange?: (answer: unknown) => void
|
||||
/** review/grade 模式下是否显示正确答案 */
|
||||
showCorrectAnswer?: boolean
|
||||
/** review/grade 模式下是否显示批改反馈 */
|
||||
feedback?: string | null
|
||||
/** 题目头部右侧额外内容(如分数 Badge) */
|
||||
headerExtra?: ReactNode
|
||||
/** 题目底部额外内容(如批改面板) */
|
||||
footerExtra?: ReactNode
|
||||
/** 题目卡片额外 className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目渲染器(只读展示 + 答案输入组合)
|
||||
*
|
||||
* 通过 `mode` 切换交互行为:
|
||||
* - `take`: 渲染可编辑的作答输入控件
|
||||
* - `review`: 渲染只读答案 + 正确答案高亮
|
||||
* - `grade`: 渲染只读学生答案 + 正确答案(由父组件通过 `footerExtra` 注入批改面板)
|
||||
*/
|
||||
export function QuestionRenderer({
|
||||
questionId,
|
||||
questionType,
|
||||
questionContent,
|
||||
maxScore,
|
||||
index,
|
||||
mode,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
showCorrectAnswer,
|
||||
feedback,
|
||||
headerExtra,
|
||||
footerExtra,
|
||||
className,
|
||||
}: QuestionRendererProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
const text = getQuestionText(questionContent)
|
||||
const options = getOptions(questionContent)
|
||||
const isReadOnly = mode !== "take"
|
||||
const showFeedback = isReadOnly && Boolean(feedback)
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`question-${questionId}`}
|
||||
className={className}
|
||||
aria-labelledby={`question-${questionId}-title`}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3
|
||||
id={`question-${questionId}-title`}
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{t("homework.take.question", { index: index + 1 })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{questionType.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase())} • {maxScore} {t("homework.take.points")}
|
||||
</p>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</header>
|
||||
|
||||
<div className="mt-4 text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<QuestionAnswerInput
|
||||
questionId={questionId}
|
||||
questionType={questionType}
|
||||
options={options}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
readOnly={isReadOnly}
|
||||
onChange={onChange}
|
||||
showCorrectAnswer={showCorrectAnswer === true}
|
||||
questionContent={questionContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFeedback && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{feedback}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footerExtra}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
interface QuestionAnswerInputProps {
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
options: QuestionOption[]
|
||||
value: unknown
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
onChange?: (answer: unknown) => void
|
||||
showCorrectAnswer?: boolean
|
||||
questionContent: unknown
|
||||
}
|
||||
|
||||
function QuestionAnswerInput({
|
||||
questionId,
|
||||
questionType,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
readOnly,
|
||||
onChange,
|
||||
showCorrectAnswer,
|
||||
questionContent,
|
||||
}: QuestionAnswerInputProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
if (questionType === "text") {
|
||||
if (readOnly) {
|
||||
const textValue = typeof value === "string" ? value : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||
{textValue || (
|
||||
<span className="text-muted-foreground italic">{t("homework.review.noAnswer")}</span>
|
||||
)}
|
||||
</div>
|
||||
{showCorrectAnswer && (
|
||||
<CorrectAnswerDisplay questionType="text" questionContent={questionContent} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
|
||||
<Textarea
|
||||
placeholder={t("homework.take.answerPlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
className="min-h-[120px] resize-y"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const boolValue = typeof value === "boolean" ? (value ? "true" : "false") : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={boolValue}
|
||||
onValueChange={(v) => onChange?.(v === "true")}
|
||||
disabled={disabled || readOnly}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="true" id={`${questionId}-true`} />
|
||||
<Label htmlFor={`${questionId}-true`} className="flex-1 cursor-pointer font-normal">
|
||||
{t("homework.take.true")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="false" id={`${questionId}-false`} />
|
||||
<Label htmlFor={`${questionId}-false`} className="flex-1 cursor-pointer font-normal">
|
||||
{t("homework.take.false")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{showCorrectAnswer && readOnly && (
|
||||
<CorrectAnswerDisplay questionType="judgment" questionContent={questionContent} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
const strValue = typeof value === "string" ? value : ""
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={strValue}
|
||||
onValueChange={(v) => onChange?.(v)}
|
||||
disabled={disabled || readOnly}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{options.map((o) => {
|
||||
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-center space-x-2 rounded-md border p-3 ${
|
||||
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
|
||||
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
|
||||
>
|
||||
<RadioGroupItem value={o.id} id={`${questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">
|
||||
{t("homework.review.correctMarker")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "multiple_choice") {
|
||||
const selectedIds = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = selectedIds.includes(o.id)
|
||||
const isCorrectOption = showCorrectAnswer && o.isCorrect === true
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-start space-x-2 rounded-md border p-3 ${
|
||||
readOnly ? "bg-muted/20" : "hover:bg-muted/50 transition-colors"
|
||||
} ${isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (readOnly) return
|
||||
const isChecked = checked === true
|
||||
const next = isChecked
|
||||
? Array.from(new Set([...selectedIds, o.id]))
|
||||
: selectedIds.filter((x) => x !== o.id)
|
||||
onChange?.(next)
|
||||
}}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
<Label htmlFor={`${questionId}-${o.id}`} className="flex-1 cursor-pointer font-normal leading-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">
|
||||
{t("homework.review.correctMarker")}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
{t("homework.take.unsupportedType")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正确答案展示(review/grade 模式)
|
||||
*/
|
||||
function CorrectAnswerDisplay({
|
||||
questionType,
|
||||
questionContent,
|
||||
}: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
}) {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
if (questionType === "text") {
|
||||
const correctTexts = (() => {
|
||||
if (!isRecord(questionContent)) return []
|
||||
const raw = questionContent.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
})()
|
||||
if (correctTexts.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
|
||||
<div className="font-medium text-emerald-700 mb-1">{t("homework.review.correctAnswer")}</div>
|
||||
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correct = (() => {
|
||||
if (!isRecord(questionContent)) return null
|
||||
return typeof questionContent.correctAnswer === "boolean"
|
||||
? questionContent.correctAnswer
|
||||
: null
|
||||
})()
|
||||
if (correct === null) return null
|
||||
return (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
|
||||
<span className="font-medium text-emerald-700">{t("homework.review.correctAnswer")}: </span>
|
||||
<span className="text-emerald-900">{correct ? t("homework.take.true") : t("homework.take.false")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取学生答案值(便于父组件格式化展示)
|
||||
*/
|
||||
export { extractAnswerValue }
|
||||
@@ -3,76 +3,26 @@
|
||||
import { useMemo } from "react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { FileText, ChevronLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import type { StudentHomeworkTakeData } from "../types"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
type Option = { id: string; text: string; isCorrect?: boolean }
|
||||
|
||||
const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
const getOptions = (content: unknown): Option[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: Option[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
const isCorrect = item.isCorrect === true
|
||||
out.push({ id, text, isCorrect })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const getChoiceCorrectIds = (content: unknown): string[] => {
|
||||
return getOptions(content).filter((o) => o.isCorrect).map((o) => o.id)
|
||||
}
|
||||
|
||||
const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
|
||||
if (!isRecord(content)) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
const getTextCorrectAnswers = (content: unknown): string[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
}
|
||||
|
||||
const toAnswerShape = (questionType: string, v: unknown) => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") return { answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [] }
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
const parseSavedAnswer = (saved: unknown, questionType: string) => {
|
||||
if (isRecord(saved) && "answer" in saved) return toAnswerShape(questionType, saved.answer)
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import {
|
||||
getCorrectnessState,
|
||||
parseSavedAnswer,
|
||||
} from "../lib/question-content-utils"
|
||||
|
||||
type HomeworkReviewViewProps = {
|
||||
initialData: StudentHomeworkTakeData
|
||||
}
|
||||
|
||||
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
const submissionStatus = initialData.submission?.status ?? "not_started"
|
||||
const isGraded = submissionStatus === "graded"
|
||||
|
||||
@@ -96,22 +46,22 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold leading-none">
|
||||
{isGraded ? "Graded Report" : "Submission Details"}
|
||||
{isGraded ? t("homework.review.gradedReport") : t("homework.review.submissionDetails")}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
|
||||
{submissionStatus}
|
||||
</Badge>
|
||||
<span>•</span>
|
||||
<span>{initialData.questions.length} Questions</span>
|
||||
<span>{initialData.questions.length} {t("homework.review.questionsUnit")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/student/learning/assignments">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back to List
|
||||
{t("homework.review.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -119,162 +69,57 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
<ScrollArea className="flex-1 bg-muted/10">
|
||||
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||
{initialData.questions.map((q, idx) => {
|
||||
const text = getQuestionText(q.questionContent)
|
||||
const options = getOptions(q.questionContent)
|
||||
const value = answersByQuestionId[q.questionId]?.answer
|
||||
const correctness = isGraded
|
||||
? getCorrectnessState({ score: q.score ?? null, maxScore: q.maxScore })
|
||||
: "ungraded"
|
||||
const borderClass =
|
||||
correctness === "correct"
|
||||
? "border-l-4 border-l-emerald-500"
|
||||
: correctness === "incorrect"
|
||||
? "border-l-4 border-l-red-500"
|
||||
: correctness === "partial"
|
||||
? "border-l-4 border-l-yellow-500"
|
||||
: "border-l-4 border-l-primary"
|
||||
|
||||
return (
|
||||
<Card key={q.questionId} className={`shadow-sm ${isGraded ? 'border-l-4' : 'border-l-4 border-l-primary'}`}
|
||||
style={isGraded ? { borderLeftColor: q.score === q.maxScore && q.maxScore > 0 ? '#10b981' : q.score && q.score > 0 ? '#eab308' : '#ef4444' } : undefined}
|
||||
>
|
||||
<Card key={q.questionId} className={`shadow-sm ${borderClass}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||
Question {idx + 1}
|
||||
{isGraded && (
|
||||
<Badge variant="outline" className={`ml-2 ${q.score === q.maxScore ? "text-emerald-600 border-emerald-200 bg-emerald-50" : "text-red-600 border-red-200 bg-red-50"}`}>
|
||||
{q.score} / {q.maxScore}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs flex flex-col gap-1.5">
|
||||
<span>{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} • {q.maxScore} points</span>
|
||||
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{q.knowledgePoints.map((kp) => (
|
||||
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<QuestionRenderer
|
||||
questionId={q.questionId}
|
||||
questionType={q.questionType}
|
||||
questionContent={q.questionContent}
|
||||
maxScore={q.maxScore}
|
||||
index={idx}
|
||||
mode="review"
|
||||
value={value}
|
||||
showCorrectAnswer={isGraded}
|
||||
feedback={isGraded ? q.feedback : null}
|
||||
headerExtra={
|
||||
isGraded ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
aria-label={t(`homework.grade.${correctness === "ungraded" ? "partial" : correctness}`)}
|
||||
className={
|
||||
correctness === "correct"
|
||||
? "text-emerald-600 border-emerald-200 bg-emerald-50"
|
||||
: "text-red-600 border-red-200 bg-red-50"
|
||||
}
|
||||
>
|
||||
{q.score} / {q.maxScore}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm font-medium leading-relaxed">{text || "—"}</div>
|
||||
|
||||
{q.questionType === "text" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label className="sr-only">Your answer</Label>
|
||||
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
|
||||
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
|
||||
</div>
|
||||
{isGraded && (() => {
|
||||
const correctTexts = getTextCorrectAnswers(q.questionContent)
|
||||
if (correctTexts.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
|
||||
<div className="font-medium text-emerald-700 mb-1">Correct Answer</div>
|
||||
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : q.questionType === "judgment" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "boolean" ? (value ? "true" : "false") : ""}
|
||||
disabled
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
|
||||
<Label htmlFor={`${q.questionId}-true`} className="flex-1 font-normal">True</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20">
|
||||
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
|
||||
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{isGraded && (() => {
|
||||
const correct = getJudgmentCorrectAnswer(q.questionContent)
|
||||
if (correct === null) return null
|
||||
return (
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
|
||||
<span className="font-medium text-emerald-700">Correct Answer: </span>
|
||||
<span className="text-emerald-900">{correct ? "True" : "False"}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : q.questionType === "single_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={typeof value === "string" ? value : ""}
|
||||
disabled
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{options.map((o) => {
|
||||
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
|
||||
const isCorrectOption = isGraded && correctIds.includes(o.id)
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-center space-x-2 rounded-md border p-3 bg-muted/20 ${
|
||||
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
|
||||
}`}
|
||||
>
|
||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">✓ Correct</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : q.questionType === "multiple_choice" ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((o) => {
|
||||
const selected = Array.isArray(value) ? value.includes(o.id) : false
|
||||
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
|
||||
const isCorrectOption = isGraded && correctIds.includes(o.id)
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className={`flex items-start space-x-2 rounded-md border p-3 bg-muted/20 ${
|
||||
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${q.questionId}-${o.id}`}
|
||||
checked={selected}
|
||||
disabled
|
||||
/>
|
||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
|
||||
{o.text}
|
||||
{isCorrectOption && (
|
||||
<span className="ml-2 text-xs font-medium text-emerald-700">✓ Correct</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
|
||||
)}
|
||||
|
||||
{isGraded && (
|
||||
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
|
||||
{q.feedback ? (
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="font-medium text-foreground">Teacher Feedback</div>
|
||||
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
|
||||
{q.feedback}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
|
||||
)}
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{q.knowledgePoints.map((kp) => (
|
||||
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -287,29 +132,29 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||
<div className="border-b p-4 bg-muted/30">
|
||||
<h3 className="font-semibold">Assignment Info</h3>
|
||||
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{submissionStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.description")}</Label>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{initialData.assignment.description || "No description provided."}
|
||||
{initialData.assignment.description || t("homework.review.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isGraded && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Total Score</Label>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.totalScore")}</Label>
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-primary">
|
||||
{initialData.submission?.score ?? 0}
|
||||
@@ -319,36 +164,38 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-600"></div>
|
||||
<span>Correct</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||
<span>Partial</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<span>Incorrect</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-600" aria-hidden="true" />
|
||||
<span>{t("homework.grade.correct")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-yellow-500" aria-hidden="true" />
|
||||
<span>{t("homework.grade.partial")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" aria-hidden="true" />
|
||||
<span>{t("homework.grade.incorrect")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
{isGraded ? "Question Breakdown" : "Response Summary"}
|
||||
{isGraded ? t("homework.review.questionBreakdown") : t("homework.review.responseSummary")}
|
||||
</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer)
|
||||
? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0
|
||||
: true)
|
||||
|
||||
const score = q.score ?? 0
|
||||
const max = q.maxScore
|
||||
let statusClass = "bg-background text-muted-foreground border-input"
|
||||
|
||||
|
||||
if (isGraded) {
|
||||
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
|
||||
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
|
||||
@@ -356,14 +203,11 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
} else if (hasAnswer) {
|
||||
statusClass = "bg-primary text-primary-foreground border-primary"
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={q.questionId}
|
||||
className={`
|
||||
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border
|
||||
${statusClass}
|
||||
`}
|
||||
className={`h-8 w-8 rounded flex items-center justify-center text-xs font-medium border ${statusClass}`}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
@@ -118,11 +118,113 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
},
|
||||
})
|
||||
|
||||
if (data.length === 0) return []
|
||||
|
||||
const assignmentIds = data.map((a) => a.id)
|
||||
const now = new Date()
|
||||
|
||||
// 并行查询:目标学生数 / 已提交数 / 已批改数 / 已批改平均分 / 逾期未提交学生集合
|
||||
const [targetCountRows, submittedCountRows, gradedCountRows, avgScoreRows, submittedStudentRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
targetCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
const targetCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||
|
||||
const submittedCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
|
||||
|
||||
const gradedCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
|
||||
|
||||
const avgScoreByAssignmentId = new Map<string, number | null>()
|
||||
for (const r of avgScoreRows) {
|
||||
const v = r.avgScore
|
||||
avgScoreByAssignmentId.set(r.assignmentId, v === null ? null : Number(v))
|
||||
}
|
||||
|
||||
// 已提交学生集合(按 assignmentId 分组),用于计算逾期未提交人数
|
||||
const submittedStudentIdsByAssignmentId = new Map<string, Set<string>>()
|
||||
for (const r of submittedStudentRows) {
|
||||
let set = submittedStudentIdsByAssignmentId.get(r.assignmentId)
|
||||
if (!set) {
|
||||
set = new Set<string>()
|
||||
submittedStudentIdsByAssignmentId.set(r.assignmentId, set)
|
||||
}
|
||||
set.add(r.studentId)
|
||||
}
|
||||
|
||||
// 逾期未提交人数 = 目标学生数 - 已提交学生数(仅当 dueAt 已过时计算)
|
||||
const computeOverdueCount = (assignmentId: string, dueAt: Date | null): number => {
|
||||
if (!dueAt || dueAt > now) return 0
|
||||
const targetCount = targetCountByAssignmentId.get(assignmentId) ?? 0
|
||||
const submittedCount = submittedStudentIdsByAssignmentId.get(assignmentId)?.size ?? 0
|
||||
return Math.max(0, targetCount - submittedCount)
|
||||
}
|
||||
|
||||
return data.map((a) => {
|
||||
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||
const submittedCount = submittedCountByAssignmentId.get(a.id) ?? 0
|
||||
const gradedCount = gradedCountByAssignmentId.get(a.id) ?? 0
|
||||
const averageScore = avgScoreByAssignmentId.get(a.id) ?? null
|
||||
const overdueCount = computeOverdueCount(a.id, a.dueAt)
|
||||
const item: HomeworkAssignmentListItem = {
|
||||
id: a.id,
|
||||
sourceExamId: a.sourceExamId,
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
sourceExamTitle: a.sourceExam?.title ?? null,
|
||||
title: a.title,
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
@@ -132,6 +234,11 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
maxAttempts: a.maxAttempts,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
updatedAt: a.updatedAt.toISOString(),
|
||||
targetCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
averageScore,
|
||||
overdueCount,
|
||||
}
|
||||
return item
|
||||
})
|
||||
@@ -221,7 +328,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
sourceExamTitle: a.sourceExam?.title ?? null,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
|
||||
submittedCount: submittedCountByAssignmentId.get(a.id) ?? 0,
|
||||
@@ -322,6 +429,8 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
// 快速作业(无 sourceExamId)不归年级主任管辖,直接拒绝
|
||||
if (!assignment.sourceExamId) return null
|
||||
const examIds = await getExamIdsByGradeIds(scope.gradeIds)
|
||||
if (!examIds.includes(assignment.sourceExamId)) {
|
||||
return null
|
||||
@@ -371,8 +480,8 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
description: assignment.description,
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
sourceExamTitle: assignment.sourceExam?.title ?? null,
|
||||
structure: assignment.structure,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
@@ -427,16 +536,34 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Fetch adjacent submissions for navigation
|
||||
const allSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
})
|
||||
// P1-8: Optimize adjacent submission navigation using LIMIT 1 queries
|
||||
// instead of fetching all submission IDs for the assignment.
|
||||
// Original ordering is desc(updatedAt): "previous" = newer, "next" = older.
|
||||
const currentUpdatedAt = submission.updatedAt
|
||||
|
||||
const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId)
|
||||
const prevSubmissionId = currentIndex > 0 ? allSubmissions[currentIndex - 1].id : null
|
||||
const nextSubmissionId = currentIndex >= 0 && currentIndex < allSubmissions.length - 1 ? allSubmissions[currentIndex + 1].id : null
|
||||
const [prevSubmission, nextSubmission] = await Promise.all([
|
||||
// Previous (newer): closest submission with updatedAt > current
|
||||
db.query.homeworkSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
gt(homeworkSubmissions.updatedAt, currentUpdatedAt)
|
||||
),
|
||||
orderBy: [asc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
}),
|
||||
// Next (older): closest submission with updatedAt < current
|
||||
db.query.homeworkSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, submission.assignmentId),
|
||||
lt(homeworkSubmissions.updatedAt, currentUpdatedAt)
|
||||
),
|
||||
orderBy: [desc(homeworkSubmissions.updatedAt)],
|
||||
columns: { id: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const prevSubmissionId = prevSubmission?.id ?? null
|
||||
const nextSubmissionId = nextSubmission?.id ?? null
|
||||
|
||||
return {
|
||||
id: submission.id,
|
||||
@@ -490,7 +617,10 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
if (assignments.length === 0) return []
|
||||
|
||||
// Fetch subject names via cross-module interfaces
|
||||
const examIds = assignments.map((a) => a.sourceExamId)
|
||||
// 快速作业无 sourceExamId,过滤 null 后再查询科目映射
|
||||
const examIds = assignments
|
||||
.map((a) => a.sourceExamId)
|
||||
.filter((id): id is string => id !== null)
|
||||
const [examSubjectIdMap, subjectOptions] = await Promise.all([
|
||||
getExamSubjectIdMap(examIds),
|
||||
getSubjectOptions(),
|
||||
@@ -519,7 +649,7 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
return assignments.map((a) => {
|
||||
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||
const subjectId = examSubjectIdMap.get(a.sourceExamId) ?? null
|
||||
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
|
||||
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
|
||||
|
||||
const item: StudentHomeworkAssignmentListItem = {
|
||||
|
||||
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal file
189
src/modules/homework/hooks/use-debounced-auto-save.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { saveHomeworkAnswerAction } from "../actions"
|
||||
|
||||
type AutoSaveStatus = "idle" | "saving" | "saved" | "error"
|
||||
|
||||
type AnswerMap = Record<string, { answer: unknown } | undefined>
|
||||
|
||||
type UseDebouncedAutoSaveOptions = {
|
||||
submissionId: string | null
|
||||
answers: AnswerMap
|
||||
enabled: boolean
|
||||
debounceMs?: number
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type UseDebouncedAutoSaveResult = {
|
||||
status: AutoSaveStatus
|
||||
lastSavedAt: number | null
|
||||
flush: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-9: 学生答案自动保存 + 离线缓存
|
||||
*
|
||||
* - 答案变更后 debounce(默认 3 秒)自动保存到服务端
|
||||
* - 同时写入 localStorage 作为离线缓存
|
||||
* - 网络异常时标记 error,恢复后自动重试
|
||||
* - 组件卸载时 flush 未保存的答案
|
||||
*/
|
||||
export function useDebouncedAutoSave({
|
||||
submissionId,
|
||||
answers,
|
||||
enabled,
|
||||
debounceMs = 3000,
|
||||
storageKey,
|
||||
}: UseDebouncedAutoSaveOptions): UseDebouncedAutoSaveResult {
|
||||
const [status, setStatus] = useState<AutoSaveStatus>("idle")
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingRef = useRef<Map<string, unknown>>(new Map())
|
||||
const savingRef = useRef(false)
|
||||
const lastSavedAnswersRef = useRef<string>("")
|
||||
|
||||
// Persist to localStorage for offline recovery
|
||||
const cacheToLocalStorage = useCallback(
|
||||
(snapshot: AnswerMap) => {
|
||||
if (!storageKey) return
|
||||
try {
|
||||
const serialized = JSON.stringify({
|
||||
submissionId,
|
||||
answers: snapshot,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
window.localStorage.setItem(storageKey, serialized)
|
||||
} catch {
|
||||
// localStorage may be full or unavailable; silently ignore
|
||||
}
|
||||
},
|
||||
[storageKey, submissionId]
|
||||
)
|
||||
|
||||
// Save a batch of pending answers to the server
|
||||
const savePending = useCallback(async () => {
|
||||
if (!submissionId || savingRef.current) return
|
||||
const pending = Array.from(pendingRef.current.entries())
|
||||
if (pending.length === 0) return
|
||||
|
||||
savingRef.current = true
|
||||
setStatus("saving")
|
||||
|
||||
let allOk = true
|
||||
for (const [questionId, answer] of pending) {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
|
||||
savingRef.current = false
|
||||
|
||||
if (allOk) {
|
||||
pendingRef.current.clear()
|
||||
setStatus("saved")
|
||||
setLastSavedAt(Date.now())
|
||||
} else {
|
||||
setStatus("error")
|
||||
// Keep pending items for retry on next change or manual flush
|
||||
}
|
||||
}, [submissionId])
|
||||
|
||||
// Schedule debounced save when answers change
|
||||
useEffect(() => {
|
||||
if (!enabled || !submissionId) return
|
||||
|
||||
const currentSnapshot = JSON.stringify(answers)
|
||||
if (currentSnapshot === lastSavedAnswersRef.current) return
|
||||
|
||||
// Cache to localStorage immediately (offline safety net)
|
||||
cacheToLocalStorage(answers)
|
||||
|
||||
// Collect changed question IDs
|
||||
for (const questionId of Object.keys(answers)) {
|
||||
const entry = answers[questionId]
|
||||
if (entry !== undefined) {
|
||||
pendingRef.current.set(questionId, entry.answer)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing timer and set a new one
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
void savePending()
|
||||
lastSavedAnswersRef.current = currentSnapshot
|
||||
}, debounceMs)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [answers, enabled, submissionId, debounceMs, cacheToLocalStorage, savePending])
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// Fire-and-forget final save
|
||||
void savePending()
|
||||
}
|
||||
}, [savePending])
|
||||
|
||||
// Retry on window focus (network may have recovered)
|
||||
useEffect(() => {
|
||||
if (status !== "error") return
|
||||
const handleFocus = () => {
|
||||
void savePending()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
return () => window.removeEventListener("focus", handleFocus)
|
||||
}, [status, savePending])
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
await savePending()
|
||||
lastSavedAnswersRef.current = JSON.stringify(answers)
|
||||
}, [answers, savePending])
|
||||
|
||||
return { status, lastSavedAt, flush }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复离线缓存的答案
|
||||
*/
|
||||
export function loadOfflineCache(storageKey: string): AnswerMap | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as { answers?: AnswerMap }
|
||||
if (!parsed.answers || typeof parsed.answers !== "object") return null
|
||||
return parsed.answers
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 localStorage 中的离线缓存
|
||||
*/
|
||||
export function clearOfflineCache(storageKey: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
433
src/modules/homework/lib/question-content-utils.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 6.5: 单测覆盖权限校验与数据流转
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - question-content-utils 的纯函数(数据流转核心)
|
||||
* - exam-homework-role-config 的角色特性合并逻辑(权限校验配置层)
|
||||
* - applyAutoGrades 自动判分流程
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
isRecord,
|
||||
getQuestionText,
|
||||
getOptions,
|
||||
getChoiceCorrectIds,
|
||||
getJudgmentCorrectAnswer,
|
||||
getTextCorrectAnswers,
|
||||
parseSavedAnswer,
|
||||
extractAnswerValue,
|
||||
normalizeText,
|
||||
isAutoGradable,
|
||||
computeIsCorrect,
|
||||
getCorrectnessState,
|
||||
applyAutoGrades,
|
||||
formatStudentAnswer,
|
||||
type AutoGradableAnswer,
|
||||
} from "./question-content-utils"
|
||||
|
||||
describe("isRecord", () => {
|
||||
it("returns true for non-null objects (including arrays)", () => {
|
||||
expect(isRecord({})).toBe(true)
|
||||
expect(isRecord({ a: 1 })).toBe(true)
|
||||
// Note: arrays are objects in JS, so isRecord returns true for them.
|
||||
// Callers that need to exclude arrays should check Array.isArray separately.
|
||||
expect(isRecord([1, 2])).toBe(true)
|
||||
})
|
||||
it("returns false for null and primitives", () => {
|
||||
expect(isRecord(null)).toBe(false)
|
||||
expect(isRecord(undefined)).toBe(false)
|
||||
expect(isRecord("string")).toBe(false)
|
||||
expect(isRecord(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getQuestionText", () => {
|
||||
it("extracts text from valid content", () => {
|
||||
expect(getQuestionText({ text: "What is 2+2?" })).toBe("What is 2+2?")
|
||||
})
|
||||
it("returns empty string for missing or non-string text", () => {
|
||||
expect(getQuestionText({})).toBe("")
|
||||
expect(getQuestionText({ text: 123 })).toBe("")
|
||||
expect(getQuestionText(null)).toBe("")
|
||||
expect(getQuestionText("string")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOptions", () => {
|
||||
it("parses valid options array", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Option A", isCorrect: true },
|
||||
{ id: "b", text: "Option B" },
|
||||
],
|
||||
}
|
||||
const opts = getOptions(content)
|
||||
expect(opts).toHaveLength(2)
|
||||
expect(opts[0]).toEqual({ id: "a", text: "Option A", isCorrect: true })
|
||||
expect(opts[1]).toEqual({ id: "b", text: "Option B", isCorrect: false })
|
||||
})
|
||||
it("filters out options missing id or text", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "Valid" },
|
||||
{ id: "", text: "No ID" },
|
||||
{ id: "c", text: "" },
|
||||
{ id: "d" },
|
||||
{ text: "No ID" },
|
||||
],
|
||||
}
|
||||
expect(getOptions(content)).toHaveLength(1)
|
||||
})
|
||||
it("returns empty array when options is missing or not an array", () => {
|
||||
expect(getOptions({})).toEqual([])
|
||||
expect(getOptions({ options: "not-array" })).toEqual([])
|
||||
expect(getOptions(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getChoiceCorrectIds", () => {
|
||||
it("returns IDs of options marked isCorrect", () => {
|
||||
const content = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
expect(getChoiceCorrectIds(content)).toEqual(["a", "c"])
|
||||
})
|
||||
it("returns empty array when no correct options", () => {
|
||||
const content = { options: [{ id: "a", text: "A" }] }
|
||||
expect(getChoiceCorrectIds(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getJudgmentCorrectAnswer", () => {
|
||||
it("returns boolean when correctAnswer is boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: true })).toBe(true)
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: false })).toBe(false)
|
||||
})
|
||||
it("returns null for missing or non-boolean", () => {
|
||||
expect(getJudgmentCorrectAnswer({})).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer({ correctAnswer: "true" })).toBeNull()
|
||||
expect(getJudgmentCorrectAnswer(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTextCorrectAnswers", () => {
|
||||
it("returns array from string correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: "yes" })).toEqual(["yes"])
|
||||
})
|
||||
it("returns array from string[] correctAnswer", () => {
|
||||
expect(getTextCorrectAnswers({ correctAnswer: ["yes", "Yes", "YES"] })).toEqual([
|
||||
"yes",
|
||||
"Yes",
|
||||
"YES",
|
||||
])
|
||||
})
|
||||
it("returns empty array for missing or invalid", () => {
|
||||
expect(getTextCorrectAnswers({})).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: 123 })).toEqual([])
|
||||
expect(getTextCorrectAnswers({ correctAnswer: [1, 2] })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSavedAnswer", () => {
|
||||
it("parses object with answer field", () => {
|
||||
expect(parseSavedAnswer({ answer: "test" }, "text")).toEqual({ answer: "test" })
|
||||
expect(parseSavedAnswer({ answer: ["a", "b"] }, "multiple_choice")).toEqual({
|
||||
answer: ["a", "b"],
|
||||
})
|
||||
})
|
||||
it("returns empty answer for null/undefined input", () => {
|
||||
expect(parseSavedAnswer(null, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer(undefined, "text")).toEqual({ answer: "" })
|
||||
})
|
||||
it("coerces non-matching types to defaults", () => {
|
||||
expect(parseSavedAnswer("raw-string", "text")).toEqual({ answer: "raw-string" })
|
||||
expect(parseSavedAnswer(123, "text")).toEqual({ answer: "" })
|
||||
expect(parseSavedAnswer("not-boolean", "judgment")).toEqual({ answer: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractAnswerValue", () => {
|
||||
it("extracts answer from object shape", () => {
|
||||
expect(extractAnswerValue({ answer: "test" })).toBe("test")
|
||||
expect(extractAnswerValue({ answer: ["a", "b"] })).toEqual(["a", "b"])
|
||||
})
|
||||
it("returns raw value for non-object shapes", () => {
|
||||
expect(extractAnswerValue("test")).toBe("test")
|
||||
expect(extractAnswerValue(["a", "b"])).toEqual(["a", "b"])
|
||||
expect(extractAnswerValue(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeText", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeText(" Hello World ")).toBe("hello world")
|
||||
})
|
||||
it("collapses internal whitespace", () => {
|
||||
expect(normalizeText("a b\tc")).toBe("a b c")
|
||||
})
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizeText("")).toBe("")
|
||||
expect(normalizeText(" ")).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAutoGradable", () => {
|
||||
const choiceContent = {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
|
||||
it("returns true for choice types with correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: choiceContent })).toBe(true)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: choiceContent })).toBe(true)
|
||||
})
|
||||
it("returns false for choice types without correct options", () => {
|
||||
expect(isAutoGradable({ questionType: "single_choice", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "multiple_choice", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for judgment with boolean correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: judgmentContent })).toBe(true)
|
||||
})
|
||||
it("returns false for judgment without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "judgment", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns true for text type with correctAnswer", () => {
|
||||
expect(
|
||||
isAutoGradable({ questionType: "text", questionContent: { correctAnswer: "yes" } })
|
||||
).toBe(true)
|
||||
})
|
||||
it("returns false for text type without correctAnswer", () => {
|
||||
expect(isAutoGradable({ questionType: "text", questionContent: {} })).toBe(false)
|
||||
})
|
||||
it("returns false for unknown types", () => {
|
||||
expect(isAutoGradable({ questionType: "essay", questionContent: {} })).toBe(false)
|
||||
expect(isAutoGradable({ questionType: "fill_blank", questionContent: {} })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeIsCorrect", () => {
|
||||
const singleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
}
|
||||
const multipleChoiceContent = {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
{ id: "c", text: "C", isCorrect: true },
|
||||
],
|
||||
}
|
||||
const judgmentContent = { correctAnswer: true }
|
||||
const textContent = { correctAnswer: ["yes", "Yes"] }
|
||||
|
||||
it("single_choice: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "a",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("single_choice: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "single_choice",
|
||||
questionContent: singleChoiceContent,
|
||||
studentAnswer: "b",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: correct (all and only correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "c"],
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("multiple_choice: incorrect (missing one correct)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("multiple_choice: incorrect (extra wrong option)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "multiple_choice",
|
||||
questionContent: multipleChoiceContent,
|
||||
studentAnswer: ["a", "b", "c"],
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("judgment: correct", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: true,
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("judgment: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "judgment",
|
||||
questionContent: judgmentContent,
|
||||
studentAnswer: false,
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("text: correct (case-insensitive)", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: " YES ",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
it("text: incorrect", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "text",
|
||||
questionContent: textContent,
|
||||
studentAnswer: "no",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
it("returns null for non-auto-gradable types", () => {
|
||||
expect(
|
||||
computeIsCorrect({
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCorrectnessState", () => {
|
||||
it("returns 'ungraded' when score is null", () => {
|
||||
expect(getCorrectnessState({ score: null, maxScore: 10 })).toBe("ungraded")
|
||||
})
|
||||
it("returns 'correct' when score equals maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 10, maxScore: 10 })).toBe("correct")
|
||||
})
|
||||
it("returns 'incorrect' when score is 0", () => {
|
||||
expect(getCorrectnessState({ score: 0, maxScore: 10 })).toBe("incorrect")
|
||||
})
|
||||
it("returns 'partial' when 0 < score < maxScore", () => {
|
||||
expect(getCorrectnessState({ score: 5, maxScore: 10 })).toBe("partial")
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyAutoGrades", () => {
|
||||
const baseAnswers: AutoGradableAnswer[] = [
|
||||
{
|
||||
id: "a1",
|
||||
questionId: "q1",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
questionId: "q2",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [
|
||||
{ id: "a", text: "A", isCorrect: true },
|
||||
{ id: "b", text: "B" },
|
||||
],
|
||||
},
|
||||
studentAnswer: "b",
|
||||
score: null,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
questionId: "q3",
|
||||
questionType: "essay",
|
||||
questionContent: {},
|
||||
studentAnswer: "some text",
|
||||
score: null,
|
||||
maxScore: 10,
|
||||
feedback: null,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
questionId: "q4",
|
||||
questionType: "single_choice",
|
||||
questionContent: {
|
||||
options: [{ id: "a", text: "A", isCorrect: true }],
|
||||
},
|
||||
studentAnswer: "a",
|
||||
score: 5,
|
||||
maxScore: 5,
|
||||
feedback: null,
|
||||
order: 3,
|
||||
},
|
||||
]
|
||||
|
||||
it("auto-grades correct answer to full score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[0].score).toBe(5)
|
||||
})
|
||||
it("auto-grades incorrect answer to 0", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[1].score).toBe(0)
|
||||
})
|
||||
it("leaves non-auto-gradable answers with null score", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[2].score).toBeNull()
|
||||
})
|
||||
it("does not overwrite already-graded answers", () => {
|
||||
const result = applyAutoGrades(baseAnswers)
|
||||
expect(result[3].score).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatStudentAnswer", () => {
|
||||
it("formats string answer as-is", () => {
|
||||
expect(formatStudentAnswer("my answer")).toBe("my answer")
|
||||
})
|
||||
it("formats boolean answer", () => {
|
||||
expect(formatStudentAnswer(true)).toBe("True")
|
||||
expect(formatStudentAnswer(false)).toBe("False")
|
||||
})
|
||||
it("formats array answer as comma-separated", () => {
|
||||
expect(formatStudentAnswer(["a", "b", "c"])).toBe("a, b, c")
|
||||
})
|
||||
it("formats null answer as dash", () => {
|
||||
expect(formatStudentAnswer(null)).toBe("—")
|
||||
expect(formatStudentAnswer(undefined)).toBe("—")
|
||||
})
|
||||
it("formats object answer by extracting answer field", () => {
|
||||
expect(formatStudentAnswer({ answer: "test" })).toBe("test")
|
||||
})
|
||||
})
|
||||
226
src/modules/homework/lib/question-content-utils.ts
Normal file
226
src/modules/homework/lib/question-content-utils.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 题目内容解析纯函数
|
||||
*
|
||||
* 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。
|
||||
* 所有函数均为纯函数,无副作用,便于单测。
|
||||
*/
|
||||
|
||||
export type QuestionOption = {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect?: boolean
|
||||
}
|
||||
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
export const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
export const getOptions = (content: unknown): QuestionOption[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: QuestionOption[] = []
|
||||
for (const item of raw) {
|
||||
if (!isRecord(item)) continue
|
||||
const id = typeof item.id === "string" ? item.id : ""
|
||||
const text = typeof item.text === "string" ? item.text : ""
|
||||
if (!id || !text) continue
|
||||
const isCorrect = item.isCorrect === true
|
||||
out.push({ id, text, isCorrect })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const getChoiceCorrectIds = (content: unknown): string[] => {
|
||||
return getOptions(content)
|
||||
.filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true)
|
||||
.map((o) => o.id)
|
||||
}
|
||||
|
||||
export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
|
||||
if (!isRecord(content)) return null
|
||||
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
|
||||
}
|
||||
|
||||
export const getTextCorrectAnswers = (content: unknown): string[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.correctAnswer
|
||||
if (typeof raw === "string") return [raw]
|
||||
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
|
||||
return []
|
||||
}
|
||||
|
||||
export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string
|
||||
|
||||
export type AnswerShape =
|
||||
| { answer: string }
|
||||
| { answer: boolean }
|
||||
| { answer: string[] }
|
||||
| { answer: unknown }
|
||||
|
||||
export const toAnswerShape = (questionType: QuestionType, v: unknown): AnswerShape => {
|
||||
if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
|
||||
if (questionType === "single_choice") return { answer: typeof v === "string" ? v : "" }
|
||||
if (questionType === "multiple_choice") {
|
||||
return {
|
||||
answer: Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [],
|
||||
}
|
||||
}
|
||||
return { answer: v }
|
||||
}
|
||||
|
||||
export const parseSavedAnswer = (saved: unknown, questionType: QuestionType): AnswerShape => {
|
||||
if (isRecord(saved) && "answer" in saved) {
|
||||
return toAnswerShape(questionType, saved.answer)
|
||||
}
|
||||
return toAnswerShape(questionType, saved)
|
||||
}
|
||||
|
||||
export const extractAnswerValue = (studentAnswer: unknown): unknown => {
|
||||
if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer
|
||||
return studentAnswer
|
||||
}
|
||||
|
||||
export const normalizeText = (v: string): string =>
|
||||
v.trim().replace(/\s+/g, " ").toLowerCase()
|
||||
|
||||
/**
|
||||
* 判断题目是否可自动判分(有标准答案)
|
||||
*/
|
||||
export const isAutoGradable = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
}): boolean => {
|
||||
if (input.questionType === "single_choice" || input.questionType === "multiple_choice") {
|
||||
return getChoiceCorrectIds(input.questionContent).length > 0
|
||||
}
|
||||
if (input.questionType === "judgment") {
|
||||
return getJudgmentCorrectAnswer(input.questionContent) !== null
|
||||
}
|
||||
if (input.questionType === "text") {
|
||||
return getTextCorrectAnswers(input.questionContent).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
|
||||
|
||||
/**
|
||||
* 计算单题对错状态
|
||||
* @returns "correct" | "incorrect" | "partial" | "ungraded";无标准答案返回 null
|
||||
*/
|
||||
export const computeIsCorrect = (input: {
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
studentAnswer: unknown
|
||||
}): boolean | null => {
|
||||
const studentVal = extractAnswerValue(input.studentAnswer)
|
||||
|
||||
if (input.questionType === "single_choice") {
|
||||
const correct = getChoiceCorrectIds(input.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
return correct.includes(studentVal)
|
||||
}
|
||||
|
||||
if (input.questionType === "multiple_choice") {
|
||||
const correct = getChoiceCorrectIds(input.questionContent)
|
||||
if (correct.length === 0) return null
|
||||
const studentArr = Array.isArray(studentVal)
|
||||
? studentVal.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
const correctSet = new Set(correct)
|
||||
const studentSet = new Set(studentArr)
|
||||
if (studentSet.size !== correctSet.size) return false
|
||||
for (const id of correctSet) {
|
||||
if (!studentSet.has(id)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.questionType === "judgment") {
|
||||
const correct = getJudgmentCorrectAnswer(input.questionContent)
|
||||
if (correct === null) return null
|
||||
if (typeof studentVal !== "boolean") return false
|
||||
return studentVal === correct
|
||||
}
|
||||
|
||||
if (input.questionType === "text") {
|
||||
const correctAnswers = getTextCorrectAnswers(input.questionContent)
|
||||
if (correctAnswers.length === 0) return null
|
||||
if (typeof studentVal !== "string") return false
|
||||
const normalizedStudent = normalizeText(studentVal)
|
||||
return correctAnswers.some((c) => normalizeText(c) === normalizedStudent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分数与满分推断对错状态
|
||||
*/
|
||||
export const getCorrectnessState = (input: {
|
||||
score: number | null
|
||||
maxScore: number
|
||||
}): CorrectnessState => {
|
||||
if (input.score === null) return "ungraded"
|
||||
if (input.score === input.maxScore) return "correct"
|
||||
if (input.score === 0) return "incorrect"
|
||||
return "partial"
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分输入项
|
||||
*/
|
||||
export interface AutoGradableAnswer {
|
||||
id: string
|
||||
questionId: string
|
||||
questionType: QuestionType
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
studentAnswer: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 对未判分的题目应用自动判分
|
||||
* - 已有分数(score !== null)的不覆盖
|
||||
* - 无标准答案的不判分
|
||||
* - 否则按 computeIsCorrect 给满分或 0 分
|
||||
*/
|
||||
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
|
||||
return incoming.map((a) => {
|
||||
if (a.score !== null) return a
|
||||
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||
return a
|
||||
}
|
||||
const isCorrect = computeIsCorrect({
|
||||
questionType: a.questionType,
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (isCorrect === null) return a
|
||||
return { ...a, score: isCorrect ? a.maxScore : 0 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生答案为可读字符串
|
||||
*/
|
||||
export const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
const v = extractAnswerValue(studentAnswer)
|
||||
if (typeof v === "string") return v
|
||||
if (typeof v === "boolean") return v ? "True" : "False"
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
|
||||
}
|
||||
if (v == null) return "—"
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z.object({
|
||||
sourceExamId: z.string().min(1),
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: z.string().optional(),
|
||||
dueAt: z.string().optional(),
|
||||
|
||||
@@ -228,7 +228,7 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
description: assignment.description,
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
sourceExamTitle: assignment.sourceExam?.title ?? null,
|
||||
structure: assignment.structure as unknown,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||
|
||||
export type HomeworkAssignmentStatus = "draft" | "published" | "archived"
|
||||
|
||||
export type HomeworkSubmissionStatus = "started" | "submitted" | "graded"
|
||||
|
||||
/** 学生作业进度状态 → Badge variant 映射(统一 not_started/in_progress/submitted/graded 颜色) */
|
||||
export const STUDENT_HOMEWORK_PROGRESS_VARIANT: StatusVariantMap<StudentHomeworkProgressStatus> = {
|
||||
not_started: "outline",
|
||||
in_progress: "secondary",
|
||||
submitted: "secondary",
|
||||
graded: "default",
|
||||
}
|
||||
|
||||
/** 学生作业进度状态 → 展示文本映射 */
|
||||
export const STUDENT_HOMEWORK_PROGRESS_LABEL: StatusLabelMap<StudentHomeworkProgressStatus> = {
|
||||
not_started: "Not Started",
|
||||
in_progress: "In Progress",
|
||||
submitted: "Submitted",
|
||||
graded: "Graded",
|
||||
}
|
||||
|
||||
export interface TeacherGradeTrendItem {
|
||||
id: string
|
||||
title: string
|
||||
@@ -14,8 +32,8 @@ export interface TeacherGradeTrendItem {
|
||||
|
||||
export interface HomeworkAssignmentListItem {
|
||||
id: string
|
||||
sourceExamId: string
|
||||
sourceExamTitle: string
|
||||
sourceExamId: string | null
|
||||
sourceExamTitle: string | null
|
||||
title: string
|
||||
status: HomeworkAssignmentStatus
|
||||
availableAt: string | null
|
||||
@@ -25,13 +43,23 @@ export interface HomeworkAssignmentListItem {
|
||||
maxAttempts: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/** 应提交学生数(targets 表计数) */
|
||||
targetCount: number
|
||||
/** 已提交学生数(submitted/graded 状态去重计数) */
|
||||
submittedCount: number
|
||||
/** 已批改学生数(graded 状态去重计数) */
|
||||
gradedCount: number
|
||||
/** 已批改提交的平均分(null 表示无批改数据) */
|
||||
averageScore: number | null
|
||||
/** 逾期未提交学生数(dueAt 已过且未提交) */
|
||||
overdueCount: number
|
||||
}
|
||||
|
||||
export interface HomeworkAssignmentReviewListItem {
|
||||
id: string
|
||||
title: string
|
||||
status: HomeworkAssignmentStatus
|
||||
sourceExamTitle: string
|
||||
sourceExamTitle: string | null
|
||||
dueAt: string | null
|
||||
targetCount: number
|
||||
submittedCount: number
|
||||
@@ -160,8 +188,8 @@ export type HomeworkAssignmentAnalytics = {
|
||||
title: string
|
||||
description: string | null
|
||||
status: HomeworkAssignmentStatus
|
||||
sourceExamId: string
|
||||
sourceExamTitle: string
|
||||
sourceExamId: string | null
|
||||
sourceExamTitle: string | null
|
||||
structure: unknown | null
|
||||
availableAt: string | null
|
||||
dueAt: string | null
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function PublishHomeworkDialog({
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const [availableAt, setAvailableAt] = useState("");
|
||||
const [dueAt, setDueAt] = useState("");
|
||||
@@ -44,6 +46,11 @@ export function PublishHomeworkDialog({
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.publish", {
|
||||
planId,
|
||||
blockId,
|
||||
classCount: selectedClasses.length,
|
||||
});
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
|
||||
165
src/modules/parent/components/child-homework-detail.tsx
Normal file
165
src/modules/parent/components/child-homework-detail.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import Link from "next/link"
|
||||
import { PenTool, TriangleAlert } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
STUDENT_HOMEWORK_PROGRESS_VARIANT,
|
||||
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
||||
} from "@/modules/homework/types"
|
||||
import type { ChildHomeworkSummaryData } from "@/modules/parent/types"
|
||||
|
||||
type DueUrgency = "overdue" | "urgent" | "normal"
|
||||
|
||||
const getDueUrgency = (dueAt: string | null, now: Date): DueUrgency | null => {
|
||||
if (!dueAt) return null
|
||||
const due = new Date(dueAt)
|
||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
if (diffHours < 0) return "overdue"
|
||||
if (diffHours < 48) return "urgent"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
const PROGRESS_LABEL: Record<string, string> = {
|
||||
not_started: "Not started",
|
||||
in_progress: "In progress",
|
||||
submitted: "Submitted",
|
||||
graded: "Graded",
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业详情视图:展示所有作业的完整信息(状态、截止时间、提交时间、分数、尝试次数)。
|
||||
* 用于详情页 homework tab,让家长查看子女作业全貌。
|
||||
*/
|
||||
export function ChildHomeworkDetail({
|
||||
summary,
|
||||
childId,
|
||||
childName,
|
||||
}: {
|
||||
summary: ChildHomeworkSummaryData
|
||||
childId: string
|
||||
childName: string
|
||||
}) {
|
||||
const hasAssignments = summary.recentAssignments.length > 0
|
||||
const now = new Date()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Homework
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-center">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground">Pending</div>
|
||||
<div className="text-lg font-semibold tabular-nums">{summary.pendingCount}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground">Submitted</div>
|
||||
<div className="text-lg font-semibold tabular-nums">{summary.submittedCount}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground">Graded</div>
|
||||
<div className="text-lg font-semibold tabular-nums">{summary.gradedCount}</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<TriangleAlert className="h-3 w-3" aria-hidden />
|
||||
Overdue
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-lg font-semibold tabular-nums",
|
||||
summary.overdueCount > 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{summary.overdueCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasAssignments ? (
|
||||
<EmptyState
|
||||
icon={PenTool}
|
||||
title="No assignments"
|
||||
description="No homework assigned right now."
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase text-muted-foreground">
|
||||
All Assignments
|
||||
</div>
|
||||
{summary.recentAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt, now)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
const isSubmitted = a.progressStatus === "submitted"
|
||||
const scoreText =
|
||||
a.latestScore !== null ? `${a.latestScore} pts` : isGraded ? "Graded" : "-"
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="rounded-md border bg-card p-3 space-y-2 min-h-[88px]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{a.subjectName ? (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{a.subjectName}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="font-medium text-sm truncate">{a.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums shrink-0">
|
||||
{scoreText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<StatusBadge
|
||||
status={a.progressStatus}
|
||||
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
|
||||
labelMap={STUDENT_HOMEWORK_PROGRESS_LABEL}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
<span aria-label="Progress status">
|
||||
{PROGRESS_LABEL[a.progressStatus] ?? a.progressStatus}
|
||||
</span>
|
||||
{a.dueAt ? (
|
||||
<span
|
||||
className={cn(
|
||||
!isSubmitted && !isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||
)}
|
||||
>
|
||||
Due {formatDate(a.dueAt)}
|
||||
</span>
|
||||
) : null}
|
||||
{a.latestSubmittedAt ? (
|
||||
<span>Submitted {formatDate(a.latestSubmittedAt)}</span>
|
||||
) : null}
|
||||
<span className="tabular-nums">
|
||||
Attempts {a.attemptsUsed}/{a.maxAttempts}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/parent/children/${childId}?tab=homework`}
|
||||
className="inline-flex min-h-[36px] items-center text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -4,37 +4,14 @@ import { PenTool, TriangleAlert } from "lucide-react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentHomeworkProgressStatus } from "@/modules/homework/types"
|
||||
import {
|
||||
STUDENT_HOMEWORK_PROGRESS_VARIANT,
|
||||
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
||||
} from "@/modules/homework/types"
|
||||
import type { ChildHomeworkSummaryData } from "@/modules/parent/types"
|
||||
|
||||
const getStatusVariant = (
|
||||
status: StudentHomeworkProgressStatus,
|
||||
): "default" | "secondary" | "outline" => {
|
||||
switch (status) {
|
||||
case "graded":
|
||||
return "default"
|
||||
case "submitted":
|
||||
case "in_progress":
|
||||
return "secondary"
|
||||
case "not_started":
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: StudentHomeworkProgressStatus): string => {
|
||||
switch (status) {
|
||||
case "graded":
|
||||
return "Graded"
|
||||
case "submitted":
|
||||
return "Submitted"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
case "not_started":
|
||||
return "Not started"
|
||||
}
|
||||
}
|
||||
|
||||
type DueUrgency = "overdue" | "urgent" | "normal"
|
||||
|
||||
const getDueUrgency = (dueAt: string | null, now: Date): DueUrgency | null => {
|
||||
@@ -63,7 +40,7 @@ export function ChildHomeworkSummary({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||
<PenTool className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Homework
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -83,7 +60,7 @@ export function ChildHomeworkSummary({
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
<TriangleAlert className="h-3 w-3" aria-hidden />
|
||||
Overdue
|
||||
</div>
|
||||
<div
|
||||
@@ -112,18 +89,29 @@ export function ChildHomeworkSummary({
|
||||
{summary.recentAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt, now)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
const scoreText = a.latestScore !== null ? `${a.latestScore} pts` : isGraded ? "Graded" : "-"
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/parent/children/${childId}?tab=homework`}
|
||||
className="flex items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors"
|
||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="font-medium text-sm truncate">{a.title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{a.subjectName ? (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{a.subjectName}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className="font-medium text-sm truncate">{a.title}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="text-[10px]">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
status={a.progressStatus}
|
||||
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
|
||||
labelMap={STUDENT_HOMEWORK_PROGRESS_LABEL}
|
||||
className="text-[10px]"
|
||||
/>
|
||||
{a.dueAt ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -136,14 +124,14 @@ export function ChildHomeworkSummary({
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums shrink-0 ml-2">
|
||||
{a.latestScore ?? "-"}
|
||||
{scoreText}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<Link
|
||||
href={`/parent/children/${childId}?tab=homework`}
|
||||
className="block text-center text-xs text-muted-foreground hover:text-foreground transition-colors pt-1"
|
||||
className="block text-center text-xs text-muted-foreground hover:text-foreground transition-colors pt-1 min-h-[36px] flex items-center justify-center"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { type Control, type FieldPath, useWatch } from "react-hook-form"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
} from "@/shared/components/ui/card"
|
||||
|
||||
import type { ExamMode } from "../types"
|
||||
import { EXAM_MODE_LABELS } from "../types"
|
||||
|
||||
const EXAM_MODES: ExamMode[] = ["homework", "timed", "proctored"]
|
||||
|
||||
@@ -32,7 +32,7 @@ const EXAM_MODES: ExamMode[] = ["homework", "timed", "proctored"]
|
||||
*/
|
||||
export interface ExamModeConfigFieldValues {
|
||||
examMode: ExamMode
|
||||
durationMinutes: number | null
|
||||
durationMinutes?: number | null
|
||||
shuffleQuestions: boolean
|
||||
allowLateStart: boolean
|
||||
lateStartGraceMinutes: number
|
||||
@@ -47,6 +47,7 @@ type ExamModeConfigProps<T extends ExamModeConfigFieldValues> = {
|
||||
export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
control,
|
||||
}: ExamModeConfigProps<T>) {
|
||||
const t = useTranslations("examHomework")
|
||||
const examMode = useWatch({ control, name: "examMode" as FieldPath<T> }) as ExamMode
|
||||
const showDuration = examMode === "timed" || examMode === "proctored"
|
||||
const showProctorOptions = examMode === "proctored"
|
||||
@@ -54,10 +55,8 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>考试模式</CardTitle>
|
||||
<CardDescription>
|
||||
选择考试模式并配置相关选项。监考模式会启用防作弊监控。
|
||||
</CardDescription>
|
||||
<CardTitle>{t("proctoring.mode.title")}</CardTitle>
|
||||
<CardDescription>{t("proctoring.mode.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<FormField
|
||||
@@ -65,7 +64,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
name={"examMode" as FieldPath<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>模式</FormLabel>
|
||||
<FormLabel>{t("proctoring.mode.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value as ExamMode}
|
||||
@@ -85,12 +84,10 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">
|
||||
{EXAM_MODE_LABELS[mode]}
|
||||
{t(`proctoring.mode.${mode}`)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mode === "homework" && "学生可在任意时间作答,无时间限制"}
|
||||
{mode === "timed" && "限时作答,到时自动提交"}
|
||||
{mode === "proctored" && "限时作答 + 防作弊监控 + 强制全屏"}
|
||||
{t(`proctoring.mode.${mode}Description`)}
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
@@ -109,11 +106,11 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>考试时长(分钟)</FormLabel>
|
||||
<FormLabel>{t("proctoring.config.duration")}</FormLabel>
|
||||
<FormDescription>
|
||||
{examMode === "proctored"
|
||||
? "监考模式下必须设置考试时长"
|
||||
: "学生开始作答后,到时自动提交"}
|
||||
? t("proctoring.config.durationProctoredDescription")
|
||||
: t("proctoring.config.durationTimedDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -129,6 +126,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
aria-label={t("proctoring.config.duration")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -145,13 +143,14 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>题目乱序</FormLabel>
|
||||
<FormDescription>每位学生看到的题目顺序随机</FormDescription>
|
||||
<FormLabel>{t("proctoring.config.shuffleQuestions")}</FormLabel>
|
||||
<FormDescription>{t("proctoring.config.shuffleQuestionsDescription")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.shuffleQuestions")}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -164,15 +163,14 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>启用防作弊监控</FormLabel>
|
||||
<FormDescription>
|
||||
监听切屏、复制、右键、开发者工具等异常行为
|
||||
</FormDescription>
|
||||
<FormLabel>{t("proctoring.config.antiCheat")}</FormLabel>
|
||||
<FormDescription>{t("proctoring.config.antiCheatDescription")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.antiCheat")}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -185,13 +183,14 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>允许迟开始</FormLabel>
|
||||
<FormDescription>允许学生在考试开始后一段时间内进入</FormDescription>
|
||||
<FormLabel>{t("proctoring.config.allowLateStart")}</FormLabel>
|
||||
<FormDescription>{t("proctoring.config.allowLateStartDescription")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-label={t("proctoring.config.allowLateStart")}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -204,8 +203,8 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>迟到宽限时间(分钟)</FormLabel>
|
||||
<FormDescription>超过此时间后不允许新学生进入</FormDescription>
|
||||
<FormLabel>{t("proctoring.config.lateStartGrace")}</FormLabel>
|
||||
<FormDescription>{t("proctoring.config.lateStartGraceDescription")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -216,6 +215,7 @@ export function ExamModeConfig<T extends ExamModeConfigFieldValues>({
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
aria-label={t("proctoring.config.lateStartGrace")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn, formatDateTime } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -42,10 +42,7 @@ const REFRESH_INTERVAL_MS = 10_000
|
||||
|
||||
const formatTime = (iso: string | null): string => {
|
||||
if (!iso) return "—"
|
||||
return new Date(iso).toLocaleString("zh-CN", {
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
month: "2-digit", day: "2-digit",
|
||||
})
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
|
||||
const statusBadge = (status: string | null) => {
|
||||
|
||||
Reference in New Issue
Block a user