feat(exams): update question bank list, rich form, and selection toolbar
Some checks failed
CI / build-deploy (push) Waiting to run
CI / security-scan (push) Blocked by required conditions
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 1s

- Update question-bank-list component for exam assembly

- Update exam-rich-form for rich text exam editing

- Update selection-toolbar for editor selection actions
This commit is contained in:
SpecialX
2026-06-24 15:37:10 +08:00
parent 90f7d395f2
commit e27efb6282
3 changed files with 121 additions and 52 deletions

View File

@@ -12,9 +12,50 @@ import {
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Plus, Sparkles } from "lucide-react"
import type { Question } from "@/modules/questions/types"
import { createId } from "@paralleldrive/cuid2"
import type { Question, QuestionType } from "@/modules/questions/types"
import { QuestionTypeEnum } from "@/modules/questions/schema"
import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
import type { QuestionVariantResult } from "@/modules/ai/types"
/** 合法题型集合,用于运行时校验 AI 返回的 type 字符串 */
const QUESTION_TYPES = new Set<string>(QuestionTypeEnum.options)
/**
* 类型守卫:校验字符串是否为合法 QuestionType
*
* AI 返回的 type 是 string需收窄为 QuestionType 联合类型,
* 避免直接使用 as 断言。
*/
function isQuestionType(value: string): value is QuestionType {
return QUESTION_TYPES.has(value)
}
/**
* 将 AI 生成的题目变体转换为 Question 对象
*
* 变体在客户端生成临时 IDcuid2加入试卷结构后随试卷保存时持久化。
* content 结构与题库一致:{ text, options, answer, explanation }。
*/
function variantToQuestion(variant: QuestionVariantResult): Question {
const type: QuestionType = isQuestionType(variant.type) ? variant.type : "single_choice"
return {
id: createId(),
content: {
text: variant.text,
options: variant.options,
answer: variant.answer,
explanation: variant.explanation,
},
type,
difficulty: variant.difficulty,
createdAt: new Date(),
updatedAt: new Date(),
author: null,
knowledgePoints: [],
}
}
type QuestionBankListProps = {
questions: Question[]
@@ -46,7 +87,7 @@ function extractQuestionText(content: unknown): string {
}
}
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps) {
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps): React.ReactNode {
const aiClient = useAiClientOptional()
if (questions.length === 0 && !isLoading) {
@@ -104,6 +145,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
questionType={q.type}
difficulty={q.difficulty}
subject={subject}
onAdd={onAdd}
/>
) : null}
<Button
@@ -149,20 +191,28 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
* AI 题目变体生成对话框
*
* 独立组件,仅在用户点击时挂载,避免不必要的渲染。
* 采纳变体后自动关闭对话框。
*/
function AiVariantDialog({
questionText,
questionType,
difficulty,
subject,
onAdd,
}: {
questionText: string
questionType: string
difficulty: number
subject?: string
}) {
onAdd: (question: Question) => void
}): React.ReactNode {
const [open, setOpen] = useState(false)
const handleAddVariant = (variant: QuestionVariantResult): void => {
onAdd(variantToQuestion(variant))
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -189,6 +239,7 @@ function AiVariantDialog({
difficulty,
}}
subject={subject}
onAddVariant={handleAddVariant}
/>
</DialogContent>
</Dialog>

View File

@@ -180,24 +180,6 @@ export function ExamRichForm() {
? editorDocToStructure(editorDoc, values.title)
: null
// 调试:查看复合题的子题解析结果
if (previewStructure && typeof window !== "undefined") {
const composites = previewStructure.questions.filter((q) => q.type === "composite")
if (composites.length > 0) {
// eslint-disable-next-line no-console
console.log("[ExamPreview] composites:", composites.map((q) => ({
id: q.id,
textLength: q.content.text.length,
textPreview: q.content.text.slice(0, 80),
subQuestionCount: q.content.subQuestions?.length ?? 0,
subQuestions: q.content.subQuestions?.map((s) => ({
text: s.text.slice(0, 50),
score: s.score,
})),
})))
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* 顶部工具栏:基本信息(单行) + AI 自动标记 + 保存 */}

View File

@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import type { Editor } from "@tiptap/react"
import type { Editor, JSONContent } from "@tiptap/react"
import {
Box,
Brackets,
@@ -56,6 +56,33 @@ function ToolbarButton({ onClick, icon: Icon, label, active }: ToolbarButtonProp
)
}
/**
* 把 orderedList/bulletList 转为普通段落,去掉列表标号。
* 用于把选中文本转为题目块时,避免列表被错误解析为选项(填空/简答题场景)。
* 列表的每个 listItem 的子块(通常是 paragraph)会被提取为独立段落。
*/
const flattenListToParagraphs = (nodes: JSONContent[]): JSONContent[] => {
const result: JSONContent[] = []
for (const node of nodes) {
if (node.type === "orderedList" || node.type === "bulletList") {
if (Array.isArray(node.content)) {
for (const item of node.content) {
if (Array.isArray(item.content)) {
result.push(...item.content)
}
}
}
} else {
result.push(node)
}
}
// 确保非空(questionBlock 要求 content: "block+")
if (result.length === 0) {
result.push({ type: "paragraph", content: [{ type: "text", text: " " }] })
}
return result
}
/**
* 选区标记浮层 —— 选中文本时浮现,提供"标记为题目/分组/加点字/填空"等快捷操作。
* 跟随选区位置定位,使用 getBoundingClientRect 计算坐标。
@@ -159,39 +186,48 @@ export function SelectionToolbar({
const insertQuestion = (type: QuestionBlockType) => {
const score = type === "composite" ? 5 : 2
if (hasTextSelection) {
// 先尝试 wrapIn(普通情况下可用)
const wrapped = editor.commands.wrapInQuestion({ type, score })
if (!wrapped) {
// wrapIn 失败(通常在 isolating 节点如复合题块内):
// 获取选中文本,删除选区,插入包含选中文本的 questionBlock
// 这样不会清空内容,而是把选中文本转为新的子题
const { from, to } = editor.state.selection
const selectedText = editor.state.doc.textBetween(from, to, "\n")
const lines = selectedText
.split("\n")
.filter((l) => l.trim().length > 0)
const content = lines.length > 0
? lines.map((line) => ({
type: "paragraph",
content: [{ type: "text", text: line }],
}))
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: "questionBlock",
attrs: { type, score, questionId: "" },
content,
})
.run()
}
} else {
if (!hasTextSelection) {
// 未选中文本时:插入空题目块
editor.chain().focus().insertQuestion({ type, score }).run()
return
}
const isChoice =
type === "single_choice" ||
type === "multiple_choice" ||
type === "judgment"
// 选择题:先尝试 wrapIn(保留列表结构,以便解析为 A/B/C 选项)
if (isChoice && editor.commands.wrapInQuestion({ type, score })) {
return
}
// 非选择题,或 wrapIn 失败(如在 isolating 节点内):
// 用 slice 获取选区完整节点结构,把列表转为普通段落(去掉有序序列标号),
// 然后插入新的 questionBlock。避免列表导致填空/简答题在预览中不显示。
const { from, to } = editor.state.selection
const slice = editor.state.doc.slice(from, to)
const sliceContent = slice.content.toJSON
? (slice.content.toJSON() as JSONContent[])
: Array.isArray(slice.content.content)
? slice.content.content.map((n) => n.toJSON() as JSONContent)
: []
const content = isChoice
? sliceContent.length > 0
? sliceContent
: [{ type: "paragraph", content: [{ type: "text", text: " " }] }]
: flattenListToParagraphs(sliceContent)
editor.chain()
.focus()
.deleteSelection()
.insertContent({
type: "questionBlock",
attrs: { type, score, questionId: "" },
content,
})
.run()
}
/** 通用降级包裹:wrapIn 失败时,把选中文本转为指定节点 */