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:
SpecialX
2026-06-22 18:36:46 +08:00
parent f62b8c0f86
commit 682d385ee2
41 changed files with 4387 additions and 1979 deletions

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form"
import { getExams } from "@/modules/exams/data-access"
import { getTeacherClasses } from "@/modules/classes/data-access"
@@ -7,7 +8,7 @@ import { FileQuestion } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function CreateHomeworkAssignmentPage() {
export default async function CreateHomeworkAssignmentPage(): Promise<JSX.Element> {
const { dataScope } = await getAuthContext()
const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()])
const options = exams.map((e) => ({ id: e.id, title: e.title }))
@@ -16,19 +17,12 @@ export default async function CreateHomeworkAssignmentPage() {
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Create Assignment</h2>
<p className="text-muted-foreground">Dispatch homework from an existing exam.</p>
<h1 className="text-2xl font-bold tracking-tight">Create Assignment</h1>
<p className="text-muted-foreground"></p>
</div>
</div>
{options.length === 0 ? (
<EmptyState
title="No exams available"
description="Create an exam first, then dispatch it as homework."
icon={FileQuestion}
action={{ label: "Create Exam", href: "/teacher/exams/create" }}
/>
) : classes.length === 0 ? (
{classes.length === 0 ? (
<EmptyState
title="No classes available"
description="Create a class first, then publish homework to that class."

View File

@@ -10,33 +10,45 @@ import {
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
import { formatDate } from "@/shared/lib/utils"
import { type SearchParams } from "@/shared/lib/search-params"
import { getHomeworkAssignmentReviewList } from "@/modules/homework/data-access"
import { Inbox } from "lucide-react"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function SubmissionsPage(): Promise<JSX.Element> {
const PAGE_SIZE = 10
export default async function SubmissionsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
const sp = await searchParams
const creatorId = await getTeacherIdForMutations()
const assignments = await getHomeworkAssignmentReviewList({ creatorId })
const hasAssignments = assignments.length > 0
// 分页计算
const { page } = computePagination(sp, PAGE_SIZE)
const total = assignments.length
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.min(page, totalPages)
const pagedAssignments = paginate(assignments, currentPage, PAGE_SIZE)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h1 className="text-2xl font-bold tracking-tight">Submissions</h1>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
Review homework by assignment.
</p>
</div>
</div>
{!hasAssignments ? (
<EmptyState
title="No assignments"
description="There are no homework assignments to review yet."
title="暂无作业"
description="还没有可批改的作业。"
icon={Inbox}
/>
) : (
@@ -44,39 +56,59 @@ export default async function SubmissionsPage(): Promise<JSX.Element> {
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead className="text-right">Targets</TableHead>
<TableHead className="text-right">Submitted</TableHead>
<TableHead className="text-right">Graded</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link
href={`/teacher/homework/assignments/${a.id}/submissions`}
className="hover:underline line-clamp-2 max-w-[240px]"
>
{a.title}
</Link>
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
</TableRow>
))}
{pagedAssignments.map((a) => {
const submissionRate = a.targetCount > 0 ? (a.submittedCount / a.targetCount) * 100 : 0
return (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link
href={`/teacher/homework/assignments/${a.id}/submissions`}
className="hover:underline line-clamp-2 max-w-[240px]"
>
{a.title}
</Link>
{a.sourceExamTitle ? (
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</div>
) : (
<div className="text-xs text-muted-foreground italic"></div>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
<TableCell className="text-right tabular-nums">
{a.targetCount > 0 ? `${submissionRate.toFixed(0)}%` : "-"}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<ListPagination
page={currentPage}
pageSize={PAGE_SIZE}
total={total}
totalPages={totalPages}
basePath="/teacher/homework/submissions"
searchParams={sp}
itemLabel="个作业"
/>
</div>
)}
</div>

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
/**
* AI 试卷生成管线(入口模块)
*
* 本目录由三个子模块组成:
* - parse.ts: Zod schema、JSON 解析、纯转换函数、提示词
* - request.ts: AI 请求构造与发送
* - structure.ts: 结构生成与预览/草稿转换
*
* 本文件负责:
* - 重新导出公共 APIschema、类型、函数
* - 编排高层流程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)
}

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

View 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) }
}
}

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

View File

@@ -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"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View 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>
)
}

View File

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

View File

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

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

View File

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

View File

@@ -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 = {

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

View 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")
})
})

View 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)
}

View File

@@ -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(),

View File

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

View File

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

View File

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

View 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}&apos;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>
)
}

View File

@@ -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}&apos;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>

View File

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

View File

@@ -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) => {

View File

@@ -0,0 +1,115 @@
/**
* 6.5: 单测覆盖 ExamHomeworkRoleConfig 角色特性合并逻辑
*/
import { describe, it, expect } from "vitest"
import {
getExamHomeworkFeatures,
EXAM_HOMEWORK_ROLE_CONFIG,
DEFAULT_EXAM_HOMEWORK_FEATURES,
type ExamHomeworkRoleFeatures,
} from "./exam-homework-role-config"
describe("EXAM_HOMEWORK_ROLE_CONFIG", () => {
it("admin has all management features but not student take", () => {
const admin = EXAM_HOMEWORK_ROLE_CONFIG.admin
expect(admin.canCreate).toBe(true)
expect(admin.canManage).toBe(true)
expect(admin.canPublish).toBe(true)
expect(admin.canGrade).toBe(true)
expect(admin.canProctor).toBe(true)
expect(admin.canUseAi).toBe(true)
expect(admin.canViewStats).toBe(true)
expect(admin.canTake).toBe(false)
})
it("teacher has create/grade/build but not student take", () => {
const teacher = EXAM_HOMEWORK_ROLE_CONFIG.teacher
expect(teacher.canCreate).toBe(true)
expect(teacher.canBuild).toBe(true)
expect(teacher.canGrade).toBe(true)
expect(teacher.canUseAi).toBe(true)
expect(teacher.canTake).toBe(false)
})
it("student has take and viewResult but not manage/create", () => {
const student = EXAM_HOMEWORK_ROLE_CONFIG.student
expect(student.canTake).toBe(true)
expect(student.canViewResult).toBe(true)
expect(student.canCreate).toBe(false)
expect(student.canManage).toBe(false)
expect(student.canGrade).toBe(false)
expect(student.canPublish).toBe(false)
})
it("grade_head can grade/publish/proctor but not create/build", () => {
const gh = EXAM_HOMEWORK_ROLE_CONFIG.grade_head
expect(gh.canGrade).toBe(true)
expect(gh.canPublish).toBe(true)
expect(gh.canProctor).toBe(true)
expect(gh.canCreate).toBe(false)
expect(gh.canBuild).toBe(false)
expect(gh.canUseAi).toBe(false)
})
it("parent can only view results", () => {
const parent = EXAM_HOMEWORK_ROLE_CONFIG.parent
expect(parent.canViewResult).toBe(true)
expect(parent.canCreate).toBe(false)
expect(parent.canRead).toBe(false)
expect(parent.canTake).toBe(false)
expect(parent.canGrade).toBe(false)
})
})
describe("getExamHomeworkFeatures", () => {
it("returns default (all-false) for empty roles", () => {
const features = getExamHomeworkFeatures([])
expect(features).toEqual(DEFAULT_EXAM_HOMEWORK_FEATURES)
})
it("returns admin features for admin role", () => {
const features = getExamHomeworkFeatures(["admin"])
expect(features.canCreate).toBe(true)
expect(features.canManage).toBe(true)
expect(features.canTake).toBe(false)
})
it("returns student features for student role", () => {
const features = getExamHomeworkFeatures(["student"])
expect(features.canTake).toBe(true)
expect(features.canCreate).toBe(false)
})
it("merges features via union for multiple roles", () => {
// A user with both teacher and student roles should have both canCreate and canTake
const features = getExamHomeworkFeatures(["teacher", "student"])
expect(features.canCreate).toBe(true)
expect(features.canGrade).toBe(true)
expect(features.canTake).toBe(true)
expect(features.canViewResult).toBe(true)
})
it("ignores unknown roles gracefully", () => {
// Cast to bypass type-check for testing runtime safety
const features = getExamHomeworkFeatures(["unknown_role" as never])
expect(features).toEqual(DEFAULT_EXAM_HOMEWORK_FEATURES)
})
it("DEFAULT_EXAM_HOMEWORK_FEATURES has all features disabled", () => {
const allFalse: ExamHomeworkRoleFeatures = {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: false,
canUseAi: false,
canViewStats: false,
}
expect(DEFAULT_EXAM_HOMEWORK_FEATURES).toEqual(allFalse)
})
})

View File

@@ -0,0 +1,223 @@
/**
* P2-13: 配置驱动的角色渲染 ExamHomeworkRoleConfig
*
* 单一数据源:声明每个角色在考试/作业模块中可见的功能区与可执行的操作。
* 配合 usePermission() 使用,作为 UI 渲染的防御性筛选层(权限系统仍为最终授权源)。
*
* 使用方式:
* const features = getRoleFeatures(roles)
* if (features.canCreateExam) { render <CreateExamButton /> }
*/
import type { Permission, Role } from "@/shared/types/permissions"
import { Permissions } from "@/shared/types/permissions"
/**
* 考试/作业模块中按角色控制的功能区。
* 字段语义:`can*` 表示是否可见/可执行某类操作。
*/
export interface ExamHomeworkRoleFeatures {
/** 创建考试/作业(教师、管理员) */
canCreate: boolean
/** 查看考试/作业列表(所有有读权限的角色) */
canRead: boolean
/** 编辑/删除考试/作业(教师、管理员) */
canManage: boolean
/** 发布考试(教师、年级组长、教务主任) */
canPublish: boolean
/** 组卷/编辑作业题目(教师、管理员) */
canBuild: boolean
/** 批改作业/考试(教师、年级组长、教务主任) */
canGrade: boolean
/** 监考(教师、年级组长、教务主任) */
canProctor: boolean
/** 学生作答(学生) */
canTake: boolean
/** 查看成绩/反馈(学生、家长) */
canViewResult: boolean
/** AI 生成考试(教师、管理员) */
canUseAi: boolean
/** 查看统计/分析(教师、年级组长、教务主任、管理员) */
canViewStats: boolean
}
/**
* 角色到权限点的映射,作为渲染配置的依据。
* 每个角色对应一组 Permission 常量,用于推导 features。
*/
export const EXAM_HOMEWORK_ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.HOMEWORK_SUBMIT,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
],
grade_head: [
Permissions.EXAM_READ,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_GRADE,
],
teaching_head: [
Permissions.EXAM_READ,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_GRADE,
],
student: [
Permissions.EXAM_READ,
Permissions.EXAM_SUBMIT,
Permissions.HOMEWORK_SUBMIT,
],
parent: [],
} as const
/**
* 角色到功能区配置的映射。
* 这是渲染层的单一数据源UI 根据 features 决定显示哪些入口/按钮。
*/
export const EXAM_HOMEWORK_ROLE_CONFIG: Record<Role, ExamHomeworkRoleFeatures> = {
admin: {
canCreate: true,
canRead: true,
canManage: true,
canPublish: true,
canBuild: true,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: true,
canViewStats: true,
},
teacher: {
canCreate: true,
canRead: true,
canManage: true,
canPublish: true,
canBuild: true,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: true,
canViewStats: true,
},
grade_head: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: true,
canBuild: false,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: true,
},
teaching_head: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: true,
canBuild: false,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: true,
},
student: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: true,
canViewResult: true,
canUseAi: false,
canViewStats: false,
},
parent: {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: false,
},
}
/**
* 默认(无角色)特性集:所有功能关闭。
* 用于未登录或角色未识别的兜底场景。
*/
export const DEFAULT_EXAM_HOMEWORK_FEATURES: ExamHomeworkRoleFeatures = {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: false,
canUseAi: false,
canViewStats: false,
}
/**
* 根据用户角色列表合并出最终的功能特性。
*
* 合并策略:任一角色拥有某功能即视为启用(并集)。
* 例如:同时具有 teacher + grade_head 的用户,将获得两个角色的所有能力。
*
* @param roles 当前用户的角色列表
* @returns 合并后的功能特性
*/
export function getExamHomeworkFeatures(roles: readonly Role[]): ExamHomeworkRoleFeatures {
if (roles.length === 0) return DEFAULT_EXAM_HOMEWORK_FEATURES
const merged: ExamHomeworkRoleFeatures = { ...DEFAULT_EXAM_HOMEWORK_FEATURES }
for (const role of roles) {
const cfg = EXAM_HOMEWORK_ROLE_CONFIG[role]
if (!cfg) continue
merged.canCreate = merged.canCreate || cfg.canCreate
merged.canRead = merged.canRead || cfg.canRead
merged.canManage = merged.canManage || cfg.canManage
merged.canPublish = merged.canPublish || cfg.canPublish
merged.canBuild = merged.canBuild || cfg.canBuild
merged.canGrade = merged.canGrade || cfg.canGrade
merged.canProctor = merged.canProctor || cfg.canProctor
merged.canTake = merged.canTake || cfg.canTake
merged.canViewResult = merged.canViewResult || cfg.canViewResult
merged.canUseAi = merged.canUseAi || cfg.canUseAi
merged.canViewStats = merged.canViewStats || cfg.canViewStats
}
return merged
}

View File

@@ -0,0 +1,18 @@
"use client"
/**
* P2-13: 客户端 Hook封装 ExamHomeworkRoleConfig 与 usePermission。
*
* 使用方式:
* const features = useExamHomeworkFeatures()
* if (features.canCreate) { render <CreateButton /> }
*/
import { useMemo } from "react"
import { usePermission } from "@/shared/hooks/use-permission"
import { getExamHomeworkFeatures } from "@/shared/config/exam-homework-role-config"
export function useExamHomeworkFeatures() {
const { roles } = usePermission()
return useMemo(() => getExamHomeworkFeatures(roles), [roles])
}

View File

@@ -44,7 +44,8 @@
"2": "Easy-Med",
"3": "Medium",
"4": "Med-Hard",
"5": "Hard"
"5": "Hard",
"ariaLabel": "Difficulty level {{level}}: {{label}}"
},
"actions": {
"preview": "Preview Exam",
@@ -191,6 +192,8 @@
"noDescription": "No description provided.",
"progress": "Progress",
"jumpToQuestion": "Jump to question {{index}}",
"answered": "Answered",
"unanswered": "Not answered",
"yourAnswer": "Your answer",
"answerPlaceholder": "Type your answer here...",
"true": "True",
@@ -198,7 +201,13 @@
"unsupportedType": "Unsupported question type",
"teacherFeedback": "Teacher Feedback",
"noFeedback": "No specific feedback provided.",
"makeSureAnswered": "Make sure you have answered all questions."
"makeSureAnswered": "Make sure you have answered all questions.",
"autoSaveIdle": "Auto-save ready",
"autoSaveSaving": "Auto-saving...",
"autoSaveSaved": "Auto-saved",
"autoSaveError": "Auto-save failed. Will retry when network recovers.",
"autoSaveRestored": "Restored unsaved answers from offline cache",
"autoSaveCacheError": "Failed to restore offline cache"
},
"grade": {
"title": "Grade",
@@ -248,7 +257,19 @@
"correctAnswer": "Correct Answer",
"teacherFeedback": "Teacher Feedback",
"score": "Score",
"maxScore": "Max Score"
"maxScore": "Max Score",
"correctMarker": "✓ Correct",
"backToList": "Back to List",
"gradedReport": "Graded Report",
"submissionDetails": "Submission Details",
"questionsUnit": "Questions",
"noAnswer": "No answer provided",
"noFeedback": "No specific feedback provided.",
"questionBreakdown": "Question Breakdown",
"responseSummary": "Response Summary",
"description": "Description",
"noDescription": "No description provided.",
"totalScore": "Total Score"
},
"status": {
"draft": "Draft",

View File

@@ -44,7 +44,8 @@
"2": "偏易",
"3": "中等",
"4": "偏难",
"5": "困难"
"5": "困难",
"ariaLabel": "难度等级 {{level}}{{label}}"
},
"actions": {
"preview": "预览考试",
@@ -191,6 +192,8 @@
"noDescription": "无描述。",
"progress": "进度",
"jumpToQuestion": "跳转到第 {{index}} 题",
"answered": "已作答",
"unanswered": "未作答",
"yourAnswer": "你的答案",
"answerPlaceholder": "在此输入答案...",
"true": "正确",
@@ -198,7 +201,13 @@
"unsupportedType": "不支持的题型",
"teacherFeedback": "教师反馈",
"noFeedback": "无具体反馈。",
"makeSureAnswered": "请确保已作答所有题目。"
"makeSureAnswered": "请确保已作答所有题目。",
"autoSaveIdle": "自动保存已就绪",
"autoSaveSaving": "正在自动保存...",
"autoSaveSaved": "已自动保存",
"autoSaveError": "自动保存失败,将在网络恢复后重试",
"autoSaveRestored": "已从离线缓存恢复未提交的答案",
"autoSaveCacheError": "离线缓存恢复失败"
},
"grade": {
"title": "批改",
@@ -248,7 +257,19 @@
"correctAnswer": "正确答案",
"teacherFeedback": "教师反馈",
"score": "得分",
"maxScore": "满分"
"maxScore": "满分",
"correctMarker": "✓ 正确",
"backToList": "返回列表",
"gradedReport": "批改报告",
"submissionDetails": "提交详情",
"questionsUnit": "道题",
"noAnswer": "未作答",
"noFeedback": "无具体反馈。",
"questionBreakdown": "题目分布",
"responseSummary": "作答概览",
"description": "描述",
"noDescription": "无描述。",
"totalScore": "总分"
},
"status": {
"draft": "草稿",

View File

@@ -40,6 +40,24 @@ export type EventName =
| "elective.course_selected"
| "elective.course_dropped"
| "elective.lottery_completed"
// 6.7: 考试/作业模块监控事件
| "exam.created"
| "exam.updated"
| "exam.published"
| "exam.archived"
| "exam.deleted"
| "exam.duplicated"
| "exam.ai_generated"
| "exam.submitted"
| "exam.graded"
| "homework.created"
| "homework.updated"
| "homework.published"
| "homework.archived"
| "homework.deleted"
| "homework.submitted"
| "homework.graded"
| "homework.auto_save_failed"
/** 埋点事件负载 */
export interface TrackEventPayload {
@@ -90,3 +108,36 @@ export async function trackEvent(payload: TrackEventPayload): Promise<void> {
// 埋点失败不影响主流程
}
}
/**
* 6.7: 考试/作业模块专用埋点函数
*
* 封装 trackEvent自动设置 targetType简化调用方代码。
*
* @example
* ```ts
* await trackExamEvent("exam.published", { userId: ctx.userId, targetId: examId })
* await trackExamEvent("homework.submitted", {
* userId: ctx.userId,
* targetId: submissionId,
* properties: { questionCount, duration: 1200 }
* })
* ```
*/
export async function trackExamEvent(
event: Extract<EventName, `exam.${string}` | `homework.${string}`>,
params: {
userId?: string
targetId?: string
properties?: Record<string, unknown>
}
): Promise<void> {
const targetType = event.startsWith("exam.") ? "exam" : "homework"
await trackEvent({
event,
userId: params.userId,
targetId: params.targetId,
targetType,
properties: params.properties,
})
}

View File

@@ -0,0 +1,112 @@
/**
* 6.1: ExamHomeworkServicePort — 考试/作业模块的服务端口接口
*
* 目的:
* - 为 app 层提供一个稳定的调用契约,屏蔽 modules 内部实现细节
* - 便于单元测试时注入 mock 实现(无需真实 DB
* - 为未来可能的远程服务BFF/API gateway预留替换点
*
* 使用方式:
* import { EXAM_HOMEWORK_SERVICE_PROVIDER, type ExamHomeworkServicePort } from "@/shared/services/exam-homework-port"
* const service = EXAM_HOMEWORK_SERVICE_PROVIDER.get()
* const exam = await service.getExamById(id, scope)
*
* 实际实现位于 modules/exams/data-access.ts 与 modules/homework/data-access.ts
* 通过 registerExamHomeworkService() 在应用启动时注入。
*/
import type { DataScope } from "@/shared/types/permissions"
import type { Exam } from "@/modules/exams/types"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
/**
* 考试/作业模块对外暴露的服务契约。
* 仅包含 app 层和跨模块调用所需的方法,不暴露内部实现细节。
*/
export interface ExamHomeworkServicePort {
// ===== 考试 =====
/** 按 ID 获取考试(带数据范围过滤) */
getExamById(id: string, scope?: DataScope): Promise<Exam | null>
/** 获取考试列表 */
getExams(params: {
scope?: DataScope
subjectId?: string
gradeId?: string
status?: string
search?: string
}): Promise<Exam[]>
/** 获取考试创建者 ID用于权限校验 */
getExamCreatorId(examId: string): Promise<string | null>
/** 获取考试标题(跨模块引用时使用,避免全量加载) */
getExamTitleById(examId: string): Promise<string | null>
// ===== 作业 =====
/** 按 ID 获取作业 */
getHomeworkAssignmentById(id: string, scope?: DataScope): Promise<HomeworkAssignmentListItem | null>
/** 获取教师创建的作业列表 */
getHomeworkAssignments(params: {
creatorId?: string
classId?: string
scope?: DataScope
}): Promise<HomeworkAssignmentListItem[]>
/** 获取作业的最大分值(批量) */
getAssignmentMaxScoreByIds(assignmentIds: string[]): Promise<Map<string, number>>
// ===== 跨模块 =====
/** 获取考试及其题目(供作业模块引用考试内容时使用) */
getExamWithQuestionsForHomework(examId: string): Promise<{
exam: Pick<Exam, "id" | "title" | "totalScore" | "durationMin">
questions: Array<{
id: string
questionType: string
questionContent: unknown
maxScore: number
}>
} | null>
}
/**
* 服务提供者:单例注册 + 解析。
*
* - `register(impl)` 在应用启动时调用一次(如 instrumentation.ts
* - `get()` 在运行时获取当前实现
* - 未注册时抛出明确错误,避免静默失败
*/
class ServiceProvider<T> {
private impl: T | null = null
register(impl: T): void {
if (this.impl !== null) {
// 允许重复注册HMR 场景),覆盖旧实现
if (process.env.NODE_ENV !== "production") {
console.warn("[ServiceProvider] Re-registering service implementation (HMR?)")
}
}
this.impl = impl
}
get(): T {
if (this.impl === null) {
throw new Error(
"[ExamHomeworkServicePort] No implementation registered. " +
"Call registerExamHomeworkService() during application startup."
)
}
return this.impl
}
/** 测试专用:重置为未注册状态 */
reset(): void {
this.impl = null
}
}
export const EXAM_HOMEWORK_SERVICE_PROVIDER = new ServiceProvider<ExamHomeworkServicePort>()
/**
* 注册考试/作业服务实现。
* 应在应用启动时调用(如 instrumentation.ts 或模块初始化)。
*/
export function registerExamHomeworkService(impl: ExamHomeworkServicePort): void {
EXAM_HOMEWORK_SERVICE_PROVIDER.register(impl)
}