feat(exams): update question bank list, rich form, and selection toolbar
- 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:
@@ -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 对象
|
||||
*
|
||||
* 变体在客户端生成临时 ID(cuid2),加入试卷结构后随试卷保存时持久化。
|
||||
* 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>
|
||||
|
||||
@@ -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 自动标记 + 保存 */}
|
||||
|
||||
@@ -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 失败时,把选中文本转为指定节点 */
|
||||
|
||||
Reference in New Issue
Block a user