feat(exams): add rich editor actions, auto-mark, preview, services, and config

- Add actions-helpers and actions-rich-editor for rich text exam editing

- Add ai-pipeline/auto-mark for automatic exam marking

- Add exam-boundaries and exam-preview components

- Add config directory for exam configuration

- Add data-access-cross-module for cross-module data access

- Add editor/exam-nodes-to-editor-doc and editor/utils for editor utilities

- Add use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks hooks

- Add services directory
This commit is contained in:
SpecialX
2026-07-03 10:25:11 +08:00
parent e85a5f05dd
commit a16f09d3c3
49 changed files with 3709 additions and 2074 deletions

View File

@@ -0,0 +1,104 @@
/**
* 考试 Server Action 共享辅助函数(非 "use server" 文件,可导出非异步工具函数)。
*
* 供 actions.ts / actions-rich-editor.ts / ai-pipeline/auto-mark.ts 共同使用。
*/
import { createId } from "@paralleldrive/cuid2"
import { z } from "zod"
import type { ActionState } from "@/shared/types/action-state"
import {
buildExamDescription,
resolveSubjectGradeNames,
type ExamModeConfig,
} from "./data-access"
export const getStringValue = (formData: FormData, key: string): string | undefined => {
const value = formData.get(key)
return typeof value === "string" ? value : undefined
}
export const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => {
const value = formData.get(key)
if (typeof value !== "string") return fallback
return value === "true"
}
export 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
const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0"
const parsedGrace = Number(rawGrace)
const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0
return {
examMode,
durationMinutes,
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
allowLateStart: getBoolValue(formData, "allowLateStart", false),
lateStartGraceMinutes,
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
}
}
export const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
success: false,
message,
errors,
})
export const successState = <T>(data: T, message?: string): ActionState<T> => ({
success: true,
message,
data,
})
export const invalidFormState = <T>(
error: z.ZodError,
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
): ActionState<T> => {
const errors = error.flatten().fieldErrors
const fallbackMessage = options?.fallbackMessage ?? "Invalid form data"
const useFirstMessage = options?.useFirstMessage ?? true
const messages = Object.values(errors).flatMap((items) => items ?? [])
const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0)
return failState<T>(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors)
}
export const prepareExamCreateContext = async (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string | null
}): Promise<{
examId: string
scheduled: string | undefined
subjectName: string
gradeName: string
buildDescription: (options?: { questionCount?: number }) => string
}> => {
const examId = createId()
const scheduled = input.scheduledAt || undefined
const resolvedNames = await resolveSubjectGradeNames({
subjectId: input.subject,
gradeId: input.grade,
})
const subjectName = resolvedNames.subjectName ?? input.subject
const gradeName = resolvedNames.gradeName ?? input.grade
const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({
subject: subjectName,
grade: gradeName,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: scheduled,
questionCount: options?.questionCount,
})
return { examId, scheduled, subjectName, gradeName, buildDescription }
}

View File

@@ -0,0 +1,292 @@
"use server"
/**
* 富文本编辑器 Server Actions —— 从 exams/actions.ts 拆分而来。
*
* 职责:
* - createExamFromRichEditorAction从富文本编辑器保存试卷草稿
* - updateExamFromRichEditorAction从富文本编辑器更新已有试卷P0-4 修复DB 事务下沉到 data-access
* - 共享辅助函数convertStructureNode递归转换 EditorStructureNode → AiGeneratedStructureNode
*/
import { revalidatePath } from "next/cache"
import { getTranslations } from "next-intl/server"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
import { createQuestionWithRelations } from "@/modules/questions/data-access"
import {
getExamCreatorId,
persistAiGeneratedExamDraft,
updateExamWithRichEditorData,
} from "./data-access"
import type { AiGeneratedStructureNode } from "./ai-pipeline"
import type { QuestionContentResult } from "./ai-pipeline/parse"
import type { EditorDoc, EditorStructureNode } from "./editor/exam-rich-editor-types"
import { toStandaloneQuestionType } from "./editor/exam-rich-editor-types"
import {
failState,
getStringValue,
invalidFormState,
parseExamModeConfig,
prepareExamCreateContext,
successState,
} from "./actions-helpers"
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
const RichExamCreateSchema = z.object({
title: z.string().min(1, "标题不能为空"),
subject: z.string().min(1),
grade: z.string().min(1),
difficulty: z.coerce.number().int().min(1).max(5),
totalScore: z.coerce.number().int().min(1),
durationMin: z.coerce.number().int().min(1),
scheduledAt: z.string().optional().nullable(),
/** Tiptap JSONContent 文档(JSON 字符串) */
editorDoc: z.string().min(1, "试卷内容不能为空"),
})
const RichExamUpdateSchema = z.object({
examId: z.string().min(1),
title: z.string().min(1, "标题不能为空"),
editorDoc: z.string().min(1, "试卷内容不能为空"),
})
// ---------------------------------------------------------------------------
// 共享辅助函数
// ---------------------------------------------------------------------------
/**
* 递归转换 structure 节点,保留已有 questionId。
* 被 createExamFromRichEditorAction 和 updateExamFromRichEditorAction 共享。
*/
const convertStructureNode = (node: EditorStructureNode): AiGeneratedStructureNode => {
if (node.type === "section") {
return {
id: node.id,
type: "section",
title: node.title ?? "",
level: node.level,
children: (node.children ?? []).map(convertStructureNode),
}
}
if (node.type === "group") {
return {
id: node.id,
type: "group",
title: node.title ?? "",
instruction: node.instruction,
children: (node.children ?? []).map(convertStructureNode),
}
}
return {
id: node.id,
type: "question",
questionId: node.questionId ?? "",
score: node.score ?? 0,
}
}
// ---------------------------------------------------------------------------
// Server Actions
// ---------------------------------------------------------------------------
/**
* 从富文本编辑器保存试卷草稿。
* 将 Tiptap JSONContent 转换为 questions + structure 后持久化。
*/
export async function createExamFromRichEditorAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const t = await getTranslations("examHomework")
try {
const ctx = await requirePermission(Permissions.EXAM_CREATE)
const parsed = RichExamCreateSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
difficulty: getStringValue(formData, "difficulty"),
totalScore: getStringValue(formData, "totalScore"),
durationMin: getStringValue(formData, "durationMin"),
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
editorDoc: getStringValue(formData, "editorDoc"),
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, { useFirstMessage: true })
}
const input = parsed.data
const editorDoc = safeJsonParse(input.editorDoc, t("exam.actionMessages.contentInvalid"))
if (!editorDoc) {
return failState<string>(t("exam.actionMessages.contentParseFailed"))
}
const context = await prepareExamCreateContext({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
})
// 将 Tiptap doc 转换为 EditorDoc 结构
// safeJsonParse 返回 unknown此处从 unknown 收窄为 EditorDoc
const { editorDocToStructure } = await import("./editor/editor-to-structure")
const structure = editorDocToStructure(editorDoc as unknown as EditorDoc, input.title)
// 转换为 AI 生成格式以复用 persistAiGeneratedExamDraft
// RichQuestionContent 与 QuestionContentResult 的 options.isCorrect 可选性不同,
// 此处从 unknown 收窄为 QuestionContentResult运行时由下游 Zod 校验保证安全)
// P2-1 修复composite 题型无法直接落库,通过 toStandaloneQuestionType 安全收窄
const generated = structure.questions.map((q) => ({
id: q.id,
type: toStandaloneQuestionType(q.type, "text"),
difficulty: input.difficulty,
score: q.score,
content: q.content as unknown as QuestionContentResult,
}))
const aiStructure: AiGeneratedStructureNode[] = structure.structure.map(convertStructureNode)
await persistAiGeneratedExamDraft({
examId: context.examId,
title: input.title,
creatorId: ctx.userId,
subjectId: input.subject,
gradeId: input.grade,
scheduledAt: context.scheduled,
description: context.buildDescription(),
structure: aiStructure,
generated,
examModeConfig: parseExamModeConfig(formData),
})
revalidatePath("/teacher/exams/all")
return successState(context.examId, t("exam.actionMessages.draftCreated"))
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
return handleActionError(error)
}
}
/**
* 从富文本编辑器更新已有试卷。
* 将 Tiptap JSONContent 转换为 structure 后更新 exam.structure 和 exam_questions。
* 对于 questionId 为空的新题目,创建 question 记录后 remap ID。
* P0-4 修复DB 事务逻辑已下沉到 data-access.updateExamWithRichEditorData。
*/
export async function updateExamFromRichEditorAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
const t = await getTranslations("examHomework")
try {
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
const parsed = RichExamUpdateSchema.safeParse({
examId: getStringValue(formData, "examId"),
title: getStringValue(formData, "title"),
editorDoc: getStringValue(formData, "editorDoc"),
})
if (!parsed.success) {
return invalidFormState<string>(parsed.error, { useFirstMessage: true })
}
const input = parsed.data
const editorDoc = safeJsonParse(input.editorDoc, t("exam.actionMessages.contentInvalid"))
if (!editorDoc) {
return failState<string>(t("exam.actionMessages.contentParseFailed"))
}
// Ownership check
if (ctx.dataScope.type !== "all") {
const creatorId = await getExamCreatorId(input.examId)
if (!creatorId || creatorId !== ctx.userId) {
return failState<string>(t("exam.actionMessages.onlyOwnUpdate"))
}
}
const { editorDocToStructure } = await import("./editor/editor-to-structure")
// safeJsonParse 返回 unknown此处从 unknown 收窄为 EditorDoc
const structure = editorDocToStructure(editorDoc as unknown as EditorDoc, input.title)
const aiStructure: AiGeneratedStructureNode[] = structure.structure.map(convertStructureNode)
// 对于 questionId 为空的新题目,创建 question 记录
const questionIdMapping = new Map<string, string>()
const newQuestions = structure.questions.filter((q) => !q.id || q.id.startsWith("temp"))
for (const q of newQuestions) {
const newQuestionId = await createQuestionWithRelations(
{
// RichQuestionContent 与 QuestionContentResult 的 options.isCorrect 可选性不同,
// 此处从 unknown 收窄为 QuestionContentResult运行时由下游 Zod 校验保证安全)
content: q.content as unknown as QuestionContentResult,
// P2-1 修复:通过 toStandaloneQuestionType 安全收窄,避免 composite 落库
type: toStandaloneQuestionType(q.type, "text"),
difficulty: 3,
},
ctx.userId
)
questionIdMapping.set(q.id, newQuestionId)
}
// Remap structure 中的 questionId
const remapStructure = (nodes: AiGeneratedStructureNode[]): AiGeneratedStructureNode[] => {
return nodes.map((node) => {
if (node.type === "question") {
const mapped = questionIdMapping.get(node.questionId ?? "") ?? node.questionId
return { ...node, questionId: mapped }
}
if (node.type === "group" || node.type === "section") {
return { ...node, children: remapStructure(node.children ?? []) }
}
return node
})
}
const finalStructure = remapStructure(aiStructure)
// 提取有序题目列表
const orderedQuestions: Array<{ id: string; score: number }> = []
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
for (const node of nodes) {
if (node.type === "question" && node.questionId) {
orderedQuestions.push({ id: node.questionId, score: node.score ?? 0 })
continue
}
if ((node.type === "group" || node.type === "section") && node.children) {
collectOrder(node.children)
}
}
}
collectOrder(finalStructure)
// P0-4 修复DB 事务下沉到 data-access 层
await updateExamWithRichEditorData(input.examId, {
title: input.title,
structure: finalStructure,
orderedQuestions,
})
revalidatePath("/teacher/exams/all")
revalidatePath(`/teacher/exams/${input.examId}/build`)
return successState(input.examId, t("exam.actionMessages.examUpdated"))
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<string>(error.message)
}
console.error("[updateExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
return handleActionError(error)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,396 @@
"use server"
/**
* AI 自动标记试卷文本 —— Server Action + 纯转换辅助函数。
*
* 从 exams/actions.ts 拆分而来,职责:
* - AutoMarkSchema / AutoMarkResult 类型定义
* - buildTiptapDocFromAiResponse / splitByDottedTexts / extractTitleFromAiResponse 等纯转换函数
* - autoMarkExamAction Server Action
*/
import type { ActionState } from "@/shared/types/action-state"
import { getTranslations } from "next-intl/server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { z } from "zod"
import { handleActionError } from "@/shared/lib/action-utils"
import { isRecord } from "@/shared/lib/type-guards"
import {
failState,
successState,
invalidFormState,
getStringValue,
} from "../actions-helpers"
// ---------------------------------------------------------------------------
// Schema & 类型
// ---------------------------------------------------------------------------
const AutoMarkSchema = z.object({
sourceText: z.string().min(1, "试卷文本不能为空"),
aiProviderId: z.string().optional(),
})
export interface AutoMarkResult {
/** Tiptap JSONContent 文档,可直接载入编辑器 */
doc: unknown
/** 解析出的标题(如有) */
title: string
}
// ---------------------------------------------------------------------------
// 纯转换辅助函数
// ---------------------------------------------------------------------------
const extractTitleFromAiResponse = (data: unknown): string => {
if (!isRecord(data)) return ""
return typeof data.title === "string" ? data.title : ""
}
/**
* 将文本按加点字片段切分,返回带 dotted 标记的片段数组。
* 用于在 Tiptap 文档中标记加点字(下加点)。
*/
const splitByDottedTexts = (
text: string,
dottedTexts: string[]
): Array<{ text: string; dotted: boolean }> => {
if (dottedTexts.length === 0) return [{ text, dotted: false }]
const result: Array<{ text: string; dotted: boolean }> = []
let remaining = text
while (remaining.length > 0) {
// 找到最早出现的加点字
let earliestIdx = -1
let earliestText = ""
for (const dt of dottedTexts) {
if (!dt) continue
const idx = remaining.indexOf(dt)
if (idx >= 0 && (earliestIdx === -1 || idx < earliestIdx)) {
earliestIdx = idx
earliestText = dt
}
}
if (earliestIdx === -1) {
// 没有更多加点字,剩余文本作为普通片段
if (remaining.length > 0) {
result.push({ text: remaining, dotted: false })
}
break
}
// 加点字之前的普通文本
if (earliestIdx > 0) {
result.push({ text: remaining.slice(0, earliestIdx), dotted: false })
}
// 加点字片段
result.push({ text: earliestText, dotted: true })
remaining = remaining.slice(earliestIdx + earliestText.length)
}
return result
}
/**
* 将 AI 返回的结构化 JSON 转换为 Tiptap JSONContent 文档。
* 支持 sections(分组)和顶层 questions 两种形式。
*/
const buildTiptapDocFromAiResponse = (data: unknown): unknown => {
if (!isRecord(data)) return { type: "doc", content: [] }
const content: unknown[] = []
const topQuestions = Array.isArray(data.questions) ? data.questions : []
const buildQuestionBlock = (q: unknown): unknown | null => {
if (!isRecord(q)) return null
const type = typeof q.type === "string" ? q.type : "text"
const score = typeof q.score === "number" ? q.score : 0
const contentNode = isRecord(q.content) ? q.content : {}
const text = typeof contentNode.text === "string" ? contentNode.text : ""
const inner: unknown[] = []
// 阅读材料(阅读理解题型的选段)
const readingMaterial =
typeof contentNode.readingMaterial === "string"
? contentNode.readingMaterial
: ""
if (readingMaterial) {
const materialLines = readingMaterial
.split("\n")
.filter((l) => l.trim().length > 0)
for (const line of materialLines) {
inner.push({
type: "paragraph",
content: [{ type: "text", text: line, marks: [{ type: "italic" }] }],
})
}
}
// 题干段落(按行拆分),处理加点字标记
const dottedTexts = Array.isArray(contentNode.dottedTexts)
? (contentNode.dottedTexts as unknown[])
.filter((s): s is string => typeof s === "string")
: []
const lines = text.split("\n").filter((l) => l.trim().length > 0)
for (const line of lines) {
// 如果有加点字,在文本中标记对应片段
if (dottedTexts.length > 0) {
const segments = splitByDottedTexts(line, dottedTexts)
const textNodes = segments.map((seg) =>
seg.dotted
? {
type: "text",
text: seg.text,
marks: [{ type: "dotted" }],
}
: { type: "text", text: seg.text }
)
inner.push({ type: "paragraph", content: textNodes })
} else {
inner.push({ type: "paragraph", content: [{ type: "text", text: line }] })
}
}
// 选项列表
const options = Array.isArray(contentNode.options) ? contentNode.options : []
if (options.length > 0) {
inner.push({
type: "orderedList",
content: options.map((opt) => {
const o = isRecord(opt) ? opt : {}
const id = typeof o.id === "string" ? o.id : ""
const optText = typeof o.text === "string" ? o.text : ""
return {
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: `${id}. ${optText}` }],
},
],
}
}),
})
}
// 子题(阅读理解的小题)—— 生成为嵌套的 questionBlock,便于编辑器层级展示与解析
const subQuestions = Array.isArray(contentNode.subQuestions)
? contentNode.subQuestions
: []
for (const sub of subQuestions) {
if (!isRecord(sub)) continue
const subText = typeof sub.text === "string" ? sub.text : ""
const subScore = typeof sub.score === "number" ? sub.score : 0
if (subText) {
// 子题文本按行拆分为段落
const subLines = subText.split("\n").filter((l) => l.trim().length > 0)
const subInner =
subLines.length > 0
? subLines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
inner.push({
type: "questionBlock",
attrs: { questionId: "", type: "text", score: subScore },
content: subInner,
})
}
}
// questionBlock 要求 content: "block+",无内容时给空段落
const innerContent =
inner.length > 0
? inner
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
return {
type: "questionBlock",
attrs: { questionId: "", type, score },
content: innerContent,
}
}
// 构建 groupBlock(大题分组),含 instruction
const buildGroupBlock = (g: unknown): unknown | null => {
if (!isRecord(g)) return null
const title = typeof g.title === "string" ? g.title : ""
const instruction = typeof g.instruction === "string" ? g.instruction : ""
const questions = Array.isArray(g.questions) ? g.questions : []
const children = questions
.map(buildQuestionBlock)
.filter((b): b is Record<string, unknown> => b !== null)
const groupContent =
children.length > 0
? children
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
return {
type: "groupBlock",
attrs: { title, instruction },
content: groupContent,
}
}
// 构建 sectionBlock(分卷),内含多个 groupBlock
const volumes = Array.isArray(data.volumes) ? data.volumes : []
const topGroups = Array.isArray(data.groups) ? data.groups : []
for (const volume of volumes) {
if (!isRecord(volume)) continue
const title = typeof volume.title === "string" ? volume.title : ""
const innerGroups = Array.isArray(volume.groups) ? volume.groups : []
const groupBlocks = innerGroups
.map(buildGroupBlock)
.filter((b): b is Record<string, unknown> => b !== null)
const sectionContent =
groupBlocks.length > 0
? groupBlocks
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
content.push({
type: "sectionBlock",
attrs: { title, level: 1 },
content: sectionContent,
})
}
// 顶层 groups(无分卷时)
for (const g of topGroups) {
const block = buildGroupBlock(g)
if (block) content.push(block)
}
// 顶层 questions(无分卷无大题时)
if (volumes.length === 0 && topGroups.length === 0) {
for (const q of topQuestions) {
const block = buildQuestionBlock(q)
if (block) content.push(block)
}
}
return { type: "doc", content }
}
// ---------------------------------------------------------------------------
// Server Action
// ---------------------------------------------------------------------------
/**
* AI 自动标记 —— 将粘贴的试卷文本交给 AI,返回带题目块/分组/填空/加点字标记的 Tiptap JSONContent 文档。
* 教师可在编辑器中基于此结果继续微调。
*/
export async function autoMarkExamAction(
prevState: ActionState<AutoMarkResult> | null,
formData: FormData
): Promise<ActionState<AutoMarkResult>> {
const t = await getTranslations("examHomework")
try {
await requirePermission(Permissions.EXAM_AI_GENERATE)
const parsed = AutoMarkSchema.safeParse({
sourceText: getStringValue(formData, "sourceText"),
aiProviderId: getStringValue(formData, "aiProviderId") || undefined,
})
if (!parsed.success) {
return invalidFormState<AutoMarkResult>(parsed.error, { useFirstMessage: true })
}
const { sourceText, aiProviderId } = parsed.data
const systemPrompt = [
"你是一个试卷结构解析引擎,专门解析中国中小学试卷。",
"将给定的试卷文本解析为结构化 JSON,用于在富文本编辑器中渲染为可编辑的题目块。",
"",
"## 识别规则",
"",
"1. **分卷**:识别\"第Ⅰ卷\"\"第Ⅱ卷\"\"第一部分\"等顶层分卷标记,输出到 volumes。每个 volume 含 title、instruction(可选)和 groups。",
"2. **大题分组**:识别\"一、选择题\"\"二、填空题\"\"三、阅读理解\"等大题标题,输出到 groups(若在 volume 内则归入对应 volume,否则归入顶层 groups)。每个 group 含 title、instruction(如\"每小题3分,共24分\")和 questions。",
"3. **题型识别**:",
" - 选择题(单选/多选):题干 + A/B/C/D 选项 → type: \"single_choice\" 或 \"multiple_choice\"",
" - 判断题:题干 + 对/错 → type: \"judgment\"",
" - 填空题:题干含横线空(_______或( )) → type: \"text\",在 blanks 中标记空数",
" - 简答/作文题:题干 + \"不少于X字\" → type: \"text\"",
" - 阅读理解(含选段+多小题)→ type: \"composite\",subQuestions 存小题",
"4. **分值**:从\"每小题3分\"\"共24分\"\"(12分)\"等提取,分摊到每题。",
"5. **选项**:A. B. C. D. 格式,输出到 content.options,每项含 id 和 text。",
"6. **填空**:横线\"_______\"或括号\"( )\"位置,在 content.blanks 中标记(数组长度=空数)。",
"7. **加点字**:拼音注音题中加点的字(如\"解\"剖),在 content.dottedTexts 中列出原文片段。",
"8. **子题**:阅读理解下的\"1. 2. 3.\"小题,输出到 content.subQuestions,每项含 text 和 score。",
"9. **阅读材料**:阅读理解的文章选段,放到 content.readingMaterial 字段。",
"10. **说明文字**:\"每小题3分,共24分\"\"选择正确答案的番号填涂在答题卡上\"等放到 group.instruction,不要混入题干。",
"",
"## 输出 JSON schema",
"",
"```json",
"{",
' "title": "试卷标题",',
' "volumes": [',
" {",
' "title": "第Ⅰ卷 选择题",',
' "groups": [',
" {",
' "title": "一、选择正确答案的番号填涂在答题卡上",',
' "instruction": "每小题3分,共24分",',
' "questions": [',
" {",
' "type": "single_choice",',
' "score": 3,',
' "content": {',
' "text": "下面加点字的注音全部正确的一项是",',
' "options": [{"id":"A","text":"解剖(pō) 蹭饭(cènɡ)"},{"id":"B","text":"栖息(qī) 譬如(pì)"}],',
' "blanks": [],',
' "dottedTexts": ["解","蹭","徉"],',
' "subQuestions": []',
" }",
" }",
" ]",
" }",
" ]",
" }",
" ],",
' "groups": []',
"}",
"```",
"",
"## 注意事项",
"- 不要输出 markdown 代码块标记(```),直接输出 JSON。",
"- 不要输出 ... 或 [...] 等占位符,必须输出完整数据。",
"- 如果没有 volumes,可直接返回 { \"groups\": [...] } 或 { \"questions\": [...] }。",
"- 阅读理解的选段文本放到 content.readingMaterial,不要混入题干 text。",
"- 作文题的\"不少于300字\"等要求保留在 text 中。",
"- \"共24分\"等总分信息不要写进 title,放到 instruction;实际总分由子题自动累加。",
].join("\n")
const { createAiChatCompletion } = await import("@/shared/lib/ai")
const { env } = await import("@/env.mjs")
const { parseAiResponse } = await import("./parse")
const aiResult = await createAiChatCompletion({
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
providerId: aiProviderId,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: sourceText },
],
temperature: 0,
maxTokens: 8000,
})
const parsedJson = await parseAiResponse(aiResult.content, aiProviderId)
const doc = buildTiptapDocFromAiResponse(parsedJson)
const title = extractTitleFromAiResponse(parsedJson)
return successState({ doc, title }, t("exam.actionMessages.autoMarkCompleted"))
} catch (error) {
if (error instanceof PermissionDeniedError) {
return failState<AutoMarkResult>(error.message)
}
console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error))
return handleActionError(error)
}
}

