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:
104
src/modules/exams/actions-helpers.ts
Normal file
104
src/modules/exams/actions-helpers.ts
Normal 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 }
|
||||
}
|
||||
292
src/modules/exams/actions-rich-editor.ts
Normal file
292
src/modules/exams/actions-rich-editor.ts
Normal 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
396
src/modules/exams/ai-pipeline/auto-mark.ts
Normal file
396
src/modules/exams/ai-pipeline/auto-mark.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
189
src/modules/exams/components/exam-boundaries.tsx
Normal file
189
src/modules/exams/components/exam-boundaries.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -140,6 +140,7 @@ export const aiProviderLabels: Record<AiProviderSummary["provider"], string> = {
|
||||
openai: "OpenAI",
|
||||
gemini: "Gemini",
|
||||
custom: "Custom",
|
||||
ollama: "Ollama",
|
||||
}
|
||||
|
||||
export const defaultValues: Partial<ExamFormValues> = {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ExamForm() {
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
const preview = useExamPreview(form)
|
||||
const preview = useExamPreview(form, t)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetadata = async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
176
src/modules/exams/components/exam-preview.tsx
Normal file
176
src/modules/exams/components/exam-preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
230
src/modules/exams/config/exam-widgets.ts
Normal file
230
src/modules/exams/config/exam-widgets.ts
Normal 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)
|
||||
}
|
||||
514
src/modules/exams/data-access-cross-module.ts
Normal file
514
src/modules/exams/data-access-cross-module.ts
Normal 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",
|
||||
})),
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
|
||||
113
src/modules/exams/editor/exam-nodes-to-editor-doc.ts
Normal file
113
src/modules/exams/editor/exam-nodes-to-editor-doc.ts
Normal 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: "" }
|
||||
}
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 填空占位节点 —— 原子节点,渲染为下划线空。
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
55
src/modules/exams/editor/utils/count-questions.ts
Normal file
55
src/modules/exams/editor/utils/count-questions.ts
Normal 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 }
|
||||
}
|
||||
79
src/modules/exams/hooks/use-exam-preview-rewrite.ts
Normal file
79
src/modules/exams/hooks/use-exam-preview-rewrite.ts
Normal 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 }
|
||||
}
|
||||
67
src/modules/exams/hooks/use-exam-preview-state.ts
Normal file
67
src/modules/exams/hooks/use-exam-preview-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
80
src/modules/exams/hooks/use-exam-preview-tasks.ts
Normal file
80
src/modules/exams/hooks/use-exam-preview-tasks.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
72
src/modules/exams/services/exam-service-context.tsx
Normal file
72
src/modules/exams/services/exam-service-context.tsx
Normal 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)
|
||||
}
|
||||
125
src/modules/exams/services/exam-service-port.ts
Normal file
125
src/modules/exams/services/exam-service-port.ts
Normal 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")
|
||||
23
src/modules/exams/services/index.ts
Normal file
23
src/modules/exams/services/index.ts
Normal 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"
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Exam {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
subjectId: string | null
|
||||
grade: string
|
||||
status: ExamStatus
|
||||
difficulty: ExamDifficulty
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user