View File

@@ -12,10 +12,17 @@
*/
import { z } from "zod"
import { createId } from "@paralleldrive/cuid2"
import {
AiExamResponseSchema,
AiQuestionSchema,
AiInsertQuestionSchema,
AiGeneratedStructureSchema,
} from "./parse"
import type {
AiGeneratedQuestion,
AiGeneratedStructureNode,
} from "./parse"
import {
parseQuestionDetail,
@@ -170,3 +177,102 @@ export async function generateAiExamDraft(input: {
}) {
return requestAiExamDraft(input)
}
// ---------------------------------------------------------------------------
// AI 草稿加载辅助(从 exams/actions.ts 下沉)
// ---------------------------------------------------------------------------
/**
* 加载 AI 草稿题目与结构:优先从 rawAiQuestions 解析预览 payload否则从源文本生成。
*/
export async function loadAiDraftQuestionsAndStructure(input: {
rawAiQuestions: string | null
rawStructure: string | null
title: string
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
aiSourceText?: string
aiQuestionCount?: number
aiProviderId?: string
}): Promise<
| { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] }
| { ok: false; message: string }
> {
if (input.rawAiQuestions) {
let parsedQuestions: unknown = null
try {
parsedQuestions = JSON.parse(input.rawAiQuestions)
} catch {
return { ok: false, message: "Invalid AI preview payload" }
}
const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions)
if (!validated.success || validated.data.length === 0) {
return { ok: false, message: "Invalid AI preview payload" }
}
const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
score: q.score,
content: {
text: q.content.text,
...(q.content.options
? {
options: q.content.options.map((opt) => ({
id: opt.id,
text: opt.text,
isCorrect: opt.isCorrect ?? false,
})),
}
: {}),
...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}),
},
}))
let structure: AiGeneratedStructureNode[] = []
if (input.rawStructure) {
try {
const parsedStructure = JSON.parse(input.rawStructure)
const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure)
if (validatedStructure.success) {
structure = validatedStructure.data
} else {
return { ok: false, message: "Invalid preview structure" }
}
} catch {
return { ok: false, message: "Invalid preview structure" }
}
}
if (structure.length === 0) {
structure = generated.map((q) => ({
id: createId(),
type: "question",
questionId: q.id,
score: q.score,
}))
}
return { ok: true, generated, structure }
}
const sourceText = input.aiSourceText?.trim()
if (!sourceText) {
return { ok: false, message: "Please analyze and preview before creating" }
}
const aiDraft = await generateAiCreateDraftFromSource({
title: input.title,
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
questionCount: input.aiQuestionCount,
sourceText,
aiProviderId: input.aiProviderId,
})
if (!aiDraft.ok) {
return { ok: false, message: aiDraft.message }
}
return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure }
}

View File

@@ -104,8 +104,10 @@ const AiSourceValidationSchema = z.object({
export type AiGeneratedStructureNode = {
id: string
type: "group" | "question"
type: "section" | "group" | "question"
title?: string
instruction?: string
level?: number
questionId?: string
score?: number
children?: AiGeneratedStructureNode[]
@@ -113,8 +115,10 @@ export type AiGeneratedStructureNode = {
export const AiGeneratedStructureNodeSchema: z.ZodType<AiGeneratedStructureNode> = z.lazy(() => z.object({
id: z.string().min(1),
type: z.enum(["group", "question"]),
type: z.enum(["section", "group", "question"]),
title: z.string().optional(),
instruction: z.string().optional(),
level: z.coerce.number().int().min(0).optional(),
questionId: z.string().optional(),
score: z.coerce.number().int().min(0).optional(),
children: z.array(AiGeneratedStructureNodeSchema).optional(),

View File

@@ -1,6 +1,7 @@
"use client"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import type { ExamNode } from "./selected-question-list"
type ChoiceOption = {
@@ -45,7 +46,7 @@ const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
if (node.type === "question" && node.question) {
counter += 1
map.set(node.id, counter)
} else if (node.type === "group" && node.children) {
} else if ((node.type === "group" || node.type === "section") && node.children) {
walk(node.children)
}
}
@@ -55,18 +56,37 @@ const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
}
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
const t = useTranslations("examHomework.exam.build")
const tPaper = useTranslations("examHomework.exam.paperPreview")
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
const numberMap = useMemo(() => buildQuestionNumberMap(nodes), [nodes])
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'section') {
return (
<div key={node.id} className="space-y-4 mb-8">
<div className="flex items-baseline gap-3 border-b-2 border-foreground pb-2">
<h2 className={`font-bold ${depth === 0 ? 'text-xl' : 'text-lg'} text-foreground`}>
{node.title || t("section")}
</h2>
</div>
<div className="pl-0">
{node.children?.map(child => renderNode(child, depth + 1))}
</div>
</div>
)
}
if (node.type === 'group') {
return (
<div key={node.id} className="space-y-4 mb-6">
<div className="flex items-center gap-2">
<h3 className={`font-bold ${depth === 0 ? 'text-lg' : 'text-md'} text-foreground/90`}>
{node.title || "Section"}
{node.title || t("group")}
</h3>
{/* Optional: Show section score if needed */}
{node.instruction && (
<span className="text-xs text-muted-foreground italic">{node.instruction}</span>
)}
</div>
<div className="pl-0">
{node.children?.map(child => renderNode(child, depth + 1))}
@@ -87,7 +107,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text ?? ""}
<span className="text-muted-foreground text-sm ml-2">({node.score})</span>
<span className="text-muted-foreground text-sm ml-2">{tPaper("scoreWithUnit", { score: node.score ?? 0 })}</span>
</div>
{/* Options for Choice Questions */}
@@ -120,15 +140,15 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
<div className="text-center space-y-4 mb-12 border-b-2 border-primary/20 pb-8">
<h1 className="text-3xl font-black tracking-tight text-foreground">{title}</h1>
<div className="flex justify-center gap-8 text-sm text-muted-foreground font-medium uppercase tracking-wide">
<span>Subject: {subject}</span>
<span>Grade: {grade}</span>
<span>Time: {durationMin} mins</span>
<span>Total: {totalScore} pts</span>
<span>{t("previewSubject")}: {subject}</span>
<span>{t("previewGrade")}: {grade}</span>
<span>{t("previewTime")}: {durationMin} {t("minutes")}</span>
<span>{t("previewTotal")}: {totalScore} {t("pts")}</span>
</div>
<div className="flex justify-center gap-12 text-sm pt-4">
<div className="w-32 border-b border-foreground/20 text-left px-1">Class:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">Name:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">No.:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">{t("previewClass")}:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">{t("previewName")}:</div>
<div className="w-32 border-b border-foreground/20 text-left px-1">{t("previewNo")}:</div>
</div>
</div>
@@ -136,7 +156,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
<div className="space-y-2">
{nodes.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
Empty Exam Paper
{t("emptyExamPaper")}
</div>
) : (
nodes.map(node => renderNode(node))

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card } from "@/shared/components/ui/card"
@@ -88,12 +89,13 @@ function extractQuestionText(content: unknown): string {
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps): React.ReactNode {
const t = useTranslations("examHomework.exam.build")
const aiClient = useAiClientOptional()
if (questions.length === 0 && !isLoading) {
return (
<div className="text-center py-8 text-muted-foreground">
No questions found matching your filters.
{t("noQuestionsFound")}
</div>
)
}
@@ -134,7 +136,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
))}
</div>
<p className="text-sm line-clamp-2 text-muted-foreground">
{parsedContent.text || "No content preview"}
{parsedContent.text || t("noContentPreview")}
</p>
</div>
<div className="flex items-center gap-1">
@@ -171,7 +173,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
disabled={isLoading}
className="w-full text-muted-foreground"
>
{isLoading ? "Loading..." : "Load More"}
{isLoading ? t("loading") : t("loadMore")}
</Button>
</div>
)}
@@ -206,6 +208,7 @@ function AiVariantDialog({
subject?: string
onAdd: (question: Question) => void
}): React.ReactNode {
const t = useTranslations("examHomework.exam.build")
const [open, setOpen] = useState(false)
const handleAddVariant = (variant: QuestionVariantResult): void => {
@@ -220,7 +223,7 @@ function AiVariantDialog({
size="sm"
variant="outline"
className="h-8 w-8 p-0"
title="AI variant"
title={t("aiVariant")}
>
<Sparkles className="h-4 w-4" />
</Button>
@@ -229,7 +232,7 @@ function AiVariantDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Variant
{t("aiVariant")}
</DialogTitle>
</DialogHeader>
<AiQuestionVariantGenerator

View File

@@ -3,16 +3,19 @@
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { useTranslations } from "next-intl"
import { ArrowUp, ArrowDown, Trash2 } from "lucide-react"
import type { Question } from "@/modules/questions/types"
export type ExamNode = {
id: string
type: 'group' | 'question'
title?: string // For group
type: 'section' | 'group' | 'question'
title?: string // For section/group
instruction?: string // For group (大题说明)
level?: number // For section (分卷层级 1=卷 2=部分)
questionId?: string // For question
score?: number
children?: ExamNode[] // For group
children?: ExamNode[] // For section/group
question?: Question // Populated for rendering
}
@@ -23,88 +26,126 @@ type SelectedQuestionListProps = {
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
onAddGroup: () => void
onAddSection?: () => void
}
export function SelectedQuestionList({
items,
onRemove,
onMove,
export function SelectedQuestionList({
items,
onRemove,
onMove,
onScoreChange,
onGroupTitleChange,
onAddGroup
onAddGroup,
onAddSection
}: SelectedQuestionListProps) {
const t = useTranslations("examHomework.exam.build")
if (items.length === 0) {
return (
<div className="border border-dashed rounded-lg p-8 text-center text-muted-foreground text-sm flex flex-col gap-4">
<p>No questions selected. Add questions from the bank or create a group.</p>
<Button variant="outline" onClick={onAddGroup}>Create Section</Button>
<p>{t("noQuestionsSelected")}</p>
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={onAddGroup}>{t("createGroup")}</Button>
{onAddSection && (
<Button variant="outline" onClick={onAddSection}>{t("createSection")}</Button>
)}
</div>
</div>
)
}
const renderNode = (node: ExamNode, idx: number, total: number, parentId?: string) => {
if (node.type === 'section') {
return (
<div key={node.id} className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4 space-y-4">
<div className="flex items-center gap-3">
<Input
value={node.title || ""}
onChange={(e) => onGroupTitleChange(node.id, e.target.value)}
placeholder={t("sectionTitlePlaceholder")}
className="font-bold h-9 bg-transparent border-transparent hover:border-input focus:bg-background"
/>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'up', parentId)} disabled={idx === 0}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'down', parentId)} disabled={idx === total - 1}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => onRemove(node.id, parentId)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="pl-4 border-l-2 border-primary/30 space-y-3">
{!node.children || node.children.length === 0 ? (
<div className="text-xs text-muted-foreground italic py-2">{t("dragGroupsHere")}</div>
) : (
node.children.map((child, cIdx) => renderNode(child, cIdx, node.children?.length || 0, node.id))
)}
</div>
</div>
)
}
if (node.type === 'group') {
return (
<div key={node.id} className="rounded-lg border bg-muted/10 p-4 space-y-4">
<div className="flex items-center gap-3">
<Input
value={node.title || t("untitledGroup")}
onChange={(e) => onGroupTitleChange(node.id, e.target.value)}
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background"
/>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'up', parentId)} disabled={idx === 0}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'down', parentId)} disabled={idx === total - 1}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => onRemove(node.id, parentId)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-3">
{!node.children || node.children.length === 0 ? (
<div className="text-xs text-muted-foreground italic py-2">{t("dragQuestionsHere")}</div>
) : (
node.children?.map((child, cIdx) => renderNode(child, cIdx, node.children?.length || 0, node.id))
)}
</div>
</div>
)
}
return (
<QuestionItem
key={node.id}
item={node}
index={idx}
total={total}
onRemove={() => onRemove(node.id, parentId)}
onMove={(dir) => onMove(node.id, dir, parentId)}
onScoreChange={(score) => onScoreChange(node.id, score)}
/>
)
}
return (
<div className="space-y-4">
{items.map((node, idx) => {
if (node.type === 'group') {
return (
<div key={node.id} className="rounded-lg border bg-muted/10 p-4 space-y-4">
<div className="flex items-center gap-3">
<Input
value={node.title || "Untitled Section"}
onChange={(e) => onGroupTitleChange(node.id, e.target.value)}
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background"
/>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'up')} disabled={idx === 0}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onMove(node.id, 'down')} disabled={idx === items.length - 1}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => onRemove(node.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-3">
{!node.children || node.children.length === 0 ? (
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
) : (
node.children?.map((child, cIdx) => (
<QuestionItem
key={child.id}
item={child}
index={cIdx}
total={node.children?.length || 0}
onRemove={() => onRemove(child.id, node.id)}
onMove={(dir) => onMove(child.id, dir, node.id)}
onScoreChange={(score) => onScoreChange(child.id, score)}
/>
))
)}
</div>
</div>
)
}
return (
<QuestionItem
key={node.id}
item={node}
index={idx}
total={items.length}
onRemove={() => onRemove(node.id)}
onMove={(dir) => onMove(node.id, dir)}
onScoreChange={(score) => onScoreChange(node.id, score)}
/>
)
})}
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
+ Add Section
{items.map((node, idx) => renderNode(node, idx, items.length))}
<div className="flex justify-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="border-dashed">
{t("addGroup")}
</Button>
{onAddSection && (
<Button variant="outline" size="sm" onClick={onAddSection} className="border-dashed">
{t("addSection")}
</Button>
)}
</div>
</div>
)
@@ -118,6 +159,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => void
}) {
const t = useTranslations("examHomework.exam.build")
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
@@ -142,12 +184,12 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
{index + 1}
</span>
<p className="text-sm line-clamp-2 pt-0.5">
{parsedContent.text || "Question content"}
{parsedContent.text || t("questionContent")}
</p>
</div>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={onRemove}
>
@@ -164,32 +206,32 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
))}
</div>
)}
<div className="flex items-center justify-between pl-8">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={index === 0}
onClick={() => onMove('up')}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
disabled={index === total - 1}
onClick={() => onMove('down')}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
Score
{t("score")}
</Label>
<Input
id={`score-${item.id}`}

View File

@@ -32,6 +32,7 @@ import { Label } from "@/shared/components/ui/label"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/components/ui/collapsible"
import { Trash2, GripVertical, ChevronDown, ChevronRight, Calculator } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { useTranslations } from "next-intl"
import type { ExamNode } from "./selected-question-list"
// --- Types ---
@@ -43,11 +44,12 @@ type StructureEditorProps = {
onGroupTitleChange: (id: string, title: string) => void
onRemove: (id: string) => void
onAddGroup: () => void
onAddSection?: () => void
}
function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
return nodes.map((node) => {
if (node.type === "group") {
if (node.type === "group" || node.type === "section") {
return { ...node, children: cloneExamNodes(node.children || []) }
}
return { ...node }
@@ -91,6 +93,7 @@ function SortableItem({
onRemove: () => void
onScoreChange: (val: number) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
@@ -131,7 +134,7 @@ function SortableItem({
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{parsedContent.text || "Question content"}
{parsedContent.text || t("questionContent")}
</p>
</div>
<Button
@@ -157,7 +160,7 @@ function SortableItem({
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
Score
{t("score")}
</Label>
<Input
id={`score-${item.id}`}
@@ -186,6 +189,7 @@ function SortableGroup({
onRemove: () => void
onTitleChange: (val: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
@@ -232,13 +236,13 @@ function SortableGroup({
<Input
value={item.title || ""}
onChange={(e) => onTitleChange(e.target.value)}
placeholder="Section Title"
placeholder={t("groupTitlePlaceholder")}
className="font-semibold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
/>
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
<Calculator className="h-3 w-3" />
<span>{totalScore} pts</span>
<span>{totalScore} {t("pts")}</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
@@ -253,12 +257,94 @@ function SortableGroup({
)
}
function SortableSection({
id,
item,
children,
onRemove,
onTitleChange
}: {
id: string
item: ExamNode
children: React.ReactNode
onRemove: () => void
onTitleChange: (val: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const [isOpen, setIsOpen] = useState(true)
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const childrenKey = JSON.stringify(item.children || [])
const totalScore = useMemo(() => {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((acc, node) => {
if (node.type === 'question') return acc + (node.score || 0)
if (node.type === 'group' || node.type === 'section') return acc + calc(node.children || [])
return acc
}, 0)
}
return calc(item.children || [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [childrenKey])
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border-2 border-primary/20 bg-primary/5 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
<div className="flex items-center gap-3">
<button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground">
<GripVertical className="h-5 w-5" />
</button>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6 hover:bg-transparent">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<Input
value={item.title || ""}
onChange={(e) => onTitleChange(e.target.value)}
placeholder={t("sectionTitlePlaceholder")}
className="font-bold h-9 bg-transparent border-transparent hover:border-input focus:bg-background flex-1"
/>
<div className="flex items-center gap-1 text-muted-foreground text-xs bg-background/50 px-2 py-1 rounded">
<Calculator className="h-3 w-3" />
<span>{totalScore} {t("pts")}</span>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={onRemove}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent className="pl-4 border-l-2 border-primary/30 space-y-3 min-h-[50px] animate-in slide-in-from-top-2 fade-in duration-200">
{children}
</CollapsibleContent>
</Collapsible>
)
}
function StructureRenderer({ nodes, ...props }: {
nodes: ExamNode[]
onRemove: (id: string) => void
onScoreChange: (id: string, score: number) => void
onGroupTitleChange: (id: string, title: string) => void
}) {
const t = useTranslations("examHomework.exam.build")
// Deduplicate nodes to prevent React key errors
const nodesKey = JSON.stringify(nodes.map(n => n.id))
const uniqueNodes = useMemo(() => {
@@ -275,7 +361,26 @@ function StructureRenderer({ nodes, ...props }: {
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
{uniqueNodes.map(node => (
<div key={node.id}>
{node.type === 'group' ? (
{node.type === 'section' ? (
<SortableSection
id={node.id}
item={node}
onRemove={() => props.onRemove(node.id)}
onTitleChange={(val) => props.onGroupTitleChange(node.id, val)}
>
<StructureRenderer
nodes={node.children || []}
onRemove={props.onRemove}
onScoreChange={props.onScoreChange}
onGroupTitleChange={props.onGroupTitleChange}
/>
{(!node.children || node.children.length === 0) && (
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
{t("dragItemsHere")}
</div>
)}
</SortableSection>
) : node.type === 'group' ? (
<SortableGroup
id={node.id}
item={node}
@@ -290,7 +395,7 @@ function StructureRenderer({ nodes, ...props }: {
/>
{(!node.children || node.children.length === 0) && (
<div className="text-xs text-muted-foreground italic py-2 text-center border-2 border-dashed border-muted/50 rounded">
Drag items here
{t("dragItemsHere")}
</div>
)}
</SortableGroup>
@@ -320,7 +425,8 @@ const dropAnimation: DropAnimation = {
}),
}
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup, onAddSection }: StructureEditorProps) {
const t = useTranslations("examHomework.exam.build")
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(
@@ -412,11 +518,11 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
const activeContainerId = findContainerId(activeId, items)
const overContainerId = findContainerId(overId, items)
// Scenario 1: Moving item into a Group by hovering over the Group itself
// If overNode is a Group, we might want to move INTO it
if (overNode.type === 'group') {
// Logic: If active item is NOT in this group already
// AND we are not trying to move a group into its own descendant (circular check)
// Scenario 1: Moving item into a Group/Section by hovering over the Group/Section itself
// If overNode is a Group or Section, we might want to move INTO it
if (overNode.type === 'group' || overNode.type === 'section') {
// Logic: If active item is NOT in this container already
// AND we are not trying to move a container into its own descendant (circular check)
const isDescendant = (parent: ExamNode, childId: string): boolean => {
if (!parent.children) return false
@@ -427,8 +533,8 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
return false
}
// If moving a group, check if overNode is a descendant of activeNode
if (activeNode.type === 'group' && isDescendant(activeNode, overNode.id)) {
// If moving a container, check if overNode is a descendant of activeNode
if ((activeNode.type === 'group' || activeNode.type === 'section') && isDescendant(activeNode, overNode.id)) {
return
}
@@ -622,27 +728,39 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
onGroupTitleChange={onGroupTitleChange}
/>
<div className="flex justify-center pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="w-full border-dashed">
+ Add Section
<div className="flex justify-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onAddGroup} className="border-dashed">
{t("addGroup")}
</Button>
{onAddSection && (
<Button variant="outline" size="sm" onClick={onAddSection} className="border-dashed">
{t("addSection")}
</Button>
)}
</div>
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeItem ? (
activeItem.type === 'group' ? (
activeItem.type === 'section' ? (
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4 shadow-lg opacity-80 w-[300px]">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5" />
<span className="font-bold">{activeItem.title || t("section")}</span>
</div>
</div>
) : activeItem.type === 'group' ? (
<div className="rounded-lg border bg-background p-4 shadow-lg opacity-80 w-[300px]">
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5" />
<span className="font-semibold">{activeItem.title || "Section"}</span>
<span className="font-semibold">{activeItem.title || t("group")}</span>
</div>
</div>
) : (
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
<GripVertical className="h-4 w-4" />
<p className="text-sm line-clamp-1">
{extractQuestionText(activeItem.question?.content) || "Question"}
{extractQuestionText(activeItem.question?.content) || t("question")}
</p>
</div>
)

View File

@@ -2,8 +2,9 @@
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Eye } from "lucide-react"
import { Eye, FileText } from "lucide-react"
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -46,6 +47,7 @@ type ExamAssemblyProps = {
}
export function ExamAssembly(props: ExamAssemblyProps) {
const t = useTranslations("examHomework.exam.build")
const router = useRouter()
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<string>("all")
@@ -69,7 +71,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const q = node.questionId ? questionById.get(node.questionId) : undefined
return { ...node, question: q }
}
if (node.type === "group") {
if (node.type === "group" || node.type === "section") {
return { ...node, children: hydrate(node.children || []) }
}
return node
@@ -112,10 +114,10 @@ export function ExamAssembly(props: ExamAssemblyProps) {
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Failed to load questions")
toast.error(t("loadQuestionsFailed"))
}
})
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter, t])
const isFirstRender = useRef(true)
@@ -134,7 +136,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const calc = (nodes: ExamNode[]): number => {
return nodes.reduce((sum, node) => {
if (node.type === 'question') return sum + (node.score || 0)
if (node.type === 'group') return sum + calc(node.children || [])
if (node.type === 'group' || node.type === 'section') return sum + calc(node.children || [])
return sum
}, 0)
}
@@ -150,7 +152,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const walk = (nodes: ExamNode[]) => {
for (const n of nodes) {
if (n.type === "question" && n.questionId) ids.add(n.questionId)
if (n.type === "group" && n.children) walk(n.children)
if ((n.type === "group" || n.type === "section") && n.children) walk(n.children)
}
}
walk(structure)
@@ -162,7 +164,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const has = (nodes: ExamNode[]): boolean => {
return nodes.some((n) => {
if (n.type === "question") return n.questionId === question.id
if (n.type === "group" && n.children) return has(n.children)
if ((n.type === "group" || n.type === "section") && n.children) return has(n.children)
return false
})
}
@@ -187,7 +189,20 @@ export function ExamAssembly(props: ExamAssemblyProps) {
{
id: createId(),
type: 'group',
title: 'New Section',
title: t("newGroup"),
children: []
}
])
}
const handleAddSection = () => {
setStructure(prev => [
...prev,
{
id: createId(),
type: 'section',
title: t("newSection"),
level: 1,
children: []
}
])
@@ -196,7 +211,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const handleRemove = (id: string) => {
const removeRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.filter(n => n.id !== id).map(n => {
if (n.type === 'group') {
if (n.type === 'group' || n.type === 'section') {
return { ...n, children: removeRecursive(n.children || []) }
}
return n
@@ -209,7 +224,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(n => {
if (n.id === id) return { ...n, score }
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
if (n.type === 'group' || n.type === 'section') return { ...n, children: updateRecursive(n.children || []) }
return n
})
}
@@ -220,7 +235,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
return nodes.map(n => {
if (n.id === id) return { ...n, title }
if (n.type === 'group') return { ...n, children: updateRecursive(n.children || []) }
if (n.type === 'group' || n.type === 'section') return { ...n, children: updateRecursive(n.children || []) }
return n
})
}
@@ -239,7 +254,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
list.push({ id: n.questionId, score: n.score || 0 })
}
}
if (n.type === 'group') {
if (n.type === 'group' || n.type === 'section') {
traverse(n.children || [])
}
})
@@ -256,7 +271,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return nodes.map(n => {
const { question, ...rest } = n
void question
if (n.type === 'group') {
if (n.type === 'group' || n.type === 'section') {
return { ...rest, children: clean(n.children || []) }
}
return rest
@@ -275,13 +290,13 @@ export function ExamAssembly(props: ExamAssemblyProps) {
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Exam draft saved")
toast.success(t("saveSuccess"))
} else {
toast.error(result.message || "Save failed")
toast.error(result.message || t("saveFailed"))
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Save failed")
toast.error(t("saveFailed"))
}
}
@@ -294,14 +309,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
try {
const result = await updateExamAction(null, formData)
if (result.success) {
toast.success("Published exam")
toast.success(t("publishSuccess"))
router.push("/teacher/exams/all")
} else {
toast.error(result.message || "Publish failed")
toast.error(result.message || t("publishFailed"))
}
} catch (error) {
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
toast.error("Publish failed")
toast.error(t("publishFailed"))
}
}
@@ -312,14 +327,14 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<CardHeader className="bg-muted/30 pb-4 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-lg">Exam Structure</CardTitle>
<CardTitle className="text-lg">{t("examStructure")}</CardTitle>
<div className="h-4 w-[1px] bg-border mx-1" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{props.subject}</span>
<span></span>
<span>{props.grade}</span>
<span></span>
<span>{props.durationMin} min</span>
<span>{props.durationMin} {t("minutes")}</span>
</div>
</div>
<div className="flex items-center gap-6">
@@ -327,7 +342,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
<Eye className="h-4 w-4" />
Preview
{t("preview")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
@@ -359,7 +374,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
</span>
<span className="text-muted-foreground">/ {props.totalScore}</span>
</div>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Total Score</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t("totalScore")}</span>
</div>
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
<div
@@ -383,19 +398,32 @@ export function ExamAssembly(props: ExamAssemblyProps) {
onGroupTitleChange={handleGroupTitleChange}
onRemove={handleRemove}
onAddGroup={handleAddGroup}
onAddSection={handleAddSection}
/>
</div>
</ScrollArea>
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
<div className="mr-auto text-xs text-muted-foreground">
{structure.length === 0 ? "Start by adding questions from the right panel" : `${structure.length} items in structure`}
<div className="mr-auto flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{structure.length === 0 ? t("startHint") : t("itemsInStructure", { count: structure.length })}
</span>
<Button
variant="ghost"
size="sm"
className="gap-2 text-muted-foreground hover:text-foreground"
onClick={() => router.push(`/teacher/exams/${props.examId}/edit-rich`)}
title={t("richEditorHint")}
>
<FileText className="h-4 w-4" />
{t("richEditor")}
</Button>
</div>
<form action={handleSave}>
<Button variant="outline" size="sm" type="submit" className="w-24">Save Draft</Button>
<Button variant="outline" size="sm" type="submit" className="w-24">{t("saveDraft")}</Button>
</form>
<form action={handlePublish}>
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">Publish</Button>
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">{t("publish")}</Button>
</form>
</div>
</Card>
@@ -404,9 +432,9 @@ export function ExamAssembly(props: ExamAssemblyProps) {
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">Question Bank</CardTitle>
<CardTitle className="text-base font-semibold">{t("questionBank")}</CardTitle>
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{bankQuestions.length}{hasMore ? "+" : ""} loaded
{bankQuestions.length}{hasMore ? "+" : ""} {t("loaded")}
</span>
</div>
<QuestionBankFilters

View File

@@ -0,0 +1,189 @@
"use client"
/**
* exams 模块统一边界组件P2-3
*
* 设计目标:
* - 复用 shared 层的 SectionErrorBoundary 与 EmptyState避免每页重复实现
* - 通过 next-intl 命名空间 "examHomework" 提供本地化文案
* - 提供 ExamEmptyState / ExamSkeleton / ExamErrorBoundary 三个组合单元
*
* 使用示例:
* ```tsx
* <ExamErrorBoundary>
* <Suspense fallback={<ExamSkeleton variant="list" />}>
* {data.length === 0 ? <ExamEmptyState /> : <ExamGrid items={data} />}
* </Suspense>
* </ExamErrorBoundary>
* ```
*/
import type { ElementType, ErrorInfo, ReactNode } from "react"
import { useTranslations } from "next-intl"
import { FileText } from "lucide-react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { cn } from "@/shared/lib/utils"
/**
* 考试错误边界:薄包装 SectionErrorBoundary固定使用 examHomework 命名空间。
* 用于包裹任意考试相关数据区块,捕获渲染异常并显示统一的降级 UI。
*/
export function ExamErrorBoundary({
children,
fallback,
onError,
}: {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
onError?: (error: Error, info: ErrorInfo) => void
}): ReactNode {
return (
<SectionErrorBoundary namespace="examHomework" fallback={fallback} onError={onError}>
{children}
</SectionErrorBoundary>
)
}
interface ExamEmptyStateProps {
/** 空状态标题 i18n 键路径(相对 examHomework 命名空间),默认 "exam.list.empty" */
titleKey?: string
/** 空状态描述 i18n 键路径,默认 "exam.list.emptyDescription" */
descriptionKey?: string
/** 可选的引导操作labelKey 为相对命名空间的 i18n 键) */
action?: {
labelKey: string
onClick?: () => void
href?: string
}
/** 自定义图标,默认 FileText */
icon?: ElementType
className?: string
}
/**
* 考试空状态:默认显示"暂无考试"。
* 标题/描述/操作均通过 i18n 键传入,调用方需保证对应翻译键存在。
*/
export function ExamEmptyState({
titleKey = "exam.list.empty",
descriptionKey = "exam.list.emptyDescription",
action,
icon = FileText,
className,
}: ExamEmptyStateProps): ReactNode {
const t = useTranslations("examHomework")
const resolvedAction = action
? {
label: t(action.labelKey),
onClick: action.onClick,
href: action.href,
}
: undefined
return (
<EmptyState
icon={icon}
title={t(titleKey)}
description={t(descriptionKey)}
action={resolvedAction}
className={cn("border-none shadow-none h-auto", className)}
/>
)
}
type ExamSkeletonVariant = "list" | "card" | "detail" | "analytics" | "editor"
interface ExamSkeletonProps {
/** 骨架屏变体list=列表页, card=卡片网格, detail=详情页, analytics=分析页, editor=富文本编辑器 */
variant?: ExamSkeletonVariant
className?: string
}
/**
* 考试骨架屏:根据不同页面类型提供匹配的占位结构。
*/
export function ExamSkeleton({ variant = "list", className }: ExamSkeletonProps): ReactNode {
const wrapperClass = cn("flex h-full flex-col space-y-8 p-8", className)
if (variant === "card") {
return (
<div className={cn("grid grid-cols-1 gap-4 p-8 md:grid-cols-2 lg:grid-cols-3", className)}>
{Array.from({ length: 6 }).map((_, idx) => (
<div key={idx} className="space-y-3 rounded-lg border p-4">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="flex gap-2 pt-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
)
}
if (variant === "detail") {
return (
<div className={wrapperClass}>
<Skeleton className="h-8 w-1/3" />
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
)
}
if (variant === "analytics") {
return (
<div className={wrapperClass}>
<Skeleton className="h-7 w-40" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, idx) => (
<Skeleton key={idx} className="h-24 w-full rounded-lg" />
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-64 w-full rounded-lg" />
</div>
</div>
)
}
if (variant === "editor") {
return (
<div className={wrapperClass}>
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-10 w-full" />
<div className="space-y-2 rounded-md border p-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[95%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[85%]" />
</div>
</div>
)
}
// 默认 list 变体
return (
<div className={wrapperClass}>
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<div className="rounded-md border p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[95%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[80%]" />
</div>
</div>
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link"
import { Book, Clock, GraduationCap, Trophy, HelpCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import {
Card,
CardContent,
@@ -18,19 +19,38 @@ interface ExamCardProps {
hrefBase?: string
}
const subjectColorMap: Record<string, string> = {
Mathematics: "from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
Physics: "from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
Chemistry: "from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
English: "from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
History: "from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
Biology: "from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
Geography: "from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
/**
* P1-3 重构:按 subjectId 做确定性哈希选色,替代按英文名映射。
* 确保同一科目在任意语言下颜色一致,且无需维护硬编码科目名映射表。
*/
const SUBJECT_COLOR_PALETTE: readonly string[] = [
"from-blue-500/20 to-blue-600/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
"from-purple-500/20 to-purple-600/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-800",
"from-teal-500/20 to-teal-600/20 text-teal-700 dark:text-teal-300 border-teal-200 dark:border-teal-800",
"from-orange-500/20 to-orange-600/20 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
"from-amber-500/20 to-amber-600/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800",
"from-emerald-500/20 to-emerald-600/20 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
"from-sky-500/20 to-sky-600/20 text-sky-700 dark:text-sky-300 border-sky-200 dark:border-sky-800",
"from-rose-500/20 to-rose-600/20 text-rose-700 dark:text-rose-300 border-rose-200 dark:border-rose-800",
]
const DEFAULT_COLOR_CLASS =
"from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
const getSubjectColorClass = (subjectId: string | null): string => {
if (!subjectId) return DEFAULT_COLOR_CLASS
let hash = 0
for (let i = 0; i < subjectId.length; i += 1) {
hash = (hash * 31 + subjectId.charCodeAt(i)) | 0
}
const index = Math.abs(hash) % SUBJECT_COLOR_PALETTE.length
return SUBJECT_COLOR_PALETTE[index]!
}
export function ExamCard({ exam, hrefBase }: ExamCardProps) {
const t = useTranslations("examHomework")
const base = hrefBase || "/teacher/exams"
const colorClass = subjectColorMap[exam.subject] || "from-zinc-500/20 to-zinc-600/20 text-zinc-700 dark:text-zinc-300 border-zinc-200 dark:border-zinc-800"
const colorClass = getSubjectColorClass(exam.subjectId)
const statusVariant: BadgeProps["variant"] =
exam.status === "published"
@@ -47,11 +67,11 @@ export function ExamCard({ exam, hrefBase }: ExamCardProps) {
<div className="relative z-10 flex h-full flex-col justify-between">
<div className="flex justify-between items-start">
<Badge variant={statusVariant} className="bg-background/50 backdrop-blur-sm shadow-none border-transparent">
{exam.status}
{t(`exam.status.${exam.status}` as const)}
</Badge>
{exam.difficulty && (
<Badge variant="outline" className="bg-background/50 backdrop-blur-sm border-transparent shadow-none">
Lvl {exam.difficulty}
{t("exam.card.level", { level: exam.difficulty })}
</Badge>
)}
</div>
@@ -75,11 +95,11 @@ export function ExamCard({ exam, hrefBase }: ExamCardProps) {
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{exam.durationMin} min</span>
<span>{t("exam.card.minutes", { count: exam.durationMin })}</span>
</div>
<div className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
<span>{exam.totalScore} pts</span>
<span>{t("exam.card.points", { count: exam.totalScore })}</span>
</div>
</div>
</CardContent>
@@ -88,9 +108,9 @@ export function ExamCard({ exam, hrefBase }: ExamCardProps) {
<CardFooter className="p-4 pt-2 mt-auto border-t bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<HelpCircle className="h-3.5 w-3.5" />
<span>{exam.questionCount || 0} Questions</span>
<span>{t("exam.card.questions", { count: exam.questionCount || 0 })}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/60 mr-2">
{formatDate(exam.updatedAt || exam.createdAt)}

View File

@@ -1,6 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
@@ -12,6 +13,7 @@ import {
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ExamFilters() {
const t = useTranslations("examHomework")
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
@@ -30,33 +32,33 @@ export function ExamFilters() {
<FilterSearchInput
value={search || ""}
onChange={(v) => setSearch(v || null)}
placeholder="Search exams..."
placeholder={t("exam.list.searchPlaceholder")}
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Status" />
<SelectValue placeholder={t("exam.filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
<SelectItem value="all">{t("exam.filters.anyStatus")}</SelectItem>
<SelectItem value="draft">{t("exam.status.draft")}</SelectItem>
<SelectItem value="published">{t("exam.status.published")}</SelectItem>
<SelectItem value="archived">{t("exam.status.archived")}</SelectItem>
</SelectContent>
</Select>
<Select value={difficulty || "all"} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Difficulty" />
<SelectValue placeholder={t("exam.filters.difficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Difficulty</SelectItem>
<SelectItem value="1">Easy (1)</SelectItem>
<SelectItem value="2">Easy-Med (2)</SelectItem>
<SelectItem value="3">Medium (3)</SelectItem>
<SelectItem value="4">Med-Hard (4)</SelectItem>
<SelectItem value="5">Hard (5)</SelectItem>
<SelectItem value="all">{t("exam.filters.anyDifficulty")}</SelectItem>
<SelectItem value="1">{t("exam.difficulty.1")} (1)</SelectItem>
<SelectItem value="2">{t("exam.difficulty.2")} (2)</SelectItem>
<SelectItem value="3">{t("exam.difficulty.3")} (3)</SelectItem>
<SelectItem value="4">{t("exam.difficulty.4")} (4)</SelectItem>
<SelectItem value="5">{t("exam.difficulty.5")} (5)</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -140,6 +140,7 @@ export const aiProviderLabels: Record<AiProviderSummary["provider"], string> = {
openai: "OpenAI",
gemini: "Gemini",
custom: "Custom",
ollama: "Ollama",
}
export const defaultValues: Partial<ExamFormValues> = {

View File

@@ -41,7 +41,7 @@ export function ExamForm() {
defaultValues,
})
const preview = useExamPreview(form)
const preview = useExamPreview(form, t)
useEffect(() => {
const fetchMetadata = async () => {

View File

@@ -1,6 +1,7 @@
"use client"
import { useMemo, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
@@ -79,6 +80,7 @@ export function ExamPreviewDialog({
}: ExamPreviewDialogProps) {
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
const numberMap = useMemo(() => buildQuestionNumberMap(previewNodes), [previewNodes])
const t = useTranslations("examHomework")
const renderSelectablePreview = (nodes: ExamNode[]) => {
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
@@ -86,7 +88,7 @@ export function ExamPreviewDialog({
return (
<div key={node.id} className="space-y-3 mb-6">
<h3 className={cn("font-semibold text-foreground/90", depth === 0 ? "text-base" : "text-sm")}>
{node.title || "Section"}
{node.title || t("exam.previewDialog.section")}
</h3>
<div className="space-y-3">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
@@ -112,8 +114,8 @@ export function ExamPreviewDialog({
<span className="font-semibold text-foreground min-w-[28px]">{questionNumber}.</span>
<div className="flex-1 space-y-2">
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{content.text || "未命名题目"}
<span className="text-muted-foreground text-xs ml-2">({node.score ?? 0})</span>
{content.text || t("exam.previewDialog.untitledQuestion")}
<span className="text-muted-foreground text-xs ml-2">({node.score ?? 0}{t("exam.previewDialog.scoreUnit")})</span>
</div>
{content.options.length > 0 ? (
<div className="space-y-1.5">
@@ -130,8 +132,8 @@ export function ExamPreviewDialog({
{content.subQuestions.map((item, index) => (
<div key={`${node.id}-sub-${index}`} className="text-sm text-foreground/80 flex gap-2">
<span className="min-w-[28px]">{item.id}.</span>
<span className="whitespace-pre-wrap">{item.text || "未命名子题"}</span>
{item.score ? <span className="text-xs text-muted-foreground">({item.score})</span> : null}
<span className="whitespace-pre-wrap">{item.text || t("exam.previewDialog.untitledSubQuestion")}</span>
{item.score ? <span className="text-xs text-muted-foreground">({item.score}{t("exam.previewDialog.scoreUnit")})</span> : null}
</div>
))}
</div>
@@ -155,18 +157,24 @@ export function ExamPreviewDialog({
<DialogContent className="max-w-7xl h-[90vh] flex flex-col p-0 gap-0">
<div className="p-4 border-b shrink-0 flex items-center justify-between">
<DialogTitle className="text-lg font-semibold tracking-tight">
{previewTitle || previewTitleValue || "Exam Preview"}
{previewTitle || previewTitleValue || t("exam.previewDialog.title")}
</DialogTitle>
</div>
{previewLoading ? (
<div className="flex-1 py-20 text-center text-muted-foreground">Generating preview...</div>
<div className="flex-1 py-20 text-center text-muted-foreground">{t("exam.previewDialog.generating")}</div>
) : previewNodes.length > 0 ? (
<div className="flex-1 grid grid-cols-12 min-h-0">
<div className="col-span-5 border-r min-h-0 flex flex-col">
<div className="p-4 border-b">
<div className="text-sm font-medium"></div>
<div className="text-sm font-medium">{t("exam.previewDialog.fullPreview")}</div>
<div className="text-xs text-muted-foreground mt-1">
{previewQuestionRows.length} · {activePreviewMeta.subject} · {activePreviewMeta.grade} · {activePreviewMeta.durationMin} · {activePreviewMeta.totalScore}
{t("exam.previewDialog.summary", {
count: previewQuestionRows.length,
subject: activePreviewMeta.subject,
grade: activePreviewMeta.grade,
minutes: activePreviewMeta.durationMin,
total: activePreviewMeta.totalScore,
})}
</div>
</div>
<ScrollArea className="flex-1">
@@ -192,11 +200,11 @@ export function ExamPreviewDialog({
</div>
</div>
) : (
<div className="flex-1 py-20 text-center text-muted-foreground">No preview available</div>
<div className="flex-1 py-20 text-center text-muted-foreground">{t("exam.previewDialog.noPreview")}</div>
)}
<div className="border-t p-4 flex justify-end">
<Button type="button" disabled={previewLoading || previewNodes.length === 0} onClick={handleConfirmCreate}>
Confirm & Create
{t("exam.previewDialog.confirmCreate")}
</Button>
</div>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client"
import { Loader2, Wand2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Textarea } from "@/shared/components/ui/textarea"
@@ -55,8 +56,9 @@ export function ExamPreviewQuestionEditor({
handleRewriteSelectedQuestion,
previewRawOutput,
}: ExamPreviewQuestionEditorProps) {
const t = useTranslations("examHomework")
if (!selectedQuestion?.question || !selectedContent) {
return <div className="flex-1 py-20 text-center text-muted-foreground"></div>
return <div className="flex-1 py-20 text-center text-muted-foreground">{t("exam.preview.selectQuestionHint")}</div>
}
const isChoiceQuestion = selectedQuestion.question.type === "single_choice" || selectedQuestion.question.type === "multiple_choice"
@@ -65,15 +67,15 @@ export function ExamPreviewQuestionEditor({
<div className="flex-1 min-h-0 flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground mt-1"> AI </div>
<div className="text-sm font-medium">{t("exam.preview.editorTitle")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("exam.preview.editorDescription")}</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4 space-y-4">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label></Label>
<Label>{t("exam.richEditor.questionType")}</Label>
<Select
value={selectedQuestion.question.type}
onValueChange={(value) => {
@@ -97,7 +99,7 @@ export function ExamPreviewQuestionEditor({
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Label>{t("exam.form.difficulty")}</Label>
<Input
type="number"
min={1}
@@ -113,7 +115,7 @@ export function ExamPreviewQuestionEditor({
/>
</div>
<div className="space-y-1">
<Label></Label>
<Label>{t("exam.richEditor.score")}</Label>
<Input
type="number"
min={0}
@@ -127,7 +129,7 @@ export function ExamPreviewQuestionEditor({
</div>
<div className="space-y-1">
<Label></Label>
<Label>{t("exam.preview.stem")}</Label>
<Textarea
className="min-h-[140px]"
value={selectedContent.text}
@@ -160,23 +162,23 @@ export function ExamPreviewQuestionEditor({
/>
<div className="rounded-md border p-3 space-y-2">
<Label>AI </Label>
<Label>{t("exam.preview.aiRewriteInstruction")}</Label>
<Textarea
className="min-h-[90px]"
placeholder="例如:把这题改成更难、增加一个干扰项、保持总分不变。"
placeholder={t("exam.preview.aiRewritePlaceholder")}
value={rewriteInstruction}
onChange={(event) => setRewriteInstruction(event.target.value)}
/>
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={handleRewriteSelectedQuestion} disabled={rewritingQuestion}>
{rewritingQuestion ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wand2 className="mr-2 h-4 w-4" />}
AI
{t("exam.preview.aiRewriteButton")}
</Button>
</div>
</div>
{previewRawOutput ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="text-xs font-medium mb-2"></div>
<div className="text-xs font-medium mb-2">{t("exam.preview.rawModelOutput")}</div>
<pre className="whitespace-pre-wrap text-xs text-muted-foreground">{previewRawOutput}</pre>
</div>
) : null}

View File

@@ -1,8 +1,14 @@
import { createId } from "@paralleldrive/cuid2"
import type { AiPreviewData } from "../actions"
import type { Dispatch, SetStateAction } from "react"
import { toast } from "sonner"
import type { useTranslations } from "next-intl"
import { previewAiExamAction, type AiPreviewData } from "../actions"
import type { ExamNode } from "./assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
import type { EditableQuestionContent, ExamFormValues, PreviewSnapshotMeta } from "./exam-form-types"
import type { EditableQuestionContent, ExamFormValues, PreviewBackgroundTask, PreviewSnapshotMeta } from "./exam-form-types"
import { previewTaskStorageKey } from "./exam-form-types"
type Translator = ReturnType<typeof useTranslations>
export function buildPreviewNodes(data: AiPreviewData): ExamNode[] {
const now = new Date()
@@ -88,6 +94,9 @@ export function flattenPreviewQuestions(nodes: ExamNode[]) {
if (node.type === "group" && node.children) {
walk(node.children, node.title || sectionTitle)
}
if (node.type === "section" && node.children) {
walk(node.children, node.title || sectionTitle)
}
})
}
walk(nodes)
@@ -99,7 +108,7 @@ export function findPreviewQuestionNode(nodes: ExamNode[], questionId: string):
if (node.type === "question" && node.questionId === questionId && node.question) {
return node
}
if (node.type === "group" && node.children) {
if ((node.type === "group" || node.type === "section") && node.children) {
const found = findPreviewQuestionNode(node.children, questionId)
if (found) return found
}
@@ -112,7 +121,7 @@ export function updatePreviewQuestionNodeInList(questionId: string, items: ExamN
if (node.type === "question" && node.questionId === questionId && node.question) {
return updater(node)
}
if (node.type === "group" && node.children) {
if ((node.type === "group" || node.type === "section") && node.children) {
return { ...node, children: updatePreviewQuestionNodeInList(questionId, node.children, updater) }
}
return node
@@ -157,7 +166,7 @@ export function buildPreviewPayload(nodes: ExamNode[]) {
}
return
}
if (node.type === "group" && node.children) collect(node.children)
if ((node.type === "group" || node.type === "section") && node.children) collect(node.children)
})
}
collect(nodes)
@@ -166,7 +175,7 @@ export function buildPreviewPayload(nodes: ExamNode[]) {
return items.map((node) => {
const { question, ...rest } = node
void question
if (node.type === "group") {
if (node.type === "group" || node.type === "section") {
return { ...rest, children: cleanStructure(node.children || []) }
}
return rest
@@ -203,3 +212,82 @@ export function buildPreviewRequestData(values: ExamFormValues) {
}
return { formData, meta, signature: buildPreviewSignature(values) }
}
export function buildTaskFormValues(values: ExamFormValues): NonNullable<PreviewBackgroundTask["result"]>["formValues"] {
return {
title: values.title,
subject: values.subject,
grade: values.grade,
difficulty: values.difficulty,
totalScore: values.totalScore,
durationMin: values.durationMin,
aiSourceText: values.aiSourceText,
aiQuestionCount: values.aiQuestionCount,
aiProviderId: values.aiProviderId,
}
}
export function isPreviewBackgroundTask(v: unknown): v is PreviewBackgroundTask {
if (!v || typeof v !== "object") return false
const obj = v as Record<string, unknown>
return typeof obj.id === "string"
&& (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed")
&& typeof obj.createdAt === "number"
&& typeof obj.title === "string"
}
export function persistPreviewTasksToStorage(tasks: PreviewBackgroundTask[]) {
try {
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
}
}
export function parseStoredPreviewTasks(raw: string, t: Translator): PreviewBackgroundTask[] | null {
let parsed: unknown = null
try {
parsed = JSON.parse(raw)
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
return null
}
if (!Array.isArray(parsed)) return null
return parsed.filter(isPreviewBackgroundTask).map((task) =>
(task.status === "queued" || task.status === "running")
? { ...task, status: "failed" as const, message: t("exam.previewHook.taskInterrupted") }
: task)
}
export async function executeBackgroundPreviewTask(
taskId: string,
taskTitle: string,
values: ExamFormValues,
requestData: NonNullable<ReturnType<typeof buildPreviewRequestData>>,
t: Translator,
setPreviewTasks: Dispatch<SetStateAction<PreviewBackgroundTask[]>>,
) {
setPreviewTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status: "running" } : task))
try {
const result = await previewAiExamAction(null, requestData.formData)
const data = result.data
if (result.success && data) {
const nextNodes = buildPreviewNodes(data)
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "success", result: { title: data.title, nodes: nextNodes, rawOutput: data.rawOutput ?? "", meta: requestData.meta, formValues: buildTaskFormValues(values) } }
: task))
toast.success(t("exam.previewHook.backgroundComplete", { title: taskTitle }))
return
}
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: result.message || t("exam.previewHook.generatePreviewFailed") }
: task))
toast.error(t("exam.previewHook.backgroundFailed", { title: taskTitle }))
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: t("exam.previewHook.generatePreviewFailed") }
: task))
toast.error(t("exam.previewHook.backgroundFailed", { title: taskTitle }))
}
}

View File

@@ -0,0 +1,176 @@
"use client"
import type { ReactNode } from "react"
import Image from "next/image"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import type { EditorDoc } from "../editor"
/**
* 试卷预览组件
*
* 从 exam-rich-form.tsx 提取,负责渲染试卷结构预览。
* 支持分卷(section)、大题分组(group)、题目(question)三级嵌套。
*/
export function ExamPreview({ structure }: { structure: EditorDoc }): ReactNode {
const t = useTranslations("examHomework")
const counter = { value: 0 }
const renderNode = (
node: EditorDoc["structure"][number],
depth: number = 0
): ReactNode => {
// 分卷(第Ⅰ卷)—— 顶层结构,显示标题 + 自动统计
if (node.type === "section") {
const level = node.level ?? 1
return (
<div key={node.id} className="mb-6">
<div className="mb-3 flex items-baseline gap-3 border-b-2 border-foreground pb-2">
<h2 className={cn("font-bold", level === 1 ? "text-lg" : "text-base")}>
{node.title || (level === 1 ? t("exam.richForm.sectionLabel") : t("exam.richForm.partLabel"))}
</h2>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
{t("exam.richForm.questionCountSummary", { count: node.questionCount, score: node.totalScore })}
</span>
)}
</div>
<div className="space-y-4">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 大题分组(一、选择题)—— 显示标题 + 说明 + 自动统计
if (node.type === "group") {
return (
<div key={node.id} className="mb-5">
<div className="mb-2 flex items-baseline gap-2 border-b border-foreground/30 pb-1">
<h3 className="text-base font-bold text-foreground">
{node.title || t("exam.richForm.groupLabel")}
</h3>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
{t("exam.richForm.questionCountSummary", { count: node.questionCount, score: node.totalScore })}
</span>
)}
</div>
{node.instruction && (
<p className="mb-2 text-xs text-muted-foreground italic">
{node.instruction}
</p>
)}
<div className="space-y-3 pl-2">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 题目
const question = structure.questions.find((q) => q.id === node.questionId)
if (!question) return null
counter.value += 1
const qNum = counter.value
const isComposite = question.type === "composite"
const typeLabel = question.type === "single_choice" ? t("exam.richForm.typeSingleChoice")
: question.type === "multiple_choice" ? t("exam.richForm.typeMultipleChoice")
: question.type === "judgment" ? t("exam.richForm.typeJudgment")
: question.type === "composite" ? t("exam.richForm.typeComposite")
: t("exam.richForm.typeShortAnswer")
return (
<div key={node.id} className="rounded border border-border/60 bg-background p-3">
{/* 题目头部:题号 + 题型标签 + 分值 */}
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-foreground min-w-[32px]">{qNum}.</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{typeLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{question.score} {t("exam.richForm.scoreUnit")}
</span>
</div>
{/* 题干文本 */}
{question.content.text && (
<div className="mb-2 text-sm text-foreground/90 leading-relaxed whitespace-pre-wrap pl-8">
{question.content.text}
</div>
)}
{/* 选项 */}
{question.content.options && question.content.options.length > 0 && (
<div className="space-y-1.5 pl-8 mb-2">
{question.content.options.map((opt) => (
<div
key={`${question.id}-${opt.id}`}
className="text-sm text-foreground/80 flex gap-2"
>
<span className="font-medium min-w-[20px]">{opt.id}.</span>
<span>{opt.text}</span>
</div>
))}
</div>
)}
{/* 复合题子题(阅读理解小题) */}
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
<div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => (
<div key={`${question.id}-sub-${sub.id}`} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground/80">
({idx + 1})
</span>
{sub.score !== undefined && (
<span className="text-xs text-muted-foreground">
{sub.score} {t("exam.richForm.scoreUnit")}
</span>
)}
</div>
<div className="text-foreground/80 whitespace-pre-wrap pl-6">
{sub.text}
</div>
</div>
))}
</div>
)}
{/* 图片 */}
{question.content.images && question.content.images.length > 0 && (
<div className="mt-2 pl-8 flex flex-wrap gap-2">
{question.content.images.map((img) => (
<div
key={img.fileId}
className="relative h-32 w-32 overflow-hidden rounded border"
>
<Image
src={img.url}
alt={img.alt || ""}
fill
sizes="128px"
className="h-full w-full object-contain"
/>
</div>
))}
</div>
)}
</div>
)
}
return (
<div className="space-y-4">
{structure.title && (
<h2 className="text-center text-lg font-bold border-b pb-2">
{structure.title}
</h2>
)}
{structure.structure.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
{t("exam.richForm.emptyPreview")}
</p>
) : (
structure.structure.map((node) => renderNode(node))
)}
</div>
)
}

View File

@@ -17,13 +17,12 @@ import {
} from "@/shared/components/ui/select"
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { cn } from "@/shared/lib/utils"
import { getGradesAction, getSubjectsAction } from "../actions"
import { autoMarkExamAction } from "../ai-pipeline/auto-mark"
import {
autoMarkExamAction,
createExamFromRichEditorAction,
getGradesAction,
getSubjectsAction,
} from "../actions"
updateExamFromRichEditorAction,
} from "../actions-rich-editor"
import {
ExamRichEditor,
type ExamRichEditorHandle,
@@ -31,14 +30,7 @@ import {
editorDocToStructure,
type EditorDoc,
} from "../editor"
const DIFFICULTY_OPTIONS = [
{ value: "1", label: "1级 (简单)" },
{ value: "2", label: "2级" },
{ value: "3", label: "3级 (中等)" },
{ value: "4", label: "4级" },
{ value: "5", label: "5级 (困难)" },
]
import { ExamPreview } from "./exam-preview"
interface ExamRichFormValues {
title: string
@@ -50,12 +42,36 @@ interface ExamRichFormValues {
scheduledAt: string
}
export function ExamRichForm() {
interface ExamRichFormProps {
/** 模式:create=新建(默认), edit=编辑已有 */
mode?: "create" | "edit"
/** 编辑模式下的试卷 ID */
examId?: string
/** 编辑模式下的初始标题 */
initialTitle?: string
/** 编辑模式下的初始编辑器内容 */
initialContent?: EditorJSONContent | null
}
export function ExamRichForm({
mode = "create",
examId,
initialTitle,
initialContent,
}: ExamRichFormProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const difficultyOptions = [
{ value: "1", label: t("exam.form.difficultyLevel1") },
{ value: "2", label: t("exam.form.difficultyLevel2") },
{ value: "3", label: t("exam.form.difficultyLevel3") },
{ value: "4", label: t("exam.form.difficultyLevel4") },
{ value: "5", label: t("exam.form.difficultyLevel5") },
]
const editorRef = useRef<ExamRichEditorHandle>(null)
const [isPending, startTransition] = useTransition()
const [isMarking, startMarking] = useTransition()
const isEditMode = mode === "edit"
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
const [loadingSubjects, setLoadingSubjects] = useState(true)
@@ -63,7 +79,7 @@ export function ExamRichForm() {
const [loadingGrades, setLoadingGrades] = useState(true)
const [values, setValues] = useState<ExamRichFormValues>({
title: "",
title: initialTitle ?? "",
subject: "",
grade: "",
difficulty: "3",
@@ -72,9 +88,15 @@ export function ExamRichForm() {
scheduledAt: "",
})
const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(null)
const [editorDoc, setEditorDoc] = useState<EditorJSONContent | null>(initialContent ?? null)
// 编辑模式下不需要加载 subjects/grades
useEffect(() => {
if (isEditMode) {
setLoadingSubjects(false)
setLoadingGrades(false)
return
}
const fetchMetadata = async () => {
try {
const [subjectsResult, gradesResult] = await Promise.all([
@@ -104,7 +126,7 @@ export function ExamRichForm() {
}
}
void fetchMetadata()
}, [t])
}, [t, isEditMode])
/**
* AI 自动标记 —— 从编辑器当前内容获取文本,交给 AI 解析后重新载入。
@@ -145,6 +167,29 @@ export function ExamRichForm() {
toast.error(t("exam.richEditor.titleRequired"))
return
}
if (isEditMode) {
if (!examId) {
toast.error(t("exam.richForm.missingExamId"))
return
}
startTransition(async () => {
const formData = new FormData()
formData.append("examId", examId)
formData.append("title", values.title)
formData.append("editorDoc", JSON.stringify(doc))
const result = await updateExamFromRichEditorAction(null, formData)
if (result.success) {
toast.success(result.message || t("exam.richEditor.saveSuccess"))
router.push(`/teacher/exams/${examId}/build`)
} else {
toast.error(result.message || t("exam.richEditor.saveFailed"))
}
})
return
}
if (!values.subject || !values.grade) {
toast.error(t("exam.richEditor.subjectGradeRequired"))
return
@@ -190,71 +235,87 @@ export function ExamRichForm() {
onChange={(e) => setValues((v) => ({ ...v, title: e.target.value }))}
className="h-9 min-w-[200px] flex-1"
/>
<Select
value={values.subject}
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
disabled={loadingSubjects}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingSubjects ? "..." : t("exam.form.subject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.grade}
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
disabled={loadingGrades}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingGrades ? "..." : t("exam.form.grade")} />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.difficulty}
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
>
<SelectTrigger className="h-9 w-[100px]">
<SelectValue placeholder={t("exam.form.difficulty")} />
</SelectTrigger>
<SelectContent>
{DIFFICULTY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={String(values.totalScore)}
onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.totalScore")}
/>
<Input
type="number"
min={10}
value={String(values.durationMin)}
onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.durationMin")}
/>
{!isEditMode && (
<>
<Select
value={values.subject}
onValueChange={(val) => setValues((v) => ({ ...v, subject: val }))}
disabled={loadingSubjects}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingSubjects ? "..." : t("exam.form.subject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.grade}
onValueChange={(val) => setValues((v) => ({ ...v, grade: val }))}
disabled={loadingGrades}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue placeholder={loadingGrades ? "..." : t("exam.form.grade")} />
</SelectTrigger>
<SelectContent>
{grades.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={values.difficulty}
onValueChange={(val) => setValues((v) => ({ ...v, difficulty: val }))}
>
<SelectTrigger className="h-9 w-[100px]">
<SelectValue placeholder={t("exam.form.difficulty")} />
</SelectTrigger>
<SelectContent>
{difficultyOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={String(values.totalScore)}
onChange={(e) => setValues((v) => ({ ...v, totalScore: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.totalScore")}
/>
<Input
type="number"
min={10}
value={String(values.durationMin)}
onChange={(e) => setValues((v) => ({ ...v, durationMin: Number(e.target.value) || 0 }))}
className="h-9 w-[90px]"
title={t("exam.form.durationMin")}
/>
</>
)}
<div className="ml-auto flex items-center gap-2">
{isEditMode && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => router.push(`/teacher/exams/${examId}/build`)}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<FileText className="h-4 w-4" />
{t("exam.richForm.backToBuild")}
</Button>
)}
<Button
type="button"
variant="outline"
@@ -288,6 +349,7 @@ export function ExamRichForm() {
<div className="flex-1 min-h-0">
<ExamRichEditor
ref={editorRef}
initialContent={initialContent}
onChange={handleEditorChange}
className="h-full rounded-none border-0 shadow-none"
/>
@@ -320,159 +382,3 @@ export function ExamRichForm() {
</form>
)
}
function ExamPreview({ structure }: { structure: EditorDoc }) {
const counter = { value: 0 }
const renderNode = (
node: EditorDoc["structure"][number],
depth: number = 0
): React.ReactNode => {
// 分卷(第Ⅰ卷)—— 顶层结构,显示标题 + 自动统计
if (node.type === "section") {
const level = node.level ?? 1
return (
<div key={node.id} className="mb-6">
<div className="mb-3 flex items-baseline gap-3 border-b-2 border-foreground pb-2">
<h2 className={cn("font-bold", level === 1 ? "text-lg" : "text-base")}>
{node.title || (level === 1 ? "分卷" : "部分")}
</h2>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)}
</div>
<div className="space-y-4">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 大题分组(一、选择题)—— 显示标题 + 说明 + 自动统计
if (node.type === "group") {
return (
<div key={node.id} className="mb-5">
<div className="mb-2 flex items-baseline gap-2 border-b border-foreground/30 pb-1">
<h3 className="text-base font-bold text-foreground">
{node.title || "大题"}
</h3>
{node.questionCount !== undefined && node.totalScore !== undefined && (
<span className="text-xs text-muted-foreground">
( {node.questionCount} ,{node.totalScore} )
</span>
)}
</div>
{node.instruction && (
<p className="mb-2 text-xs text-muted-foreground italic">
{node.instruction}
</p>
)}
<div className="space-y-3 pl-2">
{(node.children ?? []).map((child) => renderNode(child, depth + 1))}
</div>
</div>
)
}
// 题目
const question = structure.questions.find((q) => q.id === node.questionId)
if (!question) return null
counter.value += 1
const qNum = counter.value
const isComposite = question.type === "composite"
const typeLabel = question.type === "single_choice" ? "单选"
: question.type === "multiple_choice" ? "多选"
: question.type === "judgment" ? "判断"
: question.type === "composite" ? "复合"
: "简答"
return (
<div key={node.id} className="rounded border border-border/60 bg-background p-3">
{/* 题目头部:题号 + 题型标签 + 分值 */}
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-foreground min-w-[32px]">{qNum}.</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{typeLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{question.score}
</span>
</div>
{/* 题干文本 */}
{question.content.text && (
<div className="mb-2 text-sm text-foreground/90 leading-relaxed whitespace-pre-wrap pl-8">
{question.content.text}
</div>
)}
{/* 选项 */}
{question.content.options && question.content.options.length > 0 && (
<div className="space-y-1.5 pl-8 mb-2">
{question.content.options.map((opt) => (
<div
key={`${question.id}-${opt.id}`}
className="text-sm text-foreground/80 flex gap-2"
>
<span className="font-medium min-w-[20px]">{opt.id}.</span>
<span>{opt.text}</span>
</div>
))}
</div>
)}
{/* 复合题子题(阅读理解小题) */}
{isComposite && question.content.subQuestions && question.content.subQuestions.length > 0 && (
<div className="mt-3 ml-4 space-y-3 border-l-2 border-primary/30 pl-4">
{question.content.subQuestions.map((sub, idx) => (
<div key={`${question.id}-sub-${sub.id}`} className="text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-foreground/80">
({idx + 1})
</span>
{sub.score !== undefined && (
<span className="text-xs text-muted-foreground">
{sub.score}
</span>
)}
</div>
<div className="text-foreground/80 whitespace-pre-wrap pl-6">
{sub.text}
</div>
</div>
))}
</div>
)}
{/* 图片 */}
{question.content.images && question.content.images.length > 0 && (
<div className="mt-2 pl-8 flex flex-wrap gap-2">
{question.content.images.map((img) => (
<img
key={img.fileId}
src={img.url}
alt={img.alt || ""}
className="max-h-32 rounded border"
/>
))}
</div>
)}
</div>
)
}
return (
<div className="space-y-4">
{structure.title && (
<h2 className="text-center text-lg font-bold border-b pb-2">
{structure.title}
</h2>
)}
{structure.structure.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-8">
,
</p>
) : (
structure.structure.map((node) => renderNode(node))
)}
</div>
)
}

View File

@@ -1,6 +1,8 @@
"use client"
import { useMemo, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { isRecord } from "@/shared/lib/type-guards"
import { cn } from "@/shared/lib/utils"
type ChoiceOption = {
@@ -23,8 +25,6 @@ type ExamViewerProps = {
onQuestionSelect?: (questionId: string) => void
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
@@ -47,6 +47,7 @@ const getOptions = (content: unknown): ChoiceOption[] => {
export function ExamViewer(props: ExamViewerProps) {
const { structure, questions, className } = props
const t = useTranslations("examHomework")
const questionById = useMemo(() => new Map(questions.map((q) => [q.questionId, q] as const)), [questions])
const questionNumberById = useMemo(() => {
@@ -61,7 +62,7 @@ export function ExamViewer(props: ExamViewerProps) {
if (questionId) ids.push(questionId)
continue
}
if (node.type === "group") {
if (node.type === "group" || node.type === "section") {
visit(Array.isArray(node.children) ? node.children : [])
}
}
@@ -92,13 +93,28 @@ export function ExamViewer(props: ExamViewerProps) {
if (!isRecord(node)) return null
const type = node.type
if (type === "section") {
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : t("exam.viewer.section")
const children = Array.isArray(node.children) ? node.children : []
return (
<div key={`s-${depth}-${idx}`} className="space-y-3">
<div className="text-lg font-bold border-b-2 border-foreground/30 pb-1">
{title}
</div>
{renderNodes(children, depth + 1)}
</div>
)
}
if (type === "group") {
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : "Section"
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : t("exam.viewer.group")
const instruction = typeof node.instruction === "string" && node.instruction.trim().length > 0 ? node.instruction : null
const children = Array.isArray(node.children) ? node.children : []
return (
<div key={`g-${depth}-${idx}`} className="space-y-3">
<div className={depth === 0 ? "text-base font-semibold" : "text-sm font-semibold text-muted-foreground"}>
{title}
{instruction && <span className="ml-2 text-xs font-normal text-muted-foreground italic">{instruction}</span>}
</div>
{renderNodes(children, depth + 1)}
</div>
@@ -144,9 +160,9 @@ export function ExamViewer(props: ExamViewerProps) {
<div className="min-w-0">
<div className="whitespace-pre-wrap text-sm">{text || "—"}</div>
<div className="mt-2 text-xs text-muted-foreground">
<span className="capitalize">{q?.questionType ?? "unknown"}</span>
<span className="capitalize">{q?.questionType ?? t("exam.viewer.unknown")}</span>
<span className="mx-2"></span>
<span>Score: {maxScore}</span>
<span>{t("exam.viewer.scoreLabel", { score: maxScore })}</span>
</div>
</div>
</div>
@@ -180,5 +196,5 @@ export function ExamViewer(props: ExamViewerProps) {
return <div className={className}>{renderNodes(flatNodes, 0)}</div>
}
return <div className={className ?? "text-sm text-muted-foreground"}>No questions available.</div>
return <div className={className ?? "text-sm text-muted-foreground"}>{t("exam.viewer.noQuestions")}</div>
}

View File

@@ -1,6 +1,7 @@
"use client"
import { Plus, Trash2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
@@ -23,10 +24,11 @@ export function QuestionOptionsEditor({
updatePreviewQuestionNode,
parseEditableContent,
}: QuestionOptionsEditorProps) {
const t = useTranslations("examHomework")
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label></Label>
<Label>{t("exam.questionOptions.label")}</Label>
<Button
type="button"
variant="outline"
@@ -50,7 +52,7 @@ export function QuestionOptionsEditor({
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.questionOptions.addOption")}
</Button>
</div>
<div className="space-y-2">
@@ -84,7 +86,7 @@ export function QuestionOptionsEditor({
/>
<div className="flex items-center gap-2 px-2">
<Checkbox
aria-label={`标记选项 ${option.id} 为正确答案`}
aria-label={t("exam.questionOptions.markCorrectAria", { id: option.id })}
checked={option.isCorrect}
onCheckedChange={(checked) => {
const isCorrect = Boolean(checked)
@@ -104,13 +106,13 @@ export function QuestionOptionsEditor({
})
}}
/>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("exam.questionOptions.correct")}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="删除选项"
aria-label={t("exam.questionOptions.deleteOptionAria")}
onClick={() => {
updatePreviewQuestionNode(selectedQuestionId, (node) => {
if (!node.question) return node

View File

@@ -1,6 +1,7 @@
"use client"
import { Plus, Trash2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
@@ -20,10 +21,11 @@ export function QuestionSubQuestionsEditor({
updatePreviewQuestionNode,
parseEditableContent,
}: QuestionSubQuestionsEditorProps) {
const t = useTranslations("examHomework")
return (
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<Label></Label>
<Label>{t("exam.subQuestions.title")}</Label>
<Button
type="button"
variant="outline"
@@ -49,7 +51,7 @@ export function QuestionSubQuestionsEditor({
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.subQuestions.add")}
</Button>
</div>
{selectedContent.subQuestions.length > 0 ? (
@@ -70,7 +72,7 @@ export function QuestionSubQuestionsEditor({
/>
<Input
value={item.text}
placeholder="子题内容"
placeholder={t("exam.subQuestions.contentPlaceholder")}
onChange={(event) => {
const text = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
@@ -83,7 +85,7 @@ export function QuestionSubQuestionsEditor({
/>
<Input
value={item.answer ?? ""}
placeholder="参考答案"
placeholder={t("exam.subQuestions.answerPlaceholder")}
onChange={(event) => {
const answer = event.target.value
updatePreviewQuestionNode(selectedQuestionId, (node) => {
@@ -98,7 +100,7 @@ export function QuestionSubQuestionsEditor({
type="number"
min={0}
value={typeof item.score === "number" ? item.score : ""}
placeholder="分值"
placeholder={t("exam.subQuestions.scorePlaceholder")}
onChange={(event) => {
const next = Number.parseInt(event.target.value, 10)
updatePreviewQuestionNode(selectedQuestionId, (node) => {
@@ -130,7 +132,7 @@ export function QuestionSubQuestionsEditor({
))}
</div>
) : (
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">{t("exam.subQuestions.empty")}</div>
)}
</div>
)

View File

@@ -0,0 +1,230 @@
/**
* exams 模块 Widget 配置P2-5 骨架)
*
* 设计目标:
* - 配置驱动决定每个角色在 exams 模块渲染哪些子模块/区块
* - 新增角色或调整 Widget 只需修改此配置文件,不需动组件代码
* - 与 dashboard/config/widget-configs.ts 模式对齐,保持一致性
*
* 当前阶段P2-5
* - 仅落地配置类型与默认配置骨架
* - 不强制改造现有页面teacher/exams/all 等仍使用既有布局)
* - P2-7+ 落地多角色页admin/student exams时直接复用此配置
*
* 使用示例:
* ```tsx
* import { getExamWidgetConfig, ExamWidgetRenderer } from "@/modules/exams/config"
* const config = getExamWidgetConfig("teacher")
* <ExamWidgetRenderer config={config} data={data} />
* ```
*/
/** exams 模块支持的角色(与 dashboard 对齐) */
export type ExamRole = "admin" | "teacher" | "student" | "parent"
/** exams 模块 Widget 标识 */
export type ExamWidgetId =
| "exam-list"
| "exam-create-entry"
| "exam-analytics"
| "exam-proctoring"
| "exam-templates"
| "exam-blueprint"
| "exam-grade-entry"
| "exam-history"
/** exams 模块 Widget 骨架屏变体(与 exam-boundaries 对齐) */
export type ExamWidgetSkeletonVariant = "list" | "card" | "detail" | "analytics" | "editor"
/** exams 模块渲染区域标识 */
export type ExamWidgetSlot =
| "header"
| "stats"
| "main"
| "sidebar"
| "actions"
| "table"
/**
* 单个 Widget 配置项
*/
export interface ExamWidgetConfig {
/** Widget 唯一标识(用于埋点与去重) */
id: ExamWidgetId
/** 渲染区域 */
slot: ExamWidgetSlot
/** 骨架屏变体 */
skeletonVariant: ExamWidgetSkeletonVariant
/** 响应式布局类名 */
layoutClassName: string
/** 是否默认显示 */
defaultVisible: boolean
/** 排序优先级(数字越小越靠前) */
order: number
/** 可选的 Widget 配置参数 */
props?: Record<string, unknown>
}
/** 角色对应 exams 模块布局配置 */
export interface ExamWidgetLayoutConfig {
role: ExamRole
widgets: ExamWidgetConfig[]
}
/** Teacher 角色配置:完整组卷/预览/分析/监考能力 */
export const TEACHER_EXAM_WIDGET_CONFIG: ExamWidgetLayoutConfig = {
role: "teacher",
widgets: [
{
id: "exam-create-entry",
slot: "actions",
skeletonVariant: "card",
layoutClassName: "grid gap-4 md:grid-cols-3",
defaultVisible: true,
order: 10,
props: { entries: ["manual", "ai", "rich-editor"] },
},
{
id: "exam-list",
slot: "main",
skeletonVariant: "list",
layoutClassName: "",
defaultVisible: true,
order: 20,
},
{
id: "exam-analytics",
slot: "main",
skeletonVariant: "analytics",
layoutClassName: "",
defaultVisible: false,
order: 30,
},
{
id: "exam-proctoring",
slot: "main",
skeletonVariant: "detail",
layoutClassName: "",
defaultVisible: false,
order: 40,
},
{
id: "exam-grade-entry",
slot: "table",
skeletonVariant: "list",
layoutClassName: "",
defaultVisible: false,
order: 50,
},
],
}
/** Admin 角色配置:聚合全校/年级视角 */
export const ADMIN_EXAM_WIDGET_CONFIG: ExamWidgetLayoutConfig = {
role: "admin",
widgets: [
{
id: "exam-list",
slot: "main",
skeletonVariant: "list",
layoutClassName: "",
defaultVisible: true,
order: 10,
props: { scope: "all-school" },
},
{
id: "exam-analytics",
slot: "main",
skeletonVariant: "analytics",
layoutClassName: "",
defaultVisible: true,
order: 20,
props: { aggregation: "by-grade" },
},
{
id: "exam-templates",
slot: "sidebar",
skeletonVariant: "card",
layoutClassName: "",
defaultVisible: false,
order: 30,
},
],
}
/** Student 角色配置:以作答与成绩回顾为主 */
export const STUDENT_EXAM_WIDGET_CONFIG: ExamWidgetLayoutConfig = {
role: "student",
widgets: [
{
id: "exam-list",
slot: "main",
skeletonVariant: "card",
layoutClassName: "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3",
defaultVisible: true,
order: 10,
props: { filter: "published", showTake: true },
},
{
id: "exam-history",
slot: "main",
skeletonVariant: "list",
layoutClassName: "",
defaultVisible: true,
order: 20,
props: { showScore: true, showAnswers: true },
},
],
}
/** Parent 角色配置:以查看子女考试表现为主 */
export const PARENT_EXAM_WIDGET_CONFIG: ExamWidgetLayoutConfig = {
role: "parent",
widgets: [
{
id: "exam-list",
slot: "main",
skeletonVariant: "card",
layoutClassName: "grid grid-cols-1 gap-4 md:grid-cols-2",
defaultVisible: true,
order: 10,
props: { filter: "child", showScore: true },
},
{
id: "exam-history",
slot: "main",
skeletonVariant: "list",
layoutClassName: "",
defaultVisible: true,
order: 20,
props: { trendChart: true },
},
],
}
/** 按角色获取 exams 模块 Widget 配置 */
export function getExamWidgetConfig(role: ExamRole): ExamWidgetLayoutConfig {
switch (role) {
case "admin":
return ADMIN_EXAM_WIDGET_CONFIG
case "teacher":
return TEACHER_EXAM_WIDGET_CONFIG
case "student":
return STUDENT_EXAM_WIDGET_CONFIG
case "parent":
return PARENT_EXAM_WIDGET_CONFIG
}
}
/**
* 从配置中筛选指定 slot 的 Widget按 order 升序)。
* 用于按区域分块渲染。
*/
export function getWidgetsBySlot(
config: ExamWidgetLayoutConfig,
slot: ExamWidgetSlot
): ExamWidgetConfig[] {
return config.widgets
.filter((w) => w.slot === slot && w.defaultVisible)
.sort((a, b) => a.order - b.order)
}

View File

@@ -0,0 +1,514 @@
import "server-only"
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { count, eq, desc, and, inArray, asc, type SQL } from "drizzle-orm"
import { cache } from "react"
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { getQuestionTypeMapByIds } from "@/modules/questions/data-access"
import type { GradeExamsResult, GradeExamItem, ExamForGradeEntry, ExamOptionForEntry } from "./types"
import type { DataScope } from "@/shared/types/permissions"
import { parseExamMeta, getString, getNumber, toExamStatus } from "./data-access"
// ---------------------------------------------------------------------------
// 跨模块查询接口(供其他模块调用,避免直查 exams/examSubmissions 表)
// 本文件从 data-access.ts 拆分而来,原文件超过 1000 行硬上限。
// ---------------------------------------------------------------------------
export type ExamsDashboardStats = {
examCount: number
}
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "grade_managed") {
// P0 fix: empty gradeIds must NOT bypass filtering
if (scope.gradeIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
}
}
if (scope.type === "class_taught") {
// P0 fix: empty classIds must NOT bypass filtering
if (scope.classIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
} else {
conditions.push(eq(exams.id, "__none__"))
}
}
}
}
const [row] = await db
.select({ value: count() })
.from(exams)
.where(conditions.length ? and(...conditions) : undefined)
return { examCount: Number(row?.value ?? 0) }
})
/**
* 获取指定年级 ID 列表对应的所有考试 ID。
* 供 homework/grades 等模块跨模块调用使用。
*/
export const getExamIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
if (gradeIds.length === 0) return []
const rows = await db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
return rows.map((r) => r.id)
}
/**
* 获取考试的基本信息(含题目列表),供 homework 模块创建作业时使用。
* 返回的数据包含 examId、title、subjectId、structure 和题目列表。
*/
export type ExamWithQuestionsForHomework = {
id: string
title: string
subjectId: string | null
structure: unknown
questions: Array<{ questionId: string; score: number | null; order: number | null }>
}
export const getExamWithQuestionsForHomework = async (
examId: string
): Promise<ExamWithQuestionsForHomework | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
subjectId: exam.subjectId,
structure: exam.structure,
questions: exam.questions.map((q) => ({
questionId: q.questionId,
score: q.score ?? null,
order: q.order ?? null,
})),
}
}
/**
* 获取多个考试的 subjectId 映射examId -> subjectId
* 供 homework 模块查询作业对应科目时使用。
*/
export const getExamSubjectIdMap = async (examIds: string[]): Promise<Map<string, string | null>> => {
if (examIds.length === 0) return new Map()
const rows = await db
.select({ id: exams.id, subjectId: exams.subjectId })
.from(exams)
.where(inArray(exams.id, examIds))
const map = new Map<string, string | null>()
for (const r of rows) map.set(r.id, r.subjectId)
return map
}
/**
* 获取考试标题。
* 供 proctoring 等模块跨模块调用使用。
*/
export const getExamTitleById = async (examId: string): Promise<string | null> => {
const [row] = await db
.select({ title: exams.title })
.from(exams)
.where(eq(exams.id, examId))
.limit(1)
return row?.title ?? null
}
/**
* 获取考试的基本信息(含监考模式相关字段),供 proctoring 模块使用。
*/
export type ExamForProctoring = {
id: string
title: string
examMode: string | null
durationMinutes: number | null
shuffleQuestions: boolean | null
allowLateStart: boolean | null
lateStartGraceMinutes: number | null
antiCheatEnabled: boolean | null
}
export const getExamForProctoringCrossModule = async (examId: string): Promise<ExamForProctoring | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
examMode: exam.examMode,
durationMinutes: exam.durationMinutes ?? null,
shuffleQuestions: exam.shuffleQuestions ?? false,
allowLateStart: exam.allowLateStart ?? false,
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
antiCheatEnabled: exam.antiCheatEnabled ?? false,
}
}
/**
* 校验提交记录归属(监考事件上报前的安全校验)。
* 供 proctoring 模块跨模块调用使用。
*/
export const getExamSubmissionForProctoringCrossModule = async (
submissionId: string,
studentId: string
): Promise<{ id: string; examId: string; studentId: string } | null> => {
const submission = await db.query.examSubmissions.findFirst({
where: and(
eq(examSubmissions.id, submissionId),
eq(examSubmissions.studentId, studentId),
),
columns: {
id: true,
examId: true,
studentId: true,
},
})
return submission ?? null
}
/**
* 获取考试提交记录及其答题数据,供 diagnostic 模块更新知识点掌握度使用。
*/
export type ExamSubmissionWithAnswers = {
studentId: string
answers: Array<{ questionId: string; score: number | null }>
}
export const getExamSubmissionWithAnswers = async (
submissionId: string
): Promise<ExamSubmissionWithAnswers | null> => {
const [submission] = await db
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
if (!submission) return null
const answers = await db
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
return {
studentId: submission.studentId,
answers,
}
}
/**
* 获取一场考试的所有提交记录(含学生 ID 和状态),供 proctoring 模块使用。
*/
export type ExamSubmissionForProctoringSummary = {
id: string
studentId: string
status: string | null
}
export const getExamSubmissionsForExam = async (
examId: string
): Promise<ExamSubmissionForProctoringSummary[]> => {
const rows = await db
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
status: examSubmissions.status,
})
.from(examSubmissions)
.where(eq(examSubmissions.examId, examId))
return rows
}
/**
* 批量插入考试题目(跨模块写接口)。
* 供 lesson-preparation 模块发布课案为作业时使用,避免直接操作 examQuestions 表。
*/
export const addExamQuestions = async (
examId: string,
items: Array<{ questionId: string; score: number; order: number }>
): Promise<void> => {
if (items.length === 0) return
await db.insert(examQuestions).values(
items.map((it) => ({
examId,
questionId: it.questionId,
score: it.score,
order: it.order,
}))
)
}
/**
* 年级仪表盘 - 维度3获取年级下所有考试 + 提交统计。
* exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。
*/
export const getExamsByGradeId = cache(
async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)]
// scope 过滤
if (params.scope.type === "owned") {
conditions.push(eq(exams.creatorId, params.scope.userId))
}
if (params.scope.type === "grade_managed") {
if (params.scope.gradeIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
// grade_managed 且当前 gradeId 在管辖范围内才可见
if (!params.scope.gradeIds.includes(params.gradeId)) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
}
}
if (params.scope.type === "class_taught") {
// 教师仅能看到所教班级对应年级的考试
if (params.scope.classIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (!gradeIds.includes(params.gradeId)) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
}
}
const examRows = await db
.select({
id: exams.id,
title: exams.title,
status: exams.status,
subjectId: exams.subjectId,
startTime: exams.startTime,
createdAt: exams.createdAt,
})
.from(exams)
.where(and(...conditions))
.orderBy(desc(exams.createdAt))
if (examRows.length === 0) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
const examIds = examRows.map((e) => e.id)
// 并行查询:科目名称 + 提交统计
const [subjectOptions, submissionRows] = await Promise.all([
getSubjectOptions(),
db
.select({
examId: examSubmissions.examId,
status: examSubmissions.status,
score: examSubmissions.score,
})
.from(examSubmissions)
.where(inArray(examSubmissions.examId, examIds)),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
// 按考试分组统计提交
const statsByExam = new Map<string, { total: number; graded: number; scoreSum: number }>()
for (const s of submissionRows) {
const entry = statsByExam.get(s.examId) ?? { total: 0, graded: 0, scoreSum: 0 }
entry.total += 1
if (s.status === "graded" && s.score !== null) {
entry.graded += 1
entry.scoreSum += Number(s.score)
}
statsByExam.set(s.examId, entry)
}
const items: GradeExamItem[] = examRows.map((e) => {
const stats = statsByExam.get(e.id) ?? { total: 0, graded: 0, scoreSum: 0 }
return {
id: e.id,
title: e.title,
status: toExamStatus(e.status),
subjectId: e.subjectId,
subjectName: e.subjectId ? (subjectNameById.get(e.subjectId) ?? null) : null,
scheduledAt: e.startTime ? e.startTime.toISOString() : null,
createdAt: e.createdAt.toISOString(),
submissionCount: stats.total,
gradedCount: stats.graded,
averageScore: stats.graded > 0 ? Math.round((stats.scoreSum / stats.graded) * 100) / 100 : null,
}
})
const totals = {
examCount: items.length,
publishedCount: items.filter((i) => i.status === "published").length,
draftCount: items.filter((i) => i.status === "draft").length,
archivedCount: items.filter((i) => i.status === "archived").length,
totalSubmissions: items.reduce((sum, i) => sum + i.submissionCount, 0),
totalGraded: items.reduce((sum, i) => sum + i.gradedCount, 0),
}
return { gradeId: params.gradeId, exams: items, totals }
}
)
/**
* 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。
*/
export const getExamsForGradeEntry = cache(
async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
const conditions: SQL[] = []
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
} else {
conditions.push(eq(exams.id, "__none__"))
}
} else if (scope.type === "class_taught") {
conditions.push(eq(exams.id, "__none__"))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
} else if (scope.type === "grade_managed") {
conditions.push(eq(exams.id, "__none__"))
}
const examRows = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)],
with: { subject: true, gradeEntity: true },
})
if (examRows.length === 0) return []
const examIds = examRows.map((e) => e.id)
const questionCountRows = await db
.select({ examId: examQuestions.examId, count: count() })
.from(examQuestions)
.where(inArray(examQuestions.examId, examIds))
.groupBy(examQuestions.examId)
const questionCountMap = new Map(
questionCountRows.map((r) => [r.examId, Number(r.count)])
)
return examRows
.filter((e) => (questionCountMap.get(e.id) ?? 0) > 0)
.map((e) => {
const meta = parseExamMeta(e.description ?? null)
return {
id: e.id,
title: e.title,
subjectName: e.subject?.name ?? getString(meta, "subject") ?? "General",
gradeName: e.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
questionCount: questionCountMap.get(e.id) ?? 0,
totalScore: getNumber(meta, "totalScore") ?? 100,
}
})
}
)
/**
* 获取单个试卷详情(含题目列表),用于成绩录入表格表头。
*/
export const getExamForGradeEntry = cache(
async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: { subject: true, gradeEntity: true },
})
if (!exam) return null
if (scope && scope.type !== "all") {
if (scope.type === "owned" && exam.creatorId !== scope.userId) return null
if (scope.type === "grade_managed") {
if (scope.gradeIds.length === 0) return null
if (!scope.gradeIds.includes(exam.gradeId ?? "")) return null
}
if (scope.type === "class_taught") {
if (scope.classIds.length === 0) return null
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length === 0) return null
if (!gradeIds.includes(exam.gradeId ?? "")) return null
}
}
const questionRows = await db
.select({
questionId: examQuestions.questionId,
score: examQuestions.score,
order: examQuestions.order,
})
.from(examQuestions)
.where(eq(examQuestions.examId, examId))
.orderBy(asc(examQuestions.order))
if (questionRows.length === 0) return null
// P0-1 修复:不再直接 JOIN questions 表,改为调用 questions 模块 data-access 获取题目类型
const questionIds = questionRows.map((q) => q.questionId)
const questionTypeMap = await getQuestionTypeMapByIds(questionIds)
const meta = parseExamMeta(exam.description ?? null)
const computedTotal = questionRows.reduce((sum, q) => sum + (q.score ?? 0), 0)
return {
id: exam.id,
title: exam.title,
subjectId: exam.subjectId,
gradeId: exam.gradeId,
totalScore: getNumber(meta, "totalScore") ?? computedTotal,
questions: questionRows.map((q) => ({
id: q.questionId,
order: q.order ?? 0,
score: q.score ?? 0,
type: questionTypeMap.get(q.questionId) ?? "text",
})),
}
}
)

View File

@@ -1,17 +1,20 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers, questions } from "@/shared/db/schema"
import { count, eq, desc, like, and, or, inArray, asc, type SQL } from "drizzle-orm"
import { exams, examQuestions } from "@/shared/db/schema"
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { createQuestionWithRelations } from "@/modules/questions/data-access"
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access"
import { escapeLikePattern } from "@/shared/lib/action-utils"
import { isRecord } from "@/shared/lib/type-guards"
import type { Exam, ExamDifficulty, ExamStatus, GradeExamsResult, GradeExamItem, ExamForGradeEntry, ExamOptionForEntry } from "./types"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
import type { DataScope } from "@/shared/types/permissions"
// 跨模块接口已拆分至 data-access-cross-module.ts文末 re-export 保持向后兼容
export type GetExamsParams = {
q?: string
status?: string
@@ -20,9 +23,7 @@ export type GetExamsParams = {
pageSize?: number
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const parseExamMeta = (description: string | null): Record<string, unknown> => {
export const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
try {
const parsed: unknown = JSON.parse(description)
@@ -32,12 +33,12 @@ const parseExamMeta = (description: string | null): Record<string, unknown> => {
}
}
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
export const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
const v = obj[key]
return typeof v === "string" ? v : undefined
}
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
export const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
const v = obj[key]
return typeof v === "number" ? v : undefined
}
@@ -52,7 +53,7 @@ const getStringArray = (obj: Record<string, unknown>, key: string): string[] | u
const isExamStatus = (v: unknown): v is ExamStatus =>
v === "draft" || v === "published" || v === "archived"
const toExamStatus = (v: string | null | undefined): ExamStatus =>
export const toExamStatus = (v: string | null | undefined): ExamStatus =>
isExamStatus(v) ? v : "draft"
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
@@ -123,6 +124,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
title: exam.title,
status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
subjectId: exam.subjectId ?? null,
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
@@ -193,6 +195,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
title: exam.title,
status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
subjectId: exam.subjectId ?? null,
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
totalScore: getNumber(meta, "totalScore") || 100,
@@ -310,6 +313,9 @@ const buildOrderedQuestionsFromStructure = (
if (node.type === "group" && Array.isArray(node.children) && node.children.length > 0) {
collectOrder(node.children)
}
if (node.type === "section" && Array.isArray(node.children) && node.children.length > 0) {
collectOrder(node.children)
}
}
}
collectOrder(structure)
@@ -387,49 +393,6 @@ export const persistAiGeneratedExamDraft = async (input: {
})
}
export type ExamsDashboardStats = {
examCount: number
}
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "grade_managed") {
// P0 fix: empty gradeIds must NOT bypass filtering
if (scope.gradeIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
}
}
if (scope.type === "class_taught") {
// P0 fix: empty classIds must NOT bypass filtering
if (scope.classIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
} else {
conditions.push(eq(exams.id, "__none__"))
}
}
}
}
const [row] = await db
.select({ value: count() })
.from(exams)
.where(conditions.length ? and(...conditions) : undefined)
return { examCount: Number(row?.value ?? 0) }
})
/**
* Get exam creator ID for ownership check.
* Returns null if exam not found.
@@ -477,6 +440,38 @@ export const updateExamWithQuestions = async (
}
}
/**
* 从富文本编辑器更新已有试卷(事务):更新 exam 标题与 structure并重建 exam_questions 关联。
* P0-4 修复:将原 actions.ts 中直接操作 DB 的事务逻辑下沉到 data-access 层。
*/
export const updateExamWithRichEditorData = async (
examId: string,
data: {
title: string
structure: unknown
orderedQuestions: Array<{ id: string; score: number }>
}
): Promise<void> => {
await db.transaction(async (tx) => {
await tx.update(exams).set({
title: data.title,
structure: data.structure,
}).where(eq(exams.id, examId))
await tx.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (data.orderedQuestions.length > 0) {
await tx.insert(examQuestions).values(
data.orderedQuestions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score,
order: idx,
}))
)
}
})
}
/**
* Delete an exam by ID.
*/
@@ -579,455 +574,29 @@ export const getExamGrades = async (): Promise<Array<{ id: string; name: string
}
// ---------------------------------------------------------------------------
// Cross-module query interfaces (供其他模块调用,避免直查 exams/examSubmissions 表)
// Re-export 跨模块接口(保持向后兼容,调用方仍可从 data-access 导入)
// ---------------------------------------------------------------------------
/**
* 获取指定年级 ID 列表对应的所有考试 ID。
* 供 homework/grades 等模块跨模块调用使用。
*/
export const getExamIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
if (gradeIds.length === 0) return []
const rows = await db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
return rows.map((r) => r.id)
}
export {
getExamsDashboardStats,
getExamIdsByGradeIds,
getExamWithQuestionsForHomework,
getExamSubjectIdMap,
getExamTitleById,
getExamForProctoringCrossModule,
getExamSubmissionForProctoringCrossModule,
getExamSubmissionWithAnswers,
getExamSubmissionsForExam,
addExamQuestions,
getExamsByGradeId,
getExamsForGradeEntry,
getExamForGradeEntry,
} from "./data-access-cross-module"
/**
* 获取考试的基本信息(含题目列表),供 homework 模块创建作业时使用。
* 返回的数据包含 examId、title、subjectId、structure 和题目列表。
*/
export type ExamWithQuestionsForHomework = {
id: string
title: string
subjectId: string | null
structure: unknown
questions: Array<{ questionId: string; score: number | null; order: number | null }>
}
export const getExamWithQuestionsForHomework = async (
examId: string
): Promise<ExamWithQuestionsForHomework | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
subjectId: exam.subjectId,
structure: exam.structure,
questions: exam.questions.map((q) => ({
questionId: q.questionId,
score: q.score ?? null,
order: q.order ?? null,
})),
}
}
/**
* 获取多个考试的 subjectId 映射examId -> subjectId
* 供 homework 模块查询作业对应科目时使用。
*/
export const getExamSubjectIdMap = async (examIds: string[]): Promise<Map<string, string | null>> => {
if (examIds.length === 0) return new Map()
const rows = await db
.select({ id: exams.id, subjectId: exams.subjectId })
.from(exams)
.where(inArray(exams.id, examIds))
const map = new Map<string, string | null>()
for (const r of rows) map.set(r.id, r.subjectId)
return map
}
/**
* 获取考试标题。
* 供 proctoring 等模块跨模块调用使用。
*/
export const getExamTitleById = async (examId: string): Promise<string | null> => {
const [row] = await db
.select({ title: exams.title })
.from(exams)
.where(eq(exams.id, examId))
.limit(1)
return row?.title ?? null
}
/**
* 获取考试的基本信息(含监考模式相关字段),供 proctoring 模块使用。
*/
export type ExamForProctoring = {
id: string
title: string
examMode: string | null
durationMinutes: number | null
shuffleQuestions: boolean | null
allowLateStart: boolean | null
lateStartGraceMinutes: number | null
antiCheatEnabled: boolean | null
}
export const getExamForProctoringCrossModule = async (examId: string): Promise<ExamForProctoring | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
})
if (!exam) return null
return {
id: exam.id,
title: exam.title,
examMode: exam.examMode,
durationMinutes: exam.durationMinutes ?? null,
shuffleQuestions: exam.shuffleQuestions ?? false,
allowLateStart: exam.allowLateStart ?? false,
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
antiCheatEnabled: exam.antiCheatEnabled ?? false,
}
}
/**
* 校验提交记录归属(监考事件上报前的安全校验)。
* 供 proctoring 模块跨模块调用使用。
*/
export const getExamSubmissionForProctoringCrossModule = async (
submissionId: string,
studentId: string
): Promise<{ id: string; examId: string; studentId: string } | null> => {
const submission = await db.query.examSubmissions.findFirst({
where: and(
eq(examSubmissions.id, submissionId),
eq(examSubmissions.studentId, studentId),
),
columns: {
id: true,
examId: true,
studentId: true,
},
})
return submission ?? null
}
/**
* 获取考试提交记录及其答题数据,供 diagnostic 模块更新知识点掌握度使用。
*/
export type ExamSubmissionWithAnswers = {
studentId: string
answers: Array<{ questionId: string; score: number | null }>
}
export const getExamSubmissionWithAnswers = async (
submissionId: string
): Promise<ExamSubmissionWithAnswers | null> => {
const [submission] = await db
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
if (!submission) return null
const answers = await db
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
return {
studentId: submission.studentId,
answers,
}
}
/**
* 获取一场考试的所有提交记录(含学生 ID 和状态),供 proctoring 模块使用。
*/
export type ExamSubmissionForProctoringSummary = {
id: string
studentId: string
status: string | null
}
export const getExamSubmissionsForExam = async (
examId: string
): Promise<ExamSubmissionForProctoringSummary[]> => {
const rows = await db
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
status: examSubmissions.status,
})
.from(examSubmissions)
.where(eq(examSubmissions.examId, examId))
return rows
}
/**
* 批量插入考试题目(跨模块写接口)。
* 供 lesson-preparation 模块发布课案为作业时使用,避免直接操作 examQuestions 表。
*/
export const addExamQuestions = async (
examId: string,
items: Array<{ questionId: string; score: number; order: number }>
): Promise<void> => {
if (items.length === 0) return
await db.insert(examQuestions).values(
items.map((it) => ({
examId,
questionId: it.questionId,
score: it.score,
order: it.order,
}))
)
}
/**
* 年级仪表盘 - 维度3获取年级下所有考试 + 提交统计。
* exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。
*/
export const getExamsByGradeId = cache(
async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)]
// scope 过滤
if (params.scope.type === "owned") {
conditions.push(eq(exams.creatorId, params.scope.userId))
}
if (params.scope.type === "grade_managed") {
if (params.scope.gradeIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
// grade_managed 且当前 gradeId 在管辖范围内才可见
if (!params.scope.gradeIds.includes(params.gradeId)) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
}
}
if (params.scope.type === "class_taught") {
// 教师仅能看到所教班级对应年级的考试
if (params.scope.classIds.length === 0) {
conditions.push(eq(exams.id, "__none__"))
} else {
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (!gradeIds.includes(params.gradeId)) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
}
}
const examRows = await db
.select({
id: exams.id,
title: exams.title,
status: exams.status,
subjectId: exams.subjectId,
startTime: exams.startTime,
createdAt: exams.createdAt,
})
.from(exams)
.where(and(...conditions))
.orderBy(desc(exams.createdAt))
if (examRows.length === 0) {
return {
gradeId: params.gradeId,
exams: [],
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
}
}
const examIds = examRows.map((e) => e.id)
// 并行查询:科目名称 + 提交统计
const [subjectOptions, submissionRows] = await Promise.all([
getSubjectOptions(),
db
.select({
examId: examSubmissions.examId,
status: examSubmissions.status,
score: examSubmissions.score,
})
.from(examSubmissions)
.where(inArray(examSubmissions.examId, examIds)),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
// 按考试分组统计提交
const statsByExam = new Map<string, { total: number; graded: number; scoreSum: number }>()
for (const s of submissionRows) {
const entry = statsByExam.get(s.examId) ?? { total: 0, graded: 0, scoreSum: 0 }
entry.total += 1
if (s.status === "graded" && s.score !== null) {
entry.graded += 1
entry.scoreSum += Number(s.score)
}
statsByExam.set(s.examId, entry)
}
const items: GradeExamItem[] = examRows.map((e) => {
const stats = statsByExam.get(e.id) ?? { total: 0, graded: 0, scoreSum: 0 }
return {
id: e.id,
title: e.title,
status: toExamStatus(e.status),
subjectId: e.subjectId,
subjectName: e.subjectId ? (subjectNameById.get(e.subjectId) ?? null) : null,
scheduledAt: e.startTime ? e.startTime.toISOString() : null,
createdAt: e.createdAt.toISOString(),
submissionCount: stats.total,
gradedCount: stats.graded,
averageScore: stats.graded > 0 ? Math.round((stats.scoreSum / stats.graded) * 100) / 100 : null,
}
})
const totals = {
examCount: items.length,
publishedCount: items.filter((i) => i.status === "published").length,
draftCount: items.filter((i) => i.status === "draft").length,
archivedCount: items.filter((i) => i.status === "archived").length,
totalSubmissions: items.reduce((sum, i) => sum + i.submissionCount, 0),
totalGraded: items.reduce((sum, i) => sum + i.gradedCount, 0),
}
return { gradeId: params.gradeId, exams: items, totals }
}
)
/**
* 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。
*/
export const getExamsForGradeEntry = cache(
async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
const conditions: SQL[] = []
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
} else {
conditions.push(eq(exams.id, "__none__"))
}
} else if (scope.type === "class_taught") {
conditions.push(eq(exams.id, "__none__"))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
} else if (scope.type === "grade_managed") {
conditions.push(eq(exams.id, "__none__"))
}
const examRows = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)],
with: { subject: true, gradeEntity: true },
})
if (examRows.length === 0) return []
const examIds = examRows.map((e) => e.id)
const questionCountRows = await db
.select({ examId: examQuestions.examId, count: count() })
.from(examQuestions)
.where(inArray(examQuestions.examId, examIds))
.groupBy(examQuestions.examId)
const questionCountMap = new Map(
questionCountRows.map((r) => [r.examId, Number(r.count)])
)
return examRows
.filter((e) => (questionCountMap.get(e.id) ?? 0) > 0)
.map((e) => {
const meta = parseExamMeta(e.description ?? null)
return {
id: e.id,
title: e.title,
subjectName: e.subject?.name ?? getString(meta, "subject") ?? "General",
gradeName: e.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
questionCount: questionCountMap.get(e.id) ?? 0,
totalScore: getNumber(meta, "totalScore") ?? 100,
}
})
}
)
/**
* 获取单个试卷详情(含题目列表),用于成绩录入表格表头。
*/
export const getExamForGradeEntry = cache(
async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: { subject: true, gradeEntity: true },
})
if (!exam) return null
if (scope && scope.type !== "all") {
if (scope.type === "owned" && exam.creatorId !== scope.userId) return null
if (scope.type === "grade_managed") {
if (scope.gradeIds.length === 0) return null
if (!scope.gradeIds.includes(exam.gradeId ?? "")) return null
}
if (scope.type === "class_taught") {
if (scope.classIds.length === 0) return null
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length === 0) return null
if (!gradeIds.includes(exam.gradeId ?? "")) return null
}
}
const questionRows = await db
.select({
questionId: examQuestions.questionId,
score: examQuestions.score,
order: examQuestions.order,
type: questions.type,
})
.from(examQuestions)
.innerJoin(questions, eq(examQuestions.questionId, questions.id))
.where(eq(examQuestions.examId, examId))
.orderBy(asc(examQuestions.order))
if (questionRows.length === 0) return null
const meta = parseExamMeta(exam.description ?? null)
const computedTotal = questionRows.reduce((sum, q) => sum + (q.score ?? 0), 0)
return {
id: exam.id,
title: exam.title,
subjectId: exam.subjectId,
gradeId: exam.gradeId,
totalScore: getNumber(meta, "totalScore") ?? computedTotal,
questions: questionRows.map((q) => ({
id: q.questionId,
order: q.order ?? 0,
score: q.score ?? 0,
type: q.type,
})),
}
}
)
export type {
ExamsDashboardStats,
ExamWithQuestionsForHomework,
ExamForProctoring,
ExamSubmissionWithAnswers,
ExamSubmissionForProctoringSummary,
} from "./data-access-cross-module"

View File

@@ -7,10 +7,21 @@ import type {
RichQuestionContent,
RichQuestionType,
} from "./exam-rich-editor-types"
import { toRichQuestionType } from "./exam-rich-editor-types"
const extractText = (node: JSONContent | undefined): string => {
if (!node) return ""
if (node.type === "text") return node.text ?? ""
// 硬换行节点(Shift+Enter):插入换行符
if (node.type === "hardBreak") return "\n"
// 段落节点:提取内部文本并在末尾补换行符,保留多段落排版
// (调用方通常 .trim(),不会受末尾多余 \n 影响)
if (node.type === "paragraph") {
const inner = Array.isArray(node.content)
? node.content.map(extractText).join("")
: ""
return inner + "\n"
}
if (Array.isArray(node.content)) return node.content.map(extractText).join("")
return ""
}
@@ -86,9 +97,7 @@ const buildQuestion = (qb: JSONContent): EditorQuestion => {
typeof attrs.questionId === "string" && attrs.questionId
? attrs.questionId
: createId()
const type = (typeof attrs.type === "string"
? attrs.type
: "single_choice") as RichQuestionType
const type = toRichQuestionType(attrs.type, "single_choice")
const score = typeof attrs.score === "number" ? attrs.score : 0
const inner = qb.content ?? []

View File

@@ -0,0 +1,113 @@
import { createId } from "@paralleldrive/cuid2"
import type { ExamNode } from "../components/assembly/selected-question-list"
import type { Question } from "@/modules/questions/types"
import type {
EditorDoc,
EditorQuestion,
EditorStructureNode,
} from "./exam-rich-editor-types"
import { toRichQuestionType } from "./exam-rich-editor-types"
/**
* 将 BuildExam 页面的 ExamNode[] + Question[] 转换为 EditorDoc,
* 以便通过 structureToEditorDoc 回填到富文本编辑器。
*
* 与 editorDocToStructure 互为逆操作。
*/
export const examNodesToEditorDoc = (
nodes: ExamNode[],
questions: Question[],
title: string
): EditorDoc => {
const questionById = new Map<string, Question>()
for (const q of questions) questionById.set(q.id, q)
const editorQuestions: EditorQuestion[] = []
const seenQuestionIds = new Set<string>()
const buildQuestionFromNode = (node: ExamNode): EditorQuestion | null => {
if (node.type !== "question" || !node.questionId) return null
const q = questionById.get(node.questionId)
if (!q) return null
if (seenQuestionIds.has(q.id)) return null
seenQuestionIds.add(q.id)
const content = parseQuestionContent(q.content)
return {
id: q.id,
type: toRichQuestionType(q.type, "single_choice"),
score: node.score ?? 0,
content,
}
}
const convertNode = (node: ExamNode): EditorStructureNode | null => {
if (node.type === "section") {
const children: EditorStructureNode[] = []
for (const child of node.children ?? []) {
const converted = convertNode(child)
if (converted) children.push(converted)
}
return {
id: node.id || createId(),
type: "section",
title: node.title,
level: node.level,
children,
}
}
if (node.type === "group") {
const children: EditorStructureNode[] = []
for (const child of node.children ?? []) {
const converted = convertNode(child)
if (converted) children.push(converted)
}
return {
id: node.id || createId(),
type: "group",
title: node.title,
instruction: node.instruction,
children,
}
}
if (node.type === "question") {
const eq = buildQuestionFromNode(node)
if (!eq) return null
editorQuestions.push(eq)
return {
id: node.id || createId(),
type: "question",
questionId: eq.id,
score: eq.score,
}
}
return null
}
const structure: EditorStructureNode[] = []
for (const node of nodes) {
const converted = convertNode(node)
if (converted) structure.push(converted)
}
return { title, questions: editorQuestions, structure }
}
/** 解析 Question.content(可能是 JSON 字符串或对象)为 RichQuestionContent */
const parseQuestionContent = (raw: unknown): EditorQuestion["content"] => {
if (raw && typeof raw === "object") {
return raw as EditorQuestion["content"]
}
if (typeof raw === "string") {
try {
const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === "object") {
return parsed as EditorQuestion["content"]
}
return { text: raw }
} catch {
return { text: raw }
}
}
return { text: "" }
}

View File

@@ -7,6 +7,61 @@ export type RichQuestionType =
| "text"
| "composite"
/**
* 可直接持久化为独立 question 的题型(不含 composite
* composite 必须通过 subQuestions 拆分创建,无法直接落库为单条 question 记录。
*/
export type StandaloneQuestionType = Exclude<RichQuestionType, "composite">
const RICH_QUESTION_TYPE_SET: ReadonlySet<string> = new Set<string>([
"single_choice",
"multiple_choice",
"judgment",
"text",
"composite",
])
const STANDALONE_QUESTION_TYPE_SET: ReadonlySet<string> = new Set<string>([
"single_choice",
"multiple_choice",
"judgment",
"text",
])
/**
* 运行时类型守卫:验证 unknown 值是否为合法的 RichQuestionType。
* 用于消除 Tiptap attrs / AI 返回值 / DB 字段等边界处的 `as RichQuestionType` 断言。
*/
export function isRichQuestionType(v: unknown): v is RichQuestionType {
return typeof v === "string" && RICH_QUESTION_TYPE_SET.has(v)
}
/**
* 运行时类型守卫:验证 unknown 值是否为可直接持久化的题型(不含 composite
* 用于 actions/data-access 在写入 questions 表前收窄类型。
*/
export function isStandaloneQuestionType(v: unknown): v is StandaloneQuestionType {
return typeof v === "string" && STANDALONE_QUESTION_TYPE_SET.has(v)
}
/**
* 安全转换:将 unknown 收窄为 RichQuestionType非法值回退到 fallback默认 "single_choice")。
*/
export function toRichQuestionType(v: unknown, fallback: RichQuestionType = "single_choice"): RichQuestionType {
return isRichQuestionType(v) ? v : fallback
}
/**
* 安全转换:将 RichQuestionType 收窄为 StandaloneQuestionType。
* composite 会回退到 fallback默认 "text"),调用方应优先过滤 composite。
*/
export function toStandaloneQuestionType(
v: RichQuestionType,
fallback: StandaloneQuestionType = "text"
): StandaloneQuestionType {
return v === "composite" ? fallback : v
}
export interface RichQuestionContent {
text: string
options?: Array<{ id: string; text: string; isCorrect?: boolean }>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, forwardRe
import { useEditor, EditorContent, type Editor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Placeholder from "@tiptap/extension-placeholder"
import { useTranslations } from "next-intl"
import {
Bold,
Italic,
@@ -109,13 +110,15 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
function ExamRichEditor(
{
initialContent,
placeholder = "在此粘贴或输入试卷内容,选中文本可标记为题目/分组/加点字/填空...",
placeholder,
onChange,
readOnly = false,
className,
},
ref
) {
const t = useTranslations("examHomework")
const resolvedPlaceholder = placeholder ?? t("exam.editor.contentPlaceholder")
const containerRef = useRef<HTMLDivElement>(null)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
@@ -126,7 +129,7 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({
placeholder,
placeholder: resolvedPlaceholder,
emptyEditorClass:
"is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0",
}),
@@ -137,7 +140,7 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
GroupBlock,
SectionBlock,
],
[placeholder]
[resolvedPlaceholder]
)
const editor = useEditor({
@@ -252,7 +255,7 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
if (!editor) {
return (
<div className={cn("flex items-center justify-center p-8 text-muted-foreground", className)}>
...
{t("exam.editor.loading")}
</div>
)
}
@@ -271,25 +274,25 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
<ToolbarButton
onClick={handleBold}
icon={Bold}
title="加粗"
title={t("exam.richEditor.bold")}
active={editor.isActive("bold")}
/>
<ToolbarButton
onClick={handleItalic}
icon={Italic}
title="斜体"
title={t("exam.richEditor.italic")}
active={editor.isActive("italic")}
/>
<ToolbarButton
onClick={handleStrike}
icon={Strikethrough}
title="删除线"
title={t("exam.richEditor.strike")}
active={editor.isActive("strike")}
/>
<ToolbarButton
onClick={handleDotted}
icon={Underline}
title="加点字(下加点)"
title={t("exam.richEditor.dotted")}
active={editor.isActive("dotted")}
/>
@@ -298,25 +301,25 @@ export const ExamRichEditor = forwardRef<ExamRichEditorHandle, ExamRichEditorPro
<ToolbarButton
onClick={handleBulletList}
icon={List}
title="无序列表"
title={t("exam.richEditor.bulletList")}
active={editor.isActive("bulletList")}
/>
<ToolbarButton
onClick={handleOrderedList}
icon={ListOrdered}
title="有序列表(选项)"
title={t("exam.richEditor.orderedList")}
active={editor.isActive("orderedList")}
/>
<ToolbarButton
onClick={handleBlockquote}
icon={Quote}
title="引用"
title={t("exam.richEditor.quote")}
active={editor.isActive("blockquote")}
/>
<div className="ml-auto flex items-center gap-1">
<ToolbarButton onClick={handleUndo} icon={Undo} title="撤销" />
<ToolbarButton onClick={handleRedo} icon={Redo} title="重做" />
<ToolbarButton onClick={handleUndo} icon={Undo} title={t("exam.richEditor.undo")} />
<ToolbarButton onClick={handleRedo} icon={Redo} title={t("exam.richEditor.redo")} />
</div>
</div>

View File

@@ -1,5 +1,8 @@
"use client"
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"
import { useTranslations } from "next-intl"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -10,22 +13,25 @@ declare module "@tiptap/core" {
}
}
const BlankView = ({ selected }: NodeViewProps) => (
<NodeViewWrapper as="span" className="inline-block align-baseline">
<span
data-blank="true"
className="mx-1 inline-block border-b border-current align-baseline"
style={{
minWidth: "80px",
height: "1.2em",
display: "inline-block",
boxShadow: selected ? "0 0 0 2px rgb(var(--primary) / 0.4))" : undefined,
}}
contentEditable={false}
aria-label="填空"
/>
</NodeViewWrapper>
)
const BlankView = ({ selected }: NodeViewProps) => {
const t = useTranslations("examHomework")
return (
<NodeViewWrapper as="span" className="inline-block align-baseline">
<span
data-blank="true"
className="mx-1 inline-block border-b border-current align-baseline"
style={{
minWidth: "80px",
height: "1.2em",
display: "inline-block",
boxShadow: selected ? "0 0 0 2px rgb(var(--primary) / 0.4))" : undefined,
}}
contentEditable={false}
aria-label={t("exam.editorExtensions.blank.ariaLabel")}
/>
</NodeViewWrapper>
)
}
/**
* 填空占位节点 —— 原子节点,渲染为下划线空。

View File

@@ -1,5 +1,9 @@
"use client"
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
import { useTranslations } from "next-intl"
import { countQuestionsAndScore } from "../utils/count-questions"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -12,34 +16,8 @@ declare module "@tiptap/core" {
}
}
/** 递归统计节点内的题目数和总分(含嵌套 questionBlock 的子题) */
const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const block of blocks) {
if (block.type === "questionBlock") {
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const GroupView = ({ node, updateAttributes }: NodeViewProps) => {
const t = useTranslations("examHomework")
const title = (node.attrs.title as string) || ""
const instruction = (node.attrs.instruction as string) || ""
const stats = countQuestionsAndScore(node.toJSON())
@@ -51,18 +29,18 @@ const GroupView = ({ node, updateAttributes }: NodeViewProps) => {
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder="大题标题(如:一、选择题)"
placeholder={t("exam.editorExtensions.group.titlePlaceholder")}
className="flex-1 bg-transparent text-base font-semibold focus:outline-none"
/>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
{t("exam.editorExtensions.group.statsSummary", { count: stats.count, score: stats.score })}
</span>
</div>
<input
type="text"
value={instruction}
onChange={(e) => updateAttributes({ instruction: e.target.value })}
placeholder="说明(如:每小题3分,共24分)—— 可留空,总分自动统计"
placeholder={t("exam.editorExtensions.group.instructionPlaceholder")}
className="mb-2 w-full bg-transparent text-xs text-muted-foreground focus:outline-none"
/>
<NodeViewContent className="mt-1 block" />

View File

@@ -1,5 +1,8 @@
"use client"
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
import { useTranslations } from "next-intl"
export type QuestionBlockType =
| "single_choice"
@@ -26,6 +29,7 @@ declare module "@tiptap/core" {
}
const QuestionView = ({ node, updateAttributes }: NodeViewProps) => {
const t = useTranslations("examHomework")
const { type, score } = node.attrs as QuestionBlockAttrs
return (
<NodeViewWrapper className="my-3 rounded-md border bg-card p-3">
@@ -35,11 +39,11 @@ const QuestionView = ({ node, updateAttributes }: NodeViewProps) => {
onChange={(e) => updateAttributes({ type: e.target.value })}
className="rounded border bg-background px-2 py-1 text-xs"
>
<option value="single_choice"></option>
<option value="multiple_choice"></option>
<option value="judgment"></option>
<option value="text">/</option>
<option value="composite"></option>
<option value="single_choice">{t("exam.editorExtensions.question.typeSingleChoice")}</option>
<option value="multiple_choice">{t("exam.editorExtensions.question.typeMultipleChoice")}</option>
<option value="judgment">{t("exam.editorExtensions.question.typeJudgment")}</option>
<option value="text">{t("exam.editorExtensions.question.typeText")}</option>
<option value="composite">{t("exam.editorExtensions.question.typeComposite")}</option>
</select>
<input
type="number"
@@ -48,7 +52,7 @@ const QuestionView = ({ node, updateAttributes }: NodeViewProps) => {
onChange={(e) => updateAttributes({ score: Number(e.target.value) || 0 })}
className="w-16 rounded border bg-background px-2 py-1 text-xs"
/>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("exam.editorExtensions.question.scoreUnit")}</span>
</div>
<NodeViewContent className="block text-sm" />
</NodeViewWrapper>

View File

@@ -1,5 +1,9 @@
"use client"
import { Node, mergeAttributes } from "@tiptap/core"
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from "@tiptap/react"
import { useTranslations } from "next-intl"
import { countQuestionsAndScore } from "../utils/count-questions"
declare module "@tiptap/core" {
interface Commands<ReturnType> {
@@ -12,35 +16,8 @@ declare module "@tiptap/core" {
}
}
/** 递归统计节点内的题目数和总分(含嵌套 section/group/question) */
const countQuestionsAndScore = (node: { content?: Array<{ type?: string; attrs?: Record<string, unknown>; content?: unknown[] }> }): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const block of blocks) {
if (block.type === "questionBlock") {
// 复合题:统计嵌套子题;否则计为 1 题
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some((n) => typeof n === "object" && n !== null && "type" in n && (n as { type?: string }).type === "questionBlock")
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block as never)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}
const SectionView = ({ node, updateAttributes }: NodeViewProps) => {
const t = useTranslations("examHomework")
const title = (node.attrs.title as string) || ""
const level = typeof node.attrs.level === "number" ? node.attrs.level : 1
const stats = countQuestionsAndScore(node.toJSON())
@@ -54,21 +31,21 @@ const SectionView = ({ node, updateAttributes }: NodeViewProps) => {
type="text"
value={title}
onChange={(e) => updateAttributes({ title: e.target.value })}
placeholder={`分卷标题(如:第Ⅰ卷 选择题)`}
placeholder={t("exam.editorExtensions.section.titlePlaceholder")}
className={`flex-1 bg-transparent font-bold focus:outline-none ${titleSize}`}
/>
<select
value={String(level)}
onChange={(e) => updateAttributes({ level: Number(e.target.value) })}
className="rounded border bg-background px-1.5 py-0.5 text-xs"
title="层级"
title={t("exam.editorExtensions.section.levelTitle")}
>
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="1">{t("exam.editorExtensions.section.levelVolume")}</option>
<option value="2">{t("exam.editorExtensions.section.levelPart")}</option>
<option value="3">{t("exam.editorExtensions.section.levelSubVolume")}</option>
</select>
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs text-primary">
{stats.count} · {stats.score}
{t("exam.editorExtensions.section.statsSummary", { count: stats.count, score: stats.score })}
</span>
</div>
<NodeViewContent className="mt-2 block" />

View File

@@ -13,6 +13,7 @@ export {
editorDocToStructure,
} from "./editor-to-structure"
export { structureToEditorDoc } from "./structure-to-editor"
export { examNodesToEditorDoc } from "./exam-nodes-to-editor-doc"
export type {
RichQuestionType,
RichQuestionContent,

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"
import type { Editor, JSONContent } from "@tiptap/react"
import { useTranslations } from "next-intl"
import {
Box,
Brackets,
@@ -92,6 +93,7 @@ export function SelectionToolbar({
activeQuestionType,
className,
}: SelectionToolbarProps) {
const t = useTranslations("examHomework")
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
const [hasSelection, setHasSelection] = useState(false)
@@ -268,22 +270,24 @@ export function SelectionToolbar({
}
const insertGroup = () => {
const defaultTitle = t("exam.selectionToolbar.defaultGroupTitle")
wrapOrInsert(
() => editor.commands.wrapInGroup("一、选择题", ""),
() => editor.chain().focus().insertGroup("一、选择题", "").run(),
() => editor.commands.wrapInGroup(defaultTitle, ""),
() => editor.chain().focus().insertGroup(defaultTitle, "").run(),
"groupBlock",
{ instruction: "" },
"一、选择题"
defaultTitle
)
}
const insertSection = () => {
const defaultTitle = t("exam.selectionToolbar.defaultSectionTitle")
wrapOrInsert(
() => editor.commands.wrapInSection("第Ⅰ卷 选择题", 1),
() => editor.chain().focus().insertSection("第Ⅰ卷 选择题", 1).run(),
() => editor.commands.wrapInSection(defaultTitle, 1),
() => editor.chain().focus().insertSection(defaultTitle, 1).run(),
"sectionBlock",
{ level: 1 },
"第Ⅰ卷 选择题"
defaultTitle
)
}
@@ -322,7 +326,7 @@ export function SelectionToolbar({
return (
<div
role="toolbar"
aria-label="选区标记工具栏"
aria-label={t("exam.selectionToolbar.ariaLabel")}
className={cn(
"fixed z-50 flex items-center gap-0.5 rounded-md border bg-popover/95 p-1 shadow-md backdrop-blur",
className
@@ -333,30 +337,30 @@ export function SelectionToolbar({
transform: "translateX(-50%)",
}}
>
<ToolbarButton onClick={insertSection} icon={Layers} label="分卷" />
<ToolbarButton onClick={insertGroup} icon={Heading} label="大题" />
<ToolbarButton onClick={insertSection} icon={Layers} label={t("exam.selectionToolbar.sectionLabel")} />
<ToolbarButton onClick={insertGroup} icon={Heading} label={t("exam.selectionToolbar.groupLabel")} />
<ToolbarButton
onClick={() => insertQuestion("single_choice")}
icon={CircleSlash}
label="单选"
label={t("exam.selectionToolbar.singleChoice")}
active={activeQuestionType === "single_choice"}
/>
<ToolbarButton
onClick={() => insertQuestion("text")}
icon={FileText}
label="填空/简答"
label={t("exam.selectionToolbar.blankShortAnswer")}
active={activeQuestionType === "text"}
/>
<ToolbarButton
onClick={() => insertQuestion("composite")}
icon={Brackets}
label="复合"
label={t("exam.selectionToolbar.composite")}
active={activeQuestionType === "composite"}
/>
<div className="mx-1 h-5 w-px bg-border" />
<ToolbarButton onClick={toggleDotted} icon={Underline} label="加点字" />
<ToolbarButton onClick={insertBlank} icon={Box} label="填空" />
<ToolbarButton onClick={insertImage} icon={FileText} label="图片" />
<ToolbarButton onClick={toggleDotted} icon={Underline} label={t("exam.richEditor.dotted")} />
<ToolbarButton onClick={insertBlank} icon={Box} label={t("exam.richEditor.markBlank")} />
<ToolbarButton onClick={insertImage} icon={FileText} label={t("exam.selectionToolbar.image")} />
</div>
)
}

View File

@@ -1,5 +1,5 @@
import type { JSONContent } from "@tiptap/react"
import type { EditorDoc, EditorQuestion } from "./exam-rich-editor-types"
import type { EditorDoc, EditorQuestion, EditorStructureNode } from "./exam-rich-editor-types"
const textToParagraphs = (text: string): JSONContent[] => {
const lines = text.split("\n").filter((l) => l.trim().length > 0)
@@ -42,28 +42,45 @@ const questionToBlock = (q: EditorQuestion): JSONContent => {
/**
* 将试卷结构(EditorDoc)转换为 Tiptap 编辑器文档(JSONContent)。
* 用于回填已有试卷到编辑器。
* 用于回填已有试卷到编辑器。支持 section/group/question 三层嵌套。
*/
export const structureToEditorDoc = (doc: EditorDoc): JSONContent => {
const content: JSONContent[] = []
for (const node of doc.structure) {
const convertNode = (node: EditorStructureNode): JSONContent | null => {
if (node.type === "section") {
const children: JSONContent[] = []
for (const child of node.children ?? []) {
const block = convertNode(child)
if (block) children.push(block)
}
return {
type: "sectionBlock",
attrs: { title: node.title ?? "", level: node.level ?? 1 },
content: children,
}
}
if (node.type === "group") {
const children: JSONContent[] = []
for (const child of node.children ?? []) {
if (child.type === "question") {
const q = doc.questions.find((x) => x.id === child.questionId)
if (q) children.push(questionToBlock(q))
}
const block = convertNode(child)
if (block) children.push(block)
}
content.push({
return {
type: "groupBlock",
attrs: { title: node.title ?? "" },
attrs: { title: node.title ?? "", instruction: node.instruction ?? "" },
content: children,
})
} else if (node.type === "question") {
const q = doc.questions.find((x) => x.id === node.questionId)
if (q) content.push(questionToBlock(q))
}
}
if (node.type === "question") {
const q = doc.questions.find((x) => x.id === node.questionId)
if (q) return questionToBlock(q)
}
return null
}
const content: JSONContent[] = []
for (const node of doc.structure) {
const block = convertNode(node)
if (block) content.push(block)
}
return { type: "doc", content }
}

View File

@@ -0,0 +1,55 @@
/**
* 递归统计 Tiptap 节点内的题目数和总分
*
* 从 group-block.tsx 和 section-block.tsx 提取的共享纯函数,
* 消除代码重复并修复 `as never` 类型断言。
*/
/** 可编辑节点的最小结构 */
type EditableNode = {
type?: string
attrs?: Record<string, unknown>
content?: unknown[]
}
/** 类型守卫:判断未知值是否为可编辑节点 */
const isEditableNode = (v: unknown): v is EditableNode =>
typeof v === "object" && v !== null
/**
* 递归统计节点内的题目数和总分(含嵌套 section/group/question 的子题)
*
* @param node - 包含 content 数组的节点
* @returns 题目数量和总分
*/
export const countQuestionsAndScore = (
node: { content?: unknown[] }
): { count: number; score: number } => {
let count = 0
let score = 0
const blocks = node.content ?? []
for (const raw of blocks) {
if (!isEditableNode(raw)) continue
const block: EditableNode = raw
if (block.type === "questionBlock") {
const inner = Array.isArray(block.content) ? block.content : []
const hasSubQuestions = inner.some(
(n) => isEditableNode(n) && n.type === "questionBlock"
)
if (hasSubQuestions) {
const subStats = countQuestionsAndScore(block)
count += subStats.count
score += subStats.score
} else {
count += 1
const s = typeof block.attrs?.score === "number" ? block.attrs.score : 0
score += s
}
} else if (block.type === "sectionBlock" || block.type === "groupBlock") {
const subStats = countQuestionsAndScore(block)
count += subStats.count
score += subStats.score
}
}
return { count, score }
}

View File

@@ -0,0 +1,79 @@
"use client"
import type { UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import type { useTranslations } from "next-intl"
import type { usePreviewState } from "./use-exam-preview-state"
import type { ExamFormValues } from "../components/exam-form-types"
import { findPreviewQuestionNode, parseEditableContent } from "../components/exam-preview-utils"
import { regenerateAiQuestionAction } from "../actions"
type Translator = ReturnType<typeof useTranslations>
export function usePreviewRewrite(
form: UseFormReturn<ExamFormValues>,
t: Translator,
state: ReturnType<typeof usePreviewState>,
) {
const {
previewNodes, selectedQuestionId, rewriteInstruction,
setRewriteInstruction, setRewritingQuestion, updateSelectedQuestionFromAi,
} = state
const handleRewriteSelectedQuestion = async () => {
if (!selectedQuestionId) {
toast.error(t("exam.previewHook.selectQuestionFirst"))
return
}
const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId)
if (!selected?.question) {
toast.error(t("exam.previewHook.questionNotFound"))
return
}
const instruction = rewriteInstruction.trim()
if (!instruction) {
toast.error(t("exam.previewHook.enterRewriteInstruction"))
return
}
setRewritingQuestion(true)
try {
const content = parseEditableContent(selected.question.content)
const questionPayload = {
type: selected.question.type,
difficulty: selected.question.difficulty ?? 3,
score: selected.score ?? 0,
content: {
text: content.text,
options: content.options.map((opt) => ({
id: opt.id, text: opt.text, isCorrect: opt.isCorrect,
})),
subQuestions: content.subQuestions.map((item) => ({
id: item.id, text: item.text, answer: item.answer, score: item.score,
})),
},
}
const formData = new FormData()
formData.append("instruction", instruction)
formData.append("questionJson", JSON.stringify(questionPayload))
const providerId = form.getValues("aiProviderId")
const sourceText = form.getValues("aiSourceText")
if (providerId) formData.append("aiProviderId", providerId)
if (sourceText) formData.append("sourceText", sourceText)
const result = await regenerateAiQuestionAction(null, formData)
if (!result.success || !result.data) {
toast.error(result.message || t("exam.previewHook.aiRewriteFailed"))
return
}
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
setRewriteInstruction("")
toast.success(t("exam.previewHook.aiRewriteSuccess"))
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
toast.error(t("exam.previewHook.aiRewriteFailed"))
} finally {
setRewritingQuestion(false)
}
}
return { handleRewriteSelectedQuestion }
}

View File

@@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import type { AiPreviewData, AiRewriteQuestionData } from "../actions"
import type { ExamNode } from "../components/assembly/selected-question-list"
import type { ExamFormValues, PreviewSnapshotMeta } from "../components/exam-form-types"
import {
buildPreviewNodes,
flattenPreviewQuestions,
updatePreviewQuestionNodeInList,
} from "../components/exam-preview-utils"
export function usePreviewState() {
const [previewOpen, setPreviewOpen] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewNodes, setPreviewNodes] = useState<ExamNode[]>([])
const [previewTitle, setPreviewTitle] = useState("")
const [previewRawOutput, setPreviewRawOutput] = useState("")
const [previewSignature, setPreviewSignature] = useState("")
const [previewMeta, setPreviewMeta] = useState<PreviewSnapshotMeta | null>(null)
const [selectedQuestionId, setSelectedQuestionId] = useState<string>("")
const [rewriteInstruction, setRewriteInstruction] = useState("")
const [rewritingQuestion, setRewritingQuestion] = useState(false)
const updatePreviewQuestionNode = (questionId: string, updater: (node: ExamNode) => ExamNode) => {
setPreviewNodes((prev) => updatePreviewQuestionNodeInList(questionId, prev, updater))
}
const updateSelectedQuestionFromAi = (questionId: string, data: AiRewriteQuestionData) => {
updatePreviewQuestionNode(questionId, (node) => {
if (!node.question) return node
return {
...node,
score: data.score,
question: {
...node.question,
type: data.type,
difficulty: data.difficulty,
content: data.content,
updatedAt: new Date(),
},
}
})
}
const applyPreviewResult = (input: { data: AiPreviewData; signature: string; meta: PreviewSnapshotMeta }) => {
setPreviewTitle(input.data.title)
const nextNodes = buildPreviewNodes(input.data)
setPreviewNodes(nextNodes)
const firstQuestion = flattenPreviewQuestions(nextNodes)[0]
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
setPreviewRawOutput(input.data.rawOutput ?? "")
setPreviewSignature(input.signature)
setPreviewMeta(input.meta)
setRewriteInstruction("")
setPreviewOpen(true)
}
return {
previewOpen, setPreviewOpen, previewLoading, setPreviewLoading,
previewNodes, setPreviewNodes, previewTitle, setPreviewTitle,
previewRawOutput, setPreviewRawOutput, previewSignature, setPreviewSignature,
previewMeta, setPreviewMeta, selectedQuestionId, setSelectedQuestionId,
rewriteInstruction, setRewriteInstruction, rewritingQuestion, setRewritingQuestion,
updatePreviewQuestionNode, updateSelectedQuestionFromAi, applyPreviewResult,
}
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useEffect, useRef, useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import PQueue from "p-queue"
import { createId } from "@paralleldrive/cuid2"
import type { useTranslations } from "next-intl"
import type { usePreviewState } from "./use-exam-preview-state"
import { type ExamFormValues, type PreviewBackgroundTask, previewTaskStorageKey } from "../components/exam-form-types"
import {
buildPreviewRequestData,
executeBackgroundPreviewTask,
flattenPreviewQuestions,
parseStoredPreviewTasks,
persistPreviewTasksToStorage,
} from "../components/exam-preview-utils"
type Translator = ReturnType<typeof useTranslations>
export function usePreviewBackgroundTasks(
form: UseFormReturn<ExamFormValues>,
t: Translator,
state: ReturnType<typeof usePreviewState>,
) {
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
const previewQueueRef = useRef<PQueue | null>(null)
if (!previewQueueRef.current) previewQueueRef.current = new PQueue({ concurrency: 3 })
const previewQueue = previewQueueRef.current
useEffect(() => {
try {
const raw = window.localStorage.getItem(previewTaskStorageKey)
const restoredTasks = raw ? parseStoredPreviewTasks(raw, t) : null
if (!restoredTasks) return
setPreviewTasks(restoredTasks)
if (restoredTasks.length > 0) form.setValue("mode", "ai")
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks([])
}
}, [form])
useEffect(() => () => { previewQueue.clear() }, [previewQueue])
useEffect(() => { persistPreviewTasksToStorage(previewTasks) }, [previewTasks])
const handleBackgroundPreview = () => {
const values = form.getValues()
const requestData = buildPreviewRequestData(values)
if (!requestData) { toast.error(t("exam.previewHook.pasteSourceFirst")); return }
const taskId = createId()
const taskTitle = values.title?.trim() || t("exam.previewHook.untitledExam")
setPreviewTasks((prev) => {
const next = [{ id: taskId, createdAt: Date.now(), status: "queued" as const, title: taskTitle, signature: requestData.signature }, ...prev]
persistPreviewTasksToStorage(next)
return next
})
toast.success(t("exam.previewHook.queuedSuccess"))
void previewQueue.add(() => executeBackgroundPreviewTask(taskId, taskTitle, values, requestData, t, setPreviewTasks))
}
const handleOpenPreviewTask = (taskId: string) => {
const task = previewTasks.find((item) => item.id === taskId)
if (!task || task.status !== "success" || !task.result) return
const tv = task.result.formValues
const fields = ["title", "subject", "grade", "difficulty", "totalScore", "durationMin", "aiSourceText", "aiQuestionCount", "aiProviderId"] as const
fields.forEach((key) => { if (typeof tv[key] !== "undefined") form.setValue(key, tv[key]) })
state.setPreviewTitle(task.result.title)
state.setPreviewNodes(task.result.nodes)
state.setPreviewRawOutput(task.result.rawOutput)
state.setPreviewSignature(task.signature)
state.setPreviewMeta(task.result.meta)
const firstQuestion = flattenPreviewQuestions(task.result.nodes)[0]
state.setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
state.setRewriteInstruction("")
state.setPreviewOpen(true)
}
return { previewTasks, setPreviewTasks, handleBackgroundPreview, handleOpenPreviewTask }
}

View File

@@ -1,154 +1,42 @@
"use client"
import { useEffect, useRef, useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { toast } from "sonner"
import PQueue from "p-queue"
import { createId } from "@paralleldrive/cuid2"
import type { useTranslations } from "next-intl"
import type { UseFormReturn } from "react-hook-form"
import { previewAiExamAction, regenerateAiQuestionAction, type AiPreviewData, type AiRewriteQuestionData } from "../actions"
import type { ExamNode } from "../components/assembly/selected-question-list"
import {
type ExamFormValues,
type PreviewSnapshotMeta,
type PreviewBackgroundTask,
previewTaskStorageKey,
} from "../components/exam-form-types"
import { previewAiExamAction } from "../actions"
import type { ExamFormValues } from "../components/exam-form-types"
import {
buildPreviewNodes,
parseEditableContent,
flattenPreviewQuestions,
findPreviewQuestionNode,
updatePreviewQuestionNodeInList,
buildPreviewSignature,
buildPreviewPayload,
buildPreviewRequestData,
buildPreviewSignature,
findPreviewQuestionNode,
flattenPreviewQuestions,
parseEditableContent,
} from "../components/exam-preview-utils"
import { usePreviewState } from "./use-exam-preview-state"
import { usePreviewBackgroundTasks } from "./use-exam-preview-tasks"
import { usePreviewRewrite } from "./use-exam-preview-rewrite"
// Runtime validator for parsed preview background tasks.
// Avoids trusting JSON.parse output blindly.
const isPreviewBackgroundTask = (v: unknown): v is PreviewBackgroundTask => {
if (!v || typeof v !== "object") return false
const obj = v as Record<string, unknown>
return typeof obj.id === "string"
&& (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed")
&& typeof obj.createdAt === "number"
&& typeof obj.title === "string"
}
export type Translator = ReturnType<typeof useTranslations>
export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
const [previewOpen, setPreviewOpen] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewNodes, setPreviewNodes] = useState<ExamNode[]>([])
const [previewTitle, setPreviewTitle] = useState("")
const [previewRawOutput, setPreviewRawOutput] = useState("")
const [previewSignature, setPreviewSignature] = useState("")
const [previewMeta, setPreviewMeta] = useState<PreviewSnapshotMeta | null>(null)
const [previewTasks, setPreviewTasks] = useState<PreviewBackgroundTask[]>([])
const [selectedQuestionId, setSelectedQuestionId] = useState<string>("")
const [rewriteInstruction, setRewriteInstruction] = useState("")
const [rewritingQuestion, setRewritingQuestion] = useState(false)
const previewQueueRef = useRef<PQueue | null>(null)
if (!previewQueueRef.current) {
previewQueueRef.current = new PQueue({ concurrency: 3 })
}
const previewQueue = previewQueueRef.current
const persistPreviewTasks = (tasks: PreviewBackgroundTask[]) => {
try {
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
}
}
useEffect(() => {
try {
const raw = window.localStorage.getItem(previewTaskStorageKey)
if (!raw) return
let parsed: unknown = null
try {
parsed = JSON.parse(raw)
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
return
}
if (!Array.isArray(parsed)) return
const restoredTasks = parsed
.filter(isPreviewBackgroundTask)
.map((task) => {
if (task.status === "queued" || task.status === "running") {
return {
...task,
status: "failed" as const,
message: "页面刷新后任务已中断,请重新生成",
}
}
return task
})
setPreviewTasks(restoredTasks)
if (restoredTasks.length > 0) {
form.setValue("mode", "ai")
}
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks([])
}
}, [form])
useEffect(() => {
return () => {
previewQueue.clear()
}
}, [previewQueue])
useEffect(() => {
persistPreviewTasks(previewTasks)
}, [previewTasks])
const updatePreviewQuestionNode = (questionId: string, updater: (node: ExamNode) => ExamNode) => {
setPreviewNodes((prev) => updatePreviewQuestionNodeInList(questionId, prev, updater))
}
const updateSelectedQuestionFromAi = (questionId: string, data: AiRewriteQuestionData) => {
updatePreviewQuestionNode(questionId, (node) => {
if (!node.question) return node
return {
...node,
score: data.score,
question: {
...node.question,
type: data.type,
difficulty: data.difficulty,
content: data.content,
updatedAt: new Date(),
},
}
})
}
const applyPreviewResult = (input: { data: AiPreviewData; signature: string; meta: PreviewSnapshotMeta }) => {
setPreviewTitle(input.data.title)
const nextNodes = buildPreviewNodes(input.data)
setPreviewNodes(nextNodes)
const firstQuestion = flattenPreviewQuestions(nextNodes)[0]
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
setPreviewRawOutput(input.data.rawOutput ?? "")
setPreviewSignature(input.signature)
setPreviewMeta(input.meta)
setRewriteInstruction("")
setPreviewOpen(true)
}
export function useExamPreview(form: UseFormReturn<ExamFormValues>, t: Translator) {
const state = usePreviewState()
const tasks = usePreviewBackgroundTasks(form, t, state)
const rewrite = usePreviewRewrite(form, t, state)
const {
setPreviewOpen, setPreviewLoading, setPreviewNodes, setPreviewRawOutput,
setPreviewSignature, setSelectedQuestionId, setRewriteInstruction, applyPreviewResult,
} = state
const handlePreview = async () => {
const values = form.getValues()
const requestData = buildPreviewRequestData(values)
if (!requestData) {
toast.error("Please paste the full exam text first")
toast.error(t("exam.previewHook.pasteSourceFirst"))
return
}
setPreviewOpen(false)
setPreviewLoading(true)
setPreviewNodes([])
@@ -159,157 +47,22 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
try {
const result = await previewAiExamAction(null, requestData.formData)
if (result.success && result.data) {
applyPreviewResult({
data: result.data,
signature: requestData.signature,
meta: requestData.meta,
})
applyPreviewResult({ data: result.data, signature: requestData.signature, meta: requestData.meta })
} else {
toast.error(result.message || "Failed to generate preview")
toast.error(result.message || t("exam.previewHook.generatePreviewFailed"))
}
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
toast.error("Failed to generate preview")
toast.error(t("exam.previewHook.generatePreviewFailed"))
} finally {
setPreviewLoading(false)
}
}
const handleBackgroundPreview = () => {
const values = form.getValues()
const requestData = buildPreviewRequestData(values)
if (!requestData) {
toast.error("Please paste the full exam text first")
return
}
const taskId = createId()
const taskTitle = values.title?.trim() || "未命名试卷"
setPreviewTasks((prev) => {
const next = [{ id: taskId, createdAt: Date.now(), status: "queued" as const, title: taskTitle, signature: requestData.signature }, ...prev]
persistPreviewTasks(next)
return next
})
toast.success("已加入后台队列,可继续编辑页面")
void previewQueue.add(async () => {
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "running" }
: task))
try {
const result = await previewAiExamAction(null, requestData.formData)
const data = result.data
if (result.success && data) {
const nextNodes = buildPreviewNodes(data)
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? {
...task,
status: "success",
result: {
title: data.title,
nodes: nextNodes,
rawOutput: data.rawOutput ?? "",
meta: requestData.meta,
formValues: { title: values.title, subject: values.subject, grade: values.grade, difficulty: values.difficulty, totalScore: values.totalScore, durationMin: values.durationMin, aiSourceText: values.aiSourceText, aiQuestionCount: values.aiQuestionCount, aiProviderId: values.aiProviderId },
},
}
: task))
toast.success(`后台生成完成:${taskTitle}`)
return
}
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
: task))
toast.error(`后台生成失败:${taskTitle}`)
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
? { ...task, status: "failed", message: "Failed to generate preview" }
: task))
toast.error(`后台生成失败:${taskTitle}`)
}
})
}
const handleOpenPreviewTask = (taskId: string) => {
const task = previewTasks.find((item) => item.id === taskId)
if (!task || task.status !== "success" || !task.result) return
const tv = task.result.formValues
const fields = ["title", "subject", "grade", "difficulty", "totalScore", "durationMin", "aiSourceText", "aiQuestionCount", "aiProviderId"] as const
fields.forEach((key) => {
if (typeof tv[key] !== "undefined") form.setValue(key, tv[key])
})
setPreviewTitle(task.result.title)
setPreviewNodes(task.result.nodes)
setPreviewRawOutput(task.result.rawOutput)
setPreviewSignature(task.signature)
setPreviewMeta(task.result.meta)
const firstQuestion = flattenPreviewQuestions(task.result.nodes)[0]
setSelectedQuestionId(firstQuestion?.node.questionId ?? "")
setRewriteInstruction("")
setPreviewOpen(true)
}
const handleRewriteSelectedQuestion = async () => {
if (!selectedQuestionId) {
toast.error("请先选择一个题目")
return
}
const selected = findPreviewQuestionNode(previewNodes, selectedQuestionId)
if (!selected?.question) {
toast.error("未找到选中的题目")
return
}
const instruction = rewriteInstruction.trim()
if (!instruction) {
toast.error("请输入重写指令")
return
}
setRewritingQuestion(true)
try {
const content = parseEditableContent(selected.question.content)
const questionPayload = {
type: selected.question.type,
difficulty: selected.question.difficulty ?? 3,
score: selected.score ?? 0,
content: {
text: content.text,
options: content.options.map((opt) => ({
id: opt.id, text: opt.text, isCorrect: opt.isCorrect,
})),
subQuestions: content.subQuestions.map((item) => ({
id: item.id, text: item.text, answer: item.answer, score: item.score,
})),
},
}
const formData = new FormData()
formData.append("instruction", instruction)
formData.append("questionJson", JSON.stringify(questionPayload))
const providerId = form.getValues("aiProviderId")
const sourceText = form.getValues("aiSourceText")
if (providerId) formData.append("aiProviderId", providerId)
if (sourceText) formData.append("sourceText", sourceText)
const result = await regenerateAiQuestionAction(null, formData)
if (!result.success || !result.data) {
toast.error(result.message || "AI 重写失败")
return
}
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
setRewriteInstruction("")
toast.success("题目已按指令重写")
} catch (error) {
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
toast.error("AI 重写失败")
} finally {
setRewritingQuestion(false)
}
}
return {
previewOpen, setPreviewOpen, previewLoading, previewNodes, setPreviewNodes,
previewTitle, previewRawOutput, previewSignature, previewMeta, previewTasks,
selectedQuestionId, setSelectedQuestionId, rewriteInstruction, setRewriteInstruction,
rewritingQuestion, buildPreviewNodes, parseEditableContent, flattenPreviewQuestions,
findPreviewQuestionNode, updatePreviewQuestionNode, buildPreviewPayload,
buildPreviewRequestData, buildPreviewSignature, handlePreview, handleBackgroundPreview,
handleOpenPreviewTask, handleRewriteSelectedQuestion,
...state, ...tasks, ...rewrite,
buildPreviewNodes, parseEditableContent, flattenPreviewQuestions,
findPreviewQuestionNode, buildPreviewPayload, buildPreviewRequestData,
buildPreviewSignature, handlePreview,
}
}

View File

@@ -0,0 +1,72 @@
"use client"
/**
* ExamServiceProvider — ExamServicePort 的 React Context 注入点P2-4 骨架)
*
* 设计要点:
* - Context 默认值为 null组件通过 useExamService() 取值
* - 未注入时抛出明确错误,避免静默使用未定义服务
* - Context 不直接绑定具体实现,不同角色页面可注入不同 Provider 实现
*
* 当前阶段:
* - 仅落地 Context 与 Hook 骨架
* - 具体实现TeacherExamService/AdminExamService/MockExamService将在 P2-6+ 重构时补充
* - 现有组件暂不强制改造,保持向后兼容
*/
import { createContext, useContext, type ReactNode } from "react"
import type { ExamServicePort } from "./exam-service-port"
const ExamServiceContext = createContext<ExamServicePort | null>(null)
interface ExamServiceProviderProps {
/** 注入的服务实现 */
service: ExamServicePort
children: ReactNode
}
/**
* 注入 ExamService 实现的 Provider。
*
* @example
* ```tsx
* // 在角色专属 layout.tsx 中
* import { ExamServiceProvider } from "@/modules/exams/services/exam-service-context"
* import { TeacherExamService } from "@/modules/exams/services/teacher-exam-service"
*
* export default function TeacherExamsLayout({ children }) {
* return <ExamServiceProvider service={new TeacherExamService()}>{children}</ExamServiceProvider>
* }
* ```
*/
export function ExamServiceProvider({ service, children }: ExamServiceProviderProps): ReactNode {
return <ExamServiceContext.Provider value={service}>{children}</ExamServiceContext.Provider>
}
/**
* 获取注入的 ExamService 实例。
*
* 必须在 ExamServiceProvider 子树内调用,否则抛出明确错误。
* 这表明调用方未在合适的角色 layout 中被包裹,需检查路由结构。
*/
export function useExamService(): ExamServicePort {
const service = useContext(ExamServiceContext)
if (service === null) {
throw new Error(
"[ExamServicePort] No implementation injected. " +
"Wrap the consumer tree in <ExamServiceProvider service={...}>. " +
"If you see this in a Server Component, ExamServicePort only supports client-side injection (P2-4 scope)."
)
}
return service
}
/**
* 测试用:尝试获取服务,未注入时返回 null不抛错
* 仅在测试场景下使用,生产代码应使用 useExamService()。
*
* 注意:命名以 `use` 开头以满足 React Hooks 规则。
*/
export function useTryExamService(): ExamServicePort | null {
return useContext(ExamServiceContext)
}

View File

@@ -0,0 +1,125 @@
/**
* ExamServicePort — exams 模块对内的服务端口接口P2-4 骨架)
*
* 目的:
* - 通过 TypeScript 接口抽象 exams 模块内部组件对数据/动作的依赖
* - 使用 React Context 注入具体实现,便于单元测试时替换为 mock
* - 为未来不同角色teacher/admin/parent/student通过不同 Provider 实现隔离
*
* 与 `@/shared/services/exam-homework-port.ts` 的区别:
* - shared 层的 ExamHomeworkServicePort 是面向 **跨模块调用方**app 层 / 其他 modules
* 的契约,在 instrumentation.ts 启动时以单例方式注册
* - 本文件定义的 ExamServicePort 是面向 **模块内部组件** 的契约,
* 通过 React Context 注入,可在不同角色页面提供差异化实现
*
* 当前阶段P2-4
* - 仅落地接口与 Context 骨架
* - 不强制改造现有组件exam-rich-form.tsx 等仍直接调用 Server Actions
* - P2-6+ 重构时再将组件改为通过 useExamService() 获取服务
*
* 使用示例(未来落地后):
* ```tsx
* // app/(dashboard)/teacher/exams/layout.tsx
* import { ExamServiceProvider, TeacherExamService } from "@/modules/exams/services"
* export default function Layout({ children }) {
* return <ExamServiceProvider service={new TeacherExamService()}>{children}</ExamServiceProvider>
* }
*
* // components/exam-rich-form.tsx未来重构后
* const service = useExamService()
* const result = await service.createExamFromRichEditor(formData)
* ```
*/
import type { ActionState } from "@/shared/types/action-state"
import type { DataScope } from "@/shared/types/permissions"
import type { Exam } from "../types"
import type { ExamAnalyticsSummary } from "../stats-service"
import type { GetExamsParams } from "../data-access"
/**
* 试卷详情:扩展 Exam包含组卷结构与题目列表
*/
export interface ExamDetail extends Exam {
structure: unknown
questions: Array<{
id: string
score: number
order: number
}>
}
/**
* 创建试卷输入
*/
export interface ExamCreateInput {
title: string
subjectId: string
gradeId: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string | null
examModeConfig?: {
examMode: "homework" | "timed" | "proctored"
durationMinutes: number | null
shuffleQuestions: boolean
allowLateStart: boolean
lateStartGraceMinutes: number
antiCheatEnabled: boolean
}
}
/**
* 更新试卷输入
*/
export interface ExamUpdateInput extends Partial<ExamCreateInput> {
examId: string
}
/**
* exams 模块服务端口契约。
*
* 所有方法返回 Promise签名尽量与现有 Server Actions 保持一致,
* 但将 prevState / FormData 等表单协议参数剥离,便于客户端组合调用。
*
* 注意:
* - 此接口仅描述"做什么",不规定"如何鉴权"——鉴权应在具体实现中完成
* - 每个方法都应返回 ActionState便于调用方统一处理成功/失败
*/
export interface ExamServicePort {
/** 列出考试(带数据范围与筛选) */
listExams(params: GetExamsParams & { scope: DataScope }): Promise<Exam[]>
/** 获取考试详情(含结构/题目) */
getExam(id: string, scope?: DataScope): Promise<ExamDetail | null>
/** 创建考试草稿 */
createExam(input: ExamCreateInput, formData: FormData): Promise<ActionState<string>>
/** AI 生成考试 */
createAiExam(input: ExamCreateInput & { prompt: string }, formData: FormData): Promise<ActionState<string>>
/** 从富文本编辑器创建考试 */
createExamFromRichEditor(formData: FormData): Promise<ActionState<string>>
/** 更新考试 */
updateExam(input: ExamUpdateInput, formData: FormData): Promise<ActionState<string>>
/** 从富文本编辑器更新考试 */
updateExamFromRichEditor(formData: FormData): Promise<ActionState<string>>
/** 删除考试 */
deleteExam(id: string): Promise<ActionState<string>>
/** 复制考试 */
duplicateExam(id: string): Promise<ActionState<string>>
/** AI 自动标记 */
autoMarkExam(formData: FormData): Promise<ActionState<{ title: string; doc: unknown }>>
/** 获取考试预览 */
getExamPreview(examId: string): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>>
/** 获取考试分析数据 */
getAnalytics(examId: string): Promise<ExamAnalyticsSummary | null>
/** 获取可用科目列表 */
getSubjects(): Promise<ActionState<Array<{ id: string; name: string }>>>
/** 获取可用年级列表 */
getGrades(): Promise<ActionState<Array<{ id: string; name: string }>>>
}
/**
* 默认实现标记:未注入 Provider 时调用会抛错。
* 仅用于类型推断与测试场景的兜底,生产环境必须通过 ExamServiceProvider 注入。
*/
export const EXAM_SERVICE_NOT_REGISTERED = Symbol("EXAM_SERVICE_NOT_REGISTERED")

View File

@@ -0,0 +1,23 @@
/**
* exams 模块服务端口与 Context 注入入口P2-4
*
* 导出内容:
* - ExamServicePort 接口与相关类型
* - ExamServiceProvider / useExamService 客户端注入工具
*
* 注意:本目录仅声明契约与注入机制,不包含具体实现。
* 具体实现TeacherExamService/AdminExamService/MockExamService将在 P2-6+ 落地。
*/
export type {
ExamServicePort,
ExamDetail,
ExamCreateInput,
ExamUpdateInput,
} from "./exam-service-port"
export {
ExamServiceProvider,
useExamService,
useTryExamService,
} from "./exam-service-context"

View File

@@ -1,17 +1,17 @@
import "server-only"
import { cache } from "react"
import { getTranslations } from "next-intl/server"
import { db } from "@/shared/db"
import { exams, examQuestions } from "@/shared/db/schema"
import { isRecord } from "@/shared/lib/type-guards"
import { eq } from "drizzle-orm"
import {
getHomeworkAssignmentsByExamId,
getGradedSubmissionsByExamId,
} from "@/modules/homework/data-access"
import { getQuestionText } from "@/modules/homework/lib/question-content-utils"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
} from "@/modules/homework/data-access-exam-cross"
import { getQuestionText } from "@/modules/homework/data-access-utils"
const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
@@ -61,6 +61,7 @@ export interface ExamAnalyticsSummary {
* 计算:平均分、及格率、分数段分布、逐题错误率与难度。
*/
export const getExamAnalytics = cache(async (examId: string): Promise<ExamAnalyticsSummary | null> => {
const t = await getTranslations("examHomework")
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { id: true, title: true, description: true },
@@ -134,7 +135,7 @@ export const getExamAnalytics = cache(async (examId: string): Promise<ExamAnalyt
return {
questionId,
questionType: eq.question.type,
questionText: getQuestionText(eq.question.content) || "(无题目文本)",
questionText: getQuestionText(eq.question.content) || t("exam.actionMessages.noQuestionText"),
maxScore,
errorCount,
errorRate,

View File

@@ -6,6 +6,7 @@ export interface Exam {
id: string
title: string
subject: string
subjectId: string | null
grade: string
status: ExamStatus
difficulty: ExamDifficulty

View File

@@ -1,4 +1,5 @@
import { createId } from "@paralleldrive/cuid2"
import { isRecord } from "@/shared/lib/type-guards"
import type { ExamNode } from "../components/assembly/selected-question-list"
/**
@@ -14,25 +15,34 @@ import type { ExamNode } from "../components/assembly/selected-question-list"
*/
export function normalizeStructure(nodes: unknown): ExamNode[] {
const seen = new Set<string>()
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
const normalize = (raw: unknown[]): ExamNode[] => {
return raw
.map((n): ExamNode | null => {
if (!isRecord(n)) return null
const type = n.type
if (type !== "group" && type !== "question") return null
if (type !== "section" && type !== "group" && type !== "question") return null
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
while (seen.has(id)) id = createId()
seen.add(id)
if (type === "section") {
return {
id,
type: "section",
title: typeof n.title === "string" ? n.title : undefined,
level: typeof n.level === "number" ? n.level : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}
if (type === "group") {
return {
id,
type: "group",
title: typeof n.title === "string" ? n.title : undefined,
instruction: typeof n.instruction === "string" ? n.instruction : undefined,
children: normalize(Array.isArray(n.children) ? n.children : []),
} satisfies ExamNode
}