feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
This commit is contained in:
@@ -10,13 +10,23 @@ import { z } from "zod"
|
||||
import {
|
||||
createQuestionWithRelations,
|
||||
deleteQuestionByIdRecursive,
|
||||
deleteQuestionsBatch,
|
||||
exportQuestions,
|
||||
getChapterOptions,
|
||||
getKnowledgePointOptions,
|
||||
getKnowledgePointOptionsByChapter,
|
||||
getQuestions,
|
||||
getTextbookOptions,
|
||||
importQuestions,
|
||||
updateQuestionById,
|
||||
type GetQuestionsParams,
|
||||
type QuestionExportItem,
|
||||
type QuestionImportItem,
|
||||
} from "./data-access"
|
||||
import type { KnowledgePointOption } from "./types"
|
||||
import type { ChapterOption, KnowledgePointOption, TextbookOption } from "./types"
|
||||
import { QuestionTypeEnum } from "./schema"
|
||||
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||
import { trackQuestionCreated, trackQuestionUpdated, trackQuestionDeleted } from "./utils/track-event"
|
||||
|
||||
/** Result type of getQuestions (data + meta) */
|
||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
||||
@@ -56,7 +66,10 @@ export async function createQuestionAction(
|
||||
|
||||
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
||||
|
||||
trackQuestionCreated(questionId, input.type, input.difficulty)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -102,7 +115,10 @@ export async function updateQuestionAction(
|
||||
|
||||
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
|
||||
|
||||
trackQuestionUpdated(id, updateData.type, updateData.difficulty)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Question updated successfully", data: id }
|
||||
} catch (e) {
|
||||
@@ -125,7 +141,10 @@ export async function deleteQuestionAction(
|
||||
|
||||
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
|
||||
|
||||
trackQuestionDeleted(questionId)
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Question deleted successfully", data: questionId }
|
||||
} catch (e) {
|
||||
@@ -133,6 +152,53 @@ export async function deleteQuestionAction(
|
||||
}
|
||||
}
|
||||
|
||||
const BatchDeleteSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1, "At least one question ID is required"),
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量删除题目。
|
||||
*
|
||||
* 输入 JSON:{ ids: string[] }
|
||||
* 权限:QUESTION_DELETE,遵循数据范围过滤。
|
||||
*/
|
||||
export async function deleteQuestionsBatchAction(
|
||||
prevState: ActionState<{ deleted: number }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ deleted: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_DELETE)
|
||||
const canDeleteAll = ctx.dataScope.type === "all"
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
|
||||
const parsed = BatchDeleteSchema.safeParse(safeJsonParse<unknown>(jsonString, "批量删除参数无效"))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const deletedCount = await deleteQuestionsBatch(parsed.data.ids, canDeleteAll, ctx.userId)
|
||||
|
||||
for (const id of parsed.data.ids) {
|
||||
trackQuestionDeleted(id)
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Batch delete completed", data: { deleted: deletedCount } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuestionsAction(
|
||||
params: GetQuestionsParams,
|
||||
): Promise<ActionState<QuestionsListResult>> {
|
||||
@@ -156,3 +222,158 @@ export async function getKnowledgePointOptionsAction(): Promise<
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Result type of getTextbookOptions */
|
||||
type TextbookOptionsResult = TextbookOption[]
|
||||
|
||||
/** Result type of getChapterOptions */
|
||||
type ChapterOptionsResult = ChapterOption[]
|
||||
|
||||
/** Result type of getKnowledgePointOptionsByChapter */
|
||||
type KnowledgePointOptionsByChapterResult = { id: string; name: string }[]
|
||||
|
||||
/**
|
||||
* 获取教材选项列表(级联筛选第一级)。
|
||||
*/
|
||||
export async function getTextbookOptionsAction(): Promise<
|
||||
ActionState<TextbookOptionsResult>
|
||||
> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getTextbookOptions()
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定教材下的章节选项列表(级联筛选第二级)。
|
||||
*/
|
||||
export async function getChapterOptionsAction(
|
||||
textbookId: string,
|
||||
): Promise<ActionState<ChapterOptionsResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getChapterOptions(textbookId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
|
||||
*/
|
||||
export async function getKnowledgePointOptionsByChapterAction(
|
||||
chapterId: string,
|
||||
): Promise<ActionState<KnowledgePointOptionsByChapterResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
const data = await getKnowledgePointOptionsByChapter(chapterId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导入/导出 Server Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ExportSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 导出题目为 JSON 格式。
|
||||
*
|
||||
* 输入 JSON:{ ids?: string[] }(不传 ids 则导出全部,受权限范围限制)
|
||||
* 权限:QUESTION_READ,遵循数据范围过滤。
|
||||
*/
|
||||
export async function exportQuestionsAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionState<QuestionExportItem[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_READ)
|
||||
const canExportAll = ctx.dataScope.type === "all"
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
const inputJson = typeof jsonString === "string" ? jsonString : "{}"
|
||||
const parsed = ExportSchema.safeParse(safeJsonParse<unknown>(inputJson, "导出参数无效"))
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const data = await exportQuestions(parsed.data.ids, canExportAll, ctx.userId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const ImportItemSchema = z.object({
|
||||
type: QuestionTypeEnum,
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.unknown(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
const ImportSchema = z.object({
|
||||
items: z.array(ImportItemSchema).min(1, "至少需要一条题目"),
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量导入题目。
|
||||
*
|
||||
* 输入 JSON:{ items: QuestionImportItem[] }
|
||||
* 权限:QUESTION_CREATE。
|
||||
*/
|
||||
export async function importQuestionsAction(
|
||||
prevState: ActionState<{ imported: number }> | undefined,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ imported: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.QUESTION_CREATE)
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." }
|
||||
}
|
||||
|
||||
const parsed = ImportSchema.safeParse(safeJsonParse<unknown>(jsonString, "导入参数无效"))
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const items: QuestionImportItem[] = parsed.data.items.map((item) => ({
|
||||
type: item.type,
|
||||
difficulty: item.difficulty,
|
||||
content: item.content,
|
||||
knowledgePointIds: item.knowledgePointIds,
|
||||
}))
|
||||
|
||||
const createdIds = await importQuestions(items, ctx.userId)
|
||||
|
||||
for (let i = 0; i < createdIds.length; i++) {
|
||||
const item = items[i]
|
||||
trackQuestionCreated(createdIds[i], item.type, item.difficulty)
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/questions")
|
||||
revalidatePath("/admin/questions")
|
||||
|
||||
return { success: true, message: "Import completed", data: { imported: createdIds.length } }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
121
src/modules/questions/components/batch-operations.tsx
Normal file
121
src/modules/questions/components/batch-operations.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Trash2, X } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { deleteQuestionsBatchAction } from "../actions"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface BatchOperationsProps {
|
||||
/** 选中的题目 ID 列表 */
|
||||
selectedIds: string[]
|
||||
/** 清除选择回调 */
|
||||
onClearSelection: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作工具栏。
|
||||
*
|
||||
* 当表格有选中行时显示,提供批量删除等功能。
|
||||
* 权限感知:无 QUESTION_DELETE 权限时不显示删除按钮。
|
||||
*/
|
||||
export function BatchOperations({ selectedIds, onClearSelection }: BatchOperationsProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const canDelete = hasPermission(Permissions.QUESTION_DELETE)
|
||||
|
||||
if (selectedIds.length === 0) return null
|
||||
|
||||
const handleBatchDelete = async (): Promise<void> => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("json", JSON.stringify({ ids: selectedIds }))
|
||||
const res = await deleteQuestionsBatchAction(undefined, fd)
|
||||
if (res.success) {
|
||||
const deleted = res.data?.deleted ?? 0
|
||||
toast.success(t("batch.deleteSuccess", { count: deleted }))
|
||||
setShowDeleteDialog(false)
|
||||
onClearSelection()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("batch.deleteFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to batch delete questions", e)
|
||||
toast.error(t("batch.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("batch.selected", { count: selectedIds.length })}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("batch.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={onClearSelection}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
{t("batch.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("batch.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("batch.deleteConfirmDesc", { count: selectedIds.length })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("batch.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleBatchDelete()
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t("batch.deleting") : t("batch.deleteConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
|
||||
export function CreateQuestionButton() {
|
||||
export function CreateQuestionButton(): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const { hasPermission } = usePermission()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!hasPermission(Permissions.QUESTION_CREATE)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Question
|
||||
{t("addQuestion")}
|
||||
</Button>
|
||||
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useForm, type SubmitHandler } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,19 +16,20 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormLabel,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Form } from "@/shared/components/ui/form"
|
||||
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
||||
import { createQuestionAction, updateQuestionAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Question } from "../types"
|
||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
||||
import type { Question } from "../types"
|
||||
import {
|
||||
parseQuestionContent,
|
||||
getQuestionOptions,
|
||||
type QuestionOption,
|
||||
} from "../utils/parse-content"
|
||||
import { KnowledgePointSelector } from "./knowledge-point-selector"
|
||||
import { OptionsEditor, type QuestionOptionInput } from "./options-editor"
|
||||
|
||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
difficulty: z.number().min(1).max(5),
|
||||
@@ -39,7 +39,7 @@ const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
isCorrect: z.boolean().default(false),
|
||||
isCorrect: z.boolean(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
@@ -56,86 +56,55 @@ interface CreateQuestionDialogProps {
|
||||
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
|
||||
}
|
||||
|
||||
function getInitialTextFromContent(content: unknown) {
|
||||
if (typeof content === "string") return content
|
||||
if (content && typeof content === "object") {
|
||||
const text = (content as { text?: unknown }).text
|
||||
if (typeof text === "string") return text
|
||||
/**
|
||||
* 将 QuestionOption[] 转换为 QuestionOptionInput[](表单格式)。
|
||||
*/
|
||||
function optionsToFormInput(options: QuestionOption[] | undefined): QuestionOptionInput[] {
|
||||
if (!options || options.length === 0) {
|
||||
return [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
}
|
||||
if (content == null) return ""
|
||||
return JSON.stringify(content)
|
||||
return options.map((opt) => ({
|
||||
label: opt.text,
|
||||
value: opt.id,
|
||||
isCorrect: opt.isCorrect,
|
||||
}))
|
||||
}
|
||||
|
||||
function getInitialOptionsFromContent(content: unknown) {
|
||||
if (!content || typeof content !== "object") return undefined
|
||||
const rawOptions = (content as { options?: unknown }).options
|
||||
if (!Array.isArray(rawOptions)) return undefined
|
||||
|
||||
const mapped = rawOptions
|
||||
.map((opt) => {
|
||||
if (!opt || typeof opt !== "object") return null
|
||||
const id =
|
||||
(opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value
|
||||
const text =
|
||||
(opt as { text?: unknown; label?: unknown }).text ??
|
||||
(opt as { label?: unknown }).label
|
||||
const isCorrect = (opt as { isCorrect?: unknown }).isCorrect
|
||||
return {
|
||||
value: typeof id === "string" ? id : "",
|
||||
label: typeof text === "string" ? text : "",
|
||||
isCorrect: typeof isCorrect === "boolean" ? isCorrect : false,
|
||||
}
|
||||
})
|
||||
.filter((v): v is NonNullable<typeof v> => Boolean(v && v.value && v.label))
|
||||
|
||||
return mapped.length > 0 ? mapped : undefined
|
||||
}
|
||||
|
||||
export function CreateQuestionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
export function CreateQuestionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
defaultKnowledgePointIds = [],
|
||||
defaultContent = "",
|
||||
defaultType = "single_choice"
|
||||
}: CreateQuestionDialogProps) {
|
||||
defaultType = "single_choice",
|
||||
}: CreateQuestionDialogProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const router = useRouter()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isEdit = !!initialData
|
||||
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
||||
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
||||
|
||||
const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery(
|
||||
() => getKnowledgePointOptionsAction(),
|
||||
{ deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" }
|
||||
)
|
||||
const knowledgePointOptions = knowledgePointOptionsData ?? []
|
||||
|
||||
const form = useForm<QuestionFormValues>({
|
||||
resolver: zodResolver(QuestionFormSchema),
|
||||
defaultValues: {
|
||||
type: initialData?.type || defaultType,
|
||||
difficulty: initialData?.difficulty || 1,
|
||||
content: getInitialTextFromContent(initialData?.content) || defaultContent,
|
||||
options:
|
||||
getInitialOptionsFromContent(initialData?.content) ?? [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
content: parseQuestionContent(initialData?.content).text || defaultContent,
|
||||
options: optionsToFormInput(getQuestionOptions(initialData?.content)),
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
const parsed = parseQuestionContent(initialData.content)
|
||||
form.reset({
|
||||
type: initialData.type,
|
||||
difficulty: initialData.difficulty,
|
||||
content: getInitialTextFromContent(initialData.content),
|
||||
options:
|
||||
getInitialOptionsFromContent(initialData.content) ?? [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
content: parsed.text,
|
||||
options: optionsToFormInput(parsed.options),
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
@@ -153,44 +122,16 @@ export function CreateQuestionDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initialData) {
|
||||
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
|
||||
setSelectedKnowledgePointIds((prev) => {
|
||||
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
|
||||
return prev
|
||||
}
|
||||
return nextIds
|
||||
})
|
||||
setSelectedKnowledgePointIds(initialData.knowledgePoints.map((kp) => kp.id))
|
||||
return
|
||||
}
|
||||
setSelectedKnowledgePointIds((prev) => {
|
||||
if (
|
||||
prev.length === defaultKnowledgePointIds.length &&
|
||||
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return defaultKnowledgePointIds
|
||||
})
|
||||
setSelectedKnowledgePointIds(defaultKnowledgePointIds)
|
||||
}, [open, initialData, defaultKnowledgePointIds])
|
||||
|
||||
const questionType = form.watch("type")
|
||||
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
|
||||
const query = knowledgePointQuery.trim().toLowerCase()
|
||||
if (!query) return true
|
||||
const fullLabel = [
|
||||
kp.textbookTitle,
|
||||
kp.chapterTitle,
|
||||
kp.name,
|
||||
kp.subject,
|
||||
kp.grade,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return fullLabel.includes(query)
|
||||
})
|
||||
const formOptions = form.watch("options") ?? []
|
||||
|
||||
const buildContent = (data: QuestionFormValues) => {
|
||||
const buildContent = (data: QuestionFormValues): unknown => {
|
||||
const text = data.content.trim()
|
||||
if (data.type === "single_choice" || data.type === "multiple_choice") {
|
||||
const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0)
|
||||
@@ -223,7 +164,7 @@ export function CreateQuestionDialog({
|
||||
setIsPending(true)
|
||||
try {
|
||||
if (isEdit && !initialData?.id) {
|
||||
toast.error("Missing question id")
|
||||
toast.error(t("error.missingId"))
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
@@ -239,18 +180,18 @@ export function CreateQuestionDialog({
|
||||
? await updateQuestionAction(undefined, fd)
|
||||
: await createQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(isEdit ? "Updated question" : "Created question")
|
||||
toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
if (!isEdit) {
|
||||
form.reset()
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || "Operation failed")
|
||||
toast.error(res.message || t("error.operationFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to submit question", e)
|
||||
toast.error("Unexpected error")
|
||||
toast.error(t("error.unexpected"))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
@@ -260,33 +201,33 @@ export function CreateQuestionDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
|
||||
<DialogTitle>{isEdit ? t("dialog.editTitle") : t("dialog.createTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
|
||||
{isEdit ? t("dialog.editDesc") : t("dialog.createDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
control={form.control}
|
||||
name="type"
|
||||
label="Question Type"
|
||||
placeholder="Select type"
|
||||
label={t("dialog.questionType")}
|
||||
placeholder={t("dialog.selectType")}
|
||||
options={[
|
||||
{ value: "single_choice", label: "Single Choice" },
|
||||
{ value: "multiple_choice", label: "Multiple Choice" },
|
||||
{ value: "judgment", label: "True/False" },
|
||||
{ value: "text", label: "Short Answer" },
|
||||
{ value: "composite", label: "Composite" },
|
||||
{ value: "single_choice", label: t("type.single_choice") },
|
||||
{ value: "multiple_choice", label: t("type.multiple_choice") },
|
||||
{ value: "judgment", label: t("type.judgment") },
|
||||
{ value: "text", label: t("type.text") },
|
||||
{ value: "composite", label: t("type.composite") },
|
||||
]}
|
||||
/>
|
||||
<SelectField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
label="Difficulty (1-5)"
|
||||
placeholder="Select difficulty"
|
||||
label={t("dialog.difficulty")}
|
||||
placeholder={t("dialog.selectDifficulty")}
|
||||
toSelectValue={(v) => String(v)}
|
||||
fromSelectValue={(val) => {
|
||||
const n = parseInt(val, 10)
|
||||
@@ -294,7 +235,7 @@ export function CreateQuestionDialog({
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5].map((level) => ({
|
||||
value: String(level),
|
||||
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
|
||||
label: `${level} - ${t(`difficulty.${level}`)}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -302,147 +243,33 @@ export function CreateQuestionDialog({
|
||||
<TextareaField
|
||||
control={form.control}
|
||||
name="content"
|
||||
label="Question Content"
|
||||
placeholder="Enter the question text here..."
|
||||
description="Supports basic text. Rich text editor coming soon."
|
||||
label={t("dialog.questionContent")}
|
||||
placeholder={t("dialog.contentPlaceholder")}
|
||||
description={t("dialog.contentDescription")}
|
||||
textareaClassName="min-h-[100px]"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Knowledge Points</FormLabel>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Search knowledge points..."
|
||||
value={knowledgePointQuery}
|
||||
onChange={(e) => setKnowledgePointQuery(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-md border">
|
||||
<ScrollArea className="h-48">
|
||||
{isLoadingKnowledgePoints ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">Loading...</div>
|
||||
) : filteredKnowledgePoints.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">No knowledge points found.</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredKnowledgePoints.map((kp) => {
|
||||
const labelParts = [
|
||||
kp.textbookTitle,
|
||||
kp.chapterTitle,
|
||||
kp.name,
|
||||
].filter(Boolean)
|
||||
const label = labelParts.join(" · ")
|
||||
return (
|
||||
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedKnowledgePointIds.includes(kp.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true
|
||||
setSelectedKnowledgePointIds((prev) => {
|
||||
if (isChecked) {
|
||||
if (prev.includes(kp.id)) return prev
|
||||
return [...prev, kp.id]
|
||||
}
|
||||
return prev.filter((id) => id !== kp.id)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgePointSelector
|
||||
selectedIds={selectedKnowledgePointIds}
|
||||
onChange={setSelectedKnowledgePointIds}
|
||||
/>
|
||||
|
||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Options</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentOptions = form.getValues("options") || []
|
||||
const nextIndex = currentOptions.length
|
||||
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
|
||||
form.setValue("options", [
|
||||
...currentOptions,
|
||||
{
|
||||
label: `Option ${nextChar}`,
|
||||
value: nextChar,
|
||||
isCorrect: false,
|
||||
},
|
||||
])
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={option.isCorrect}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
if (!next[index]) return
|
||||
|
||||
const isChecked = checked === true
|
||||
if (questionType === "single_choice" && isChecked) {
|
||||
for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index
|
||||
} else {
|
||||
next[index].isCorrect = isChecked
|
||||
}
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
aria-label="Mark correct"
|
||||
/>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
if (!next[index]) return
|
||||
next[index].label = e.target.value
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => {
|
||||
const next = [...(form.getValues("options") || [])]
|
||||
next.splice(index, 1)
|
||||
form.setValue("options", next)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<OptionsEditor
|
||||
options={formOptions}
|
||||
onChange={(next) => form.setValue("options", next)}
|
||||
singleChoice={questionType === "single_choice"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t("dialog.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (isEdit ? "Updating..." : "Creating...") : (isEdit ? "Update Question" : "Create Question")}
|
||||
{isPending
|
||||
? (isEdit ? t("dialog.updating") : t("dialog.creating"))
|
||||
: (isEdit ? t("dialog.update") : t("dialog.create"))}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
193
src/modules/questions/components/import-export-buttons.tsx
Normal file
193
src/modules/questions/components/import-export-buttons.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Download, Upload, FileJson } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { exportQuestionsAction, importQuestionsAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* 题目导入/导出组件。
|
||||
*
|
||||
* 提供两个功能:
|
||||
* - 导出:将当前题库导出为 JSON 文件下载
|
||||
* - 导入:上传 JSON 文件批量导入题目
|
||||
*
|
||||
* 权限感知:
|
||||
* - 导出需要 QUESTION_READ
|
||||
* - 导入需要 QUESTION_CREATE
|
||||
*/
|
||||
export function ImportExportButtons(): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||
const [pendingImportData, setPendingImportData] = useState<string | null>(null)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
const canRead = hasPermission(Permissions.QUESTION_READ)
|
||||
const canCreate = hasPermission(Permissions.QUESTION_CREATE)
|
||||
|
||||
const handleExport = async (): Promise<void> => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("json", JSON.stringify({}))
|
||||
const res = await exportQuestionsAction(fd)
|
||||
if (res.success && res.data) {
|
||||
const json = JSON.stringify(res.data, null, 2)
|
||||
const blob = new Blob([json], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `questions-export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t("importExport.exportSuccess", { count: res.data.length }))
|
||||
} else {
|
||||
toast.error(res.message || t("importExport.exportFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to export questions", e)
|
||||
toast.error(t("importExport.exportFailed"))
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result
|
||||
if (typeof text === "string") {
|
||||
setPendingImportData(text)
|
||||
setShowImportDialog(true)
|
||||
} else {
|
||||
toast.error(t("importExport.invalidFile"))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
toast.error(t("importExport.readFailed"))
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
if (!pendingImportData) return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("json", pendingImportData)
|
||||
const res = await importQuestionsAction(undefined, fd)
|
||||
if (res.success) {
|
||||
const imported = res.data?.imported ?? 0
|
||||
toast.success(t("importExport.importSuccess", { count: imported }))
|
||||
setShowImportDialog(false)
|
||||
setPendingImportData(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("importExport.importFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to import questions", e)
|
||||
toast.error(t("importExport.importFailed"))
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{canRead && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? t("importExport.exporting") : t("importExport.export")}
|
||||
</Button>
|
||||
)}
|
||||
{canCreate && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isImporting ? t("importExport.importing") : t("importExport.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
aria-label={t("importExport.import")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={showImportDialog} onOpenChange={setShowImportDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileJson className="h-5 w-5" />
|
||||
{t("importExport.confirmTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("importExport.confirmDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<pre className="text-xs">
|
||||
{pendingImportData ? pendingImportData.slice(0, 2000) : ""}
|
||||
{pendingImportData && pendingImportData.length > 2000 ? "\n..." : ""}
|
||||
</pre>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportDialog(false)
|
||||
setPendingImportData(null)
|
||||
}}
|
||||
>
|
||||
{t("importExport.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting ? t("importExport.importing") : t("importExport.confirmImport")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
src/modules/questions/components/knowledge-point-selector.tsx
Normal file
107
src/modules/questions/components/knowledge-point-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { FormLabel } from "@/shared/components/ui/form"
|
||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
||||
import { getKnowledgePointOptionsAction } from "../actions"
|
||||
import type { KnowledgePointOption } from "../types"
|
||||
|
||||
interface KnowledgePointSelectorProps {
|
||||
selectedIds: string[]
|
||||
onChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识点选择器组件。
|
||||
*
|
||||
* 从 create-question-dialog.tsx 抽取,负责知识点搜索和多选。
|
||||
* 使用 useActionQuery 加载知识点选项,支持模糊搜索。
|
||||
*/
|
||||
export function KnowledgePointSelector({ selectedIds, onChange }: KnowledgePointSelectorProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
const { data: knowledgePointOptions, loading: isLoading } = useActionQuery(
|
||||
() => getKnowledgePointOptionsAction(),
|
||||
{ deps: [], errorMessage: false }
|
||||
)
|
||||
|
||||
const options = knowledgePointOptions ?? []
|
||||
|
||||
const filteredOptions = options.filter((kp: KnowledgePointOption) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
const fullLabel = [
|
||||
kp.textbookTitle,
|
||||
kp.chapterTitle,
|
||||
kp.name,
|
||||
kp.subject,
|
||||
kp.grade,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return fullLabel.includes(q)
|
||||
})
|
||||
|
||||
const toggle = (kpId: string, checked: boolean): void => {
|
||||
if (checked) {
|
||||
if (selectedIds.includes(kpId)) return
|
||||
onChange([...selectedIds, kpId])
|
||||
} else {
|
||||
onChange(selectedIds.filter((id) => id !== kpId))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("dialog.knowledgePoints")}</FormLabel>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedIds.length > 0
|
||||
? t("dialog.knowledgePointsSelected", { count: selectedIds.length })
|
||||
: t("dialog.knowledgePointsOptional")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("dialog.searchKnowledgePoints")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-md border">
|
||||
<ScrollArea className="h-48">
|
||||
{isLoading ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">{t("dialog.loading")}</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground">{t("dialog.noKnowledgePoints")}</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredOptions.map((kp) => {
|
||||
const labelParts = [
|
||||
kp.textbookTitle,
|
||||
kp.chapterTitle,
|
||||
kp.name,
|
||||
].filter(Boolean)
|
||||
const label = labelParts.join(" · ")
|
||||
return (
|
||||
<label key={kp.id} className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(kp.id)}
|
||||
onCheckedChange={(checked) => toggle(kp.id, checked === true)}
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
src/modules/questions/components/options-editor.tsx
Normal file
106
src/modules/questions/components/options-editor.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { FormLabel } from "@/shared/components/ui/form"
|
||||
|
||||
export interface QuestionOptionInput {
|
||||
label: string
|
||||
value: string
|
||||
isCorrect: boolean
|
||||
}
|
||||
|
||||
interface OptionsEditorProps {
|
||||
options: QuestionOptionInput[]
|
||||
onChange: (options: QuestionOptionInput[]) => void
|
||||
/** 单选模式:选中一个选项时自动取消其他 */
|
||||
singleChoice: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项编辑器组件。
|
||||
*
|
||||
* 从 create-question-dialog.tsx 抽取,负责选择题选项的增删改。
|
||||
* 支持单选(single_choice)和多选(multiple_choice)模式。
|
||||
*/
|
||||
export function OptionsEditor({ options, onChange, singleChoice }: OptionsEditorProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
|
||||
const addOption = (): void => {
|
||||
const nextIndex = options.length
|
||||
const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1)
|
||||
onChange([
|
||||
...options,
|
||||
{ label: `Option ${nextChar}`, value: nextChar, isCorrect: false },
|
||||
])
|
||||
}
|
||||
|
||||
const toggleCorrect = (index: number, checked: boolean): void => {
|
||||
const next = [...options]
|
||||
if (!next[index]) return
|
||||
|
||||
if (singleChoice && checked) {
|
||||
for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index
|
||||
} else {
|
||||
next[index].isCorrect = checked
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const updateLabel = (index: number, label: string): void => {
|
||||
const next = [...options]
|
||||
if (!next[index]) return
|
||||
next[index].label = label
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const removeOption = (index: number): void => {
|
||||
const next = [...options]
|
||||
next.splice(index, 1)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("dialog.options")}</FormLabel>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addOption}>
|
||||
<Plus className="mr-2 h-3 w-3" /> {t("dialog.addOption")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{options.map((option, index) => (
|
||||
<div key={option.value || `option-${index}`} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={option.isCorrect}
|
||||
onCheckedChange={(checked) => toggleCorrect(index, checked === true)}
|
||||
aria-label={t("dialog.markCorrect")}
|
||||
/>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateLabel(index, e.target.value)}
|
||||
placeholder={t("dialog.optionPlaceholder", { index: index + 1 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => removeOption(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
@@ -31,146 +32,161 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { Question } from "../types"
|
||||
import type { Question } from "../types"
|
||||
import { deleteQuestionAction } from "../actions"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
import { QuestionContentRenderer } from "./question-content-renderer"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface QuestionActionsProps {
|
||||
question: Question
|
||||
}
|
||||
|
||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
export function QuestionActions({ question }: QuestionActionsProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
const canEdit = hasPermission(Permissions.QUESTION_UPDATE)
|
||||
const canDelete = hasPermission(Permissions.QUESTION_DELETE)
|
||||
|
||||
const copyId = (): void => {
|
||||
try {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
toast.success(t("actions.copyIdSuccess"))
|
||||
} catch (e) {
|
||||
console.error("Failed to copy question ID to clipboard", e)
|
||||
toast.error("Failed to copy question ID")
|
||||
toast.error(t("actions.copyIdFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.set("questionId", question.id)
|
||||
const res = await deleteQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success("Question deleted successfully")
|
||||
toast.success(t("actions.deleteSuccess"))
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "Failed to delete question")
|
||||
toast.error(res.message || t("actions.deleteFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete question", e)
|
||||
toast.error("Failed to delete question")
|
||||
toast.error(t("actions.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = t(question.type)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label={t("actions.menuLabel")}>
|
||||
<span className="sr-only">{t("actions.menuLabel")}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t("actions.actions")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={copyId}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
<Copy className="mr-2 h-4 w-4" /> {t("actions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
<Eye className="mr-2 h-4 w-4" /> {t("actions.viewDetails")}
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> {t("actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> {t("actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreateQuestionDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
initialData={question}
|
||||
/>
|
||||
{canEdit && (
|
||||
<CreateQuestionDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
initialData={question}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the question
|
||||
and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{canDelete && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("actions.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("actions.deleteConfirmDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("actions.deleteConfirmCancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t("actions.deleting") : t("actions.deleteConfirmAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Question Details</DialogTitle>
|
||||
<DialogDescription>ID: {question.id}</DialogDescription>
|
||||
<DialogTitle>{t("actions.detailsTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("actions.detailsId", { id: question.id })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Type:</span>
|
||||
<span className="col-span-3 capitalize">{question.type.replaceAll("_", " ")}</span>
|
||||
<span className="font-medium">{t("actions.detailsType")}</span>
|
||||
<span className="col-span-3">{typeLabel}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Difficulty:</span>
|
||||
<span className="font-medium">{t("actions.detailsDifficulty")}</span>
|
||||
<span className="col-span-3">{question.difficulty}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<span className="font-medium pt-1">Content:</span>
|
||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
||||
{typeof question.content === "string"
|
||||
? question.content
|
||||
: JSON.stringify(question.content, null, 2)}
|
||||
<span className="font-medium pt-1">{t("actions.detailsContent")}</span>
|
||||
<div className="col-span-3 rounded-md bg-muted p-3">
|
||||
<QuestionContentRenderer question={question} showAnswer />
|
||||
</div>
|
||||
</div>
|
||||
{question.author && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Author:</span>
|
||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
||||
<span className="font-medium">{t("actions.detailsAuthor")}</span>
|
||||
<span className="col-span-3">{question.author.name || t("actions.detailsUnknown")}</span>
|
||||
</div>
|
||||
)}
|
||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Tags:</span>
|
||||
<span className="font-medium">{t("actions.detailsTags")}</span>
|
||||
<div className="col-span-3 flex flex-wrap gap-1">
|
||||
{question.knowledgePoints.map(kp => (
|
||||
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { QuestionDataTable } from "./question-data-table"
|
||||
import { useQuestionColumns } from "./question-columns"
|
||||
import type { Question } from "../types"
|
||||
|
||||
interface QuestionBankResultsClientProps {
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 题库表格客户端组件。
|
||||
*
|
||||
* 桥接 Server Component 数据与客户端 Hook(useQuestionColumns)。
|
||||
* useQuestionColumns 内部使用 useTranslations,必须在客户端组件中调用。
|
||||
*/
|
||||
export function QuestionBankResultsClient({ questions }: QuestionBankResultsClientProps): React.ReactNode {
|
||||
const columns = useQuestionColumns()
|
||||
return <QuestionDataTable columns={columns} data={questions} getRowId={(q) => q.id} />
|
||||
}
|
||||
154
src/modules/questions/components/question-cascade-filter.tsx
Normal file
154
src/modules/questions/components/question-cascade-filter.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
||||
import {
|
||||
getTextbookOptionsAction,
|
||||
getChapterOptionsAction,
|
||||
getKnowledgePointOptionsByChapterAction,
|
||||
} from "../actions"
|
||||
import type { ChapterOption } from "../types"
|
||||
|
||||
/**
|
||||
* 题库级联筛选组件:教材 → 章节 → 知识点。
|
||||
*
|
||||
* 三级级联,选择教材后加载章节,选择章节后加载知识点。
|
||||
* 清除上级时自动清除下级。URL 状态通过 nuqs 管理。
|
||||
*
|
||||
* @example
|
||||
* <QuestionCascadeFilter />
|
||||
*/
|
||||
export function QuestionCascadeFilter(): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
|
||||
const [textbookId, setTextbookId] = useQueryState("tb", parseAsString.withDefault("all"))
|
||||
const [chapterId, setChapterId] = useQueryState("ch", parseAsString.withDefault("all"))
|
||||
const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all"))
|
||||
|
||||
// 第一级:教材列表
|
||||
const { data: textbooks, loading: textbooksLoading } = useActionQuery(
|
||||
() => getTextbookOptionsAction(),
|
||||
{ deps: [], errorMessage: false }
|
||||
)
|
||||
|
||||
// 第二级:章节列表(仅当教材已选时加载)
|
||||
const { data: chapters, loading: chaptersLoading } = useActionQuery(
|
||||
() => getChapterOptionsAction(textbookId),
|
||||
{ deps: [textbookId], enabled: textbookId !== "all", errorMessage: false }
|
||||
)
|
||||
|
||||
// 第三级:知识点列表(仅当章节已选时加载)
|
||||
const { data: knowledgePoints, loading: kpsLoading } = useActionQuery(
|
||||
() => getKnowledgePointOptionsByChapterAction(chapterId),
|
||||
{ deps: [chapterId], enabled: chapterId !== "all", errorMessage: false }
|
||||
)
|
||||
|
||||
// 清除教材时,自动清除章节和知识点
|
||||
const handleTextbookChange = (value: string): void => {
|
||||
if (value === "all") {
|
||||
setTextbookId(null)
|
||||
setChapterId(null)
|
||||
setKnowledgePointId(null)
|
||||
} else {
|
||||
setTextbookId(value)
|
||||
setChapterId(null)
|
||||
setKnowledgePointId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除章节时,自动清除知识点
|
||||
const handleChapterChange = (value: string): void => {
|
||||
if (value === "all") {
|
||||
setChapterId(null)
|
||||
setKnowledgePointId(null)
|
||||
} else {
|
||||
setChapterId(value)
|
||||
setKnowledgePointId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKnowledgePointChange = (value: string): void => {
|
||||
setKnowledgePointId(value === "all" ? null : value)
|
||||
}
|
||||
|
||||
// 章节选项带缩进
|
||||
const chapterItems = useMemo(() => {
|
||||
if (!chapters) return []
|
||||
return chapters.map((ch: ChapterOption) => ({
|
||||
id: ch.id,
|
||||
title: ch.title,
|
||||
indent: ch.depth,
|
||||
}))
|
||||
}, [chapters])
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={textbookId} onValueChange={handleTextbookChange}>
|
||||
<SelectTrigger className="w-[180px]" aria-label={t("filters.textbook")}>
|
||||
<SelectValue placeholder={t("filters.textbook")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.textbookAll")}</SelectItem>
|
||||
{textbooks?.map((tb) => (
|
||||
<SelectItem key={tb.id} value={tb.id}>
|
||||
{tb.title}
|
||||
{tb.grade ? ` (${tb.grade})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={chapterId}
|
||||
onValueChange={handleChapterChange}
|
||||
disabled={textbookId === "all"}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]" aria-label={t("filters.chapter")}>
|
||||
<SelectValue placeholder={t("filters.chapter")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.chapterAll")}</SelectItem>
|
||||
{chapterItems.map((ch) => (
|
||||
<SelectItem key={ch.id} value={ch.id}>
|
||||
{" ".repeat(ch.indent)}{ch.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={knowledgePointId}
|
||||
onValueChange={handleKnowledgePointChange}
|
||||
disabled={chapterId === "all"}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" aria-label={t("filters.knowledgePoint")}>
|
||||
<SelectValue placeholder={t("filters.knowledgePoint")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.knowledgePointAll")}</SelectItem>
|
||||
{knowledgePoints?.map((kp) => (
|
||||
<SelectItem key={kp.id} value={kp.id}>
|
||||
{kp.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(textbooksLoading || chaptersLoading || kpsLoading) && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse" aria-live="polite">
|
||||
{t("dialog.loading")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,142 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { Question } from "../types"
|
||||
import { QUESTION_TYPE_VARIANT, QUESTION_TYPE_LABEL } from "../types"
|
||||
import type { Question } from "../types"
|
||||
import { QUESTION_TYPE_VARIANT } from "../types"
|
||||
import { getQuestionPreview } from "../utils/parse-content"
|
||||
import { QuestionActions } from "./question-actions"
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type
|
||||
return (
|
||||
<StatusBadge
|
||||
status={type}
|
||||
variantMap={QUESTION_TYPE_VARIANT}
|
||||
labelMap={QUESTION_TYPE_LABEL}
|
||||
className="whitespace-nowrap"
|
||||
capitalize={false}
|
||||
/**
|
||||
* 题目表格列定义。
|
||||
*
|
||||
* 使用函数形式以获取 i18n 翻译函数。
|
||||
* 调用方通过 `useQuestionColumns()` 获取列定义。
|
||||
*/
|
||||
export function useQuestionColumns(): ColumnDef<Question>[] {
|
||||
const t = useTranslations("questions")
|
||||
|
||||
return useMemo(() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label={t("actions.menuLabel")}
|
||||
/>
|
||||
)
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t("actions.menuLabel")}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const content = row.original.content
|
||||
let preview = ""
|
||||
if (typeof content === "string") {
|
||||
preview = content
|
||||
} else if (content && typeof content === "object") {
|
||||
const text = (content as { text?: unknown }).text
|
||||
if (typeof text === "string") {
|
||||
preview = text
|
||||
} else {
|
||||
preview = JSON.stringify(content)
|
||||
}
|
||||
}
|
||||
preview = preview.slice(0, 80)
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: t("table.type"),
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type
|
||||
return (
|
||||
<StatusBadge
|
||||
status={type}
|
||||
variantMap={QUESTION_TYPE_VARIANT}
|
||||
labelMap={{ [type]: t(type) }}
|
||||
className="whitespace-nowrap"
|
||||
capitalize={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
const label =
|
||||
diff === 1
|
||||
? "Easy"
|
||||
: diff === 2
|
||||
? "Easy-Med"
|
||||
: diff === 3
|
||||
? "Medium"
|
||||
: diff === 4
|
||||
? "Med-Hard"
|
||||
: "Hard"
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: t("table.content"),
|
||||
cell: ({ row }) => {
|
||||
const preview = getQuestionPreview(row.original.content, 80)
|
||||
return (
|
||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "knowledgePoints",
|
||||
header: "Knowledge Points",
|
||||
cell: ({ row }) => {
|
||||
const kps = row.original.knowledgePoints
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map((kp) => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: t("table.difficulty"),
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
const label = t(`difficulty.${diff}`)
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="tabular-nums">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{kps.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{createdAt instanceof Date
|
||||
? formatDate(createdAt)
|
||||
: typeof createdAt === "string"
|
||||
{
|
||||
accessorKey: "knowledgePoints",
|
||||
header: t("table.knowledgePoints"),
|
||||
cell: ({ row }) => {
|
||||
const kps = row.original.knowledgePoints
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map((kp) => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{kps.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: t("table.created"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{createdAt instanceof Date
|
||||
? formatDate(createdAt)
|
||||
: "—"}
|
||||
</span>
|
||||
)
|
||||
: typeof createdAt === "string"
|
||||
? formatDate(createdAt)
|
||||
: "—"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <QuestionActions question={row.original} />,
|
||||
},
|
||||
]
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <QuestionActions question={row.original} />,
|
||||
},
|
||||
], [t])
|
||||
}
|
||||
|
||||
116
src/modules/questions/components/question-content-renderer.tsx
Normal file
116
src/modules/questions/components/question-content-renderer.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import type { Question } from "../types"
|
||||
import { parseQuestionContent } from "../utils/parse-content"
|
||||
|
||||
interface QuestionContentRendererProps {
|
||||
question: Question
|
||||
/** 是否显示答案(学生端练习前不显示) */
|
||||
showAnswer?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目内容结构化渲染组件。
|
||||
*
|
||||
* 根据 Question.content(unknown)解析后渲染:
|
||||
* - 题干文本
|
||||
* - 选项列表(选择题):单选显示圆点,多选显示方框
|
||||
* - 正确答案高亮(showAnswer=true 时)
|
||||
* - 答案与解析(showAnswer=true 时)
|
||||
*
|
||||
* 使用 parseQuestionContent 类型守卫,不使用 `as` 断言。
|
||||
*
|
||||
* @example
|
||||
* <QuestionContentRenderer question={question} showAnswer />
|
||||
*/
|
||||
export function QuestionContentRenderer({
|
||||
question,
|
||||
showAnswer = false,
|
||||
className,
|
||||
}: QuestionContentRendererProps): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const content = parseQuestionContent(question.content)
|
||||
const isChoice = question.type === "single_choice" || question.type === "multiple_choice"
|
||||
const isMultiple = question.type === "multiple_choice"
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* 题干 */}
|
||||
{content.text ? (
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{content.text}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">{t("empty.withoutFiltersDesc")}</p>
|
||||
)}
|
||||
|
||||
{/* 选项列表 */}
|
||||
{isChoice && content.options && content.options.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{content.options.map((option, idx) => {
|
||||
const label = String.fromCharCode(65 + idx)
|
||||
const isCorrect = option.isCorrect
|
||||
return (
|
||||
<li
|
||||
key={option.id || `option-${idx}`}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md border p-2 text-sm",
|
||||
showAnswer && isCorrect && "border-green-500/50 bg-green-500/5"
|
||||
)}
|
||||
>
|
||||
{isMultiple ? (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border",
|
||||
isCorrect ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{isCorrect && <CheckCircle2 className="h-3 w-3" />}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border",
|
||||
isCorrect ? "border-green-500 bg-green-500 text-white" : "border-muted-foreground/30"
|
||||
)}
|
||||
>
|
||||
{isCorrect && <CheckCircle2 className="h-3 w-3" />}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium">{label}.</span>
|
||||
<span className="flex-1">{option.text}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* 答案与解析 */}
|
||||
{showAnswer && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
{content.answer && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-700 dark:text-green-400">
|
||||
{t("actions.detailsContent")}
|
||||
</Badge>
|
||||
<span className="text-sm">{content.answer}</span>
|
||||
</div>
|
||||
)}
|
||||
{content.explanation && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
{t("dialog.contentDescription")}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{content.explanation}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -23,16 +24,21 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { BatchOperations } from "./batch-operations"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
/** 获取行 ID 的函数(用于批量操作) */
|
||||
getRowId?: (row: TData) => string
|
||||
}
|
||||
|
||||
export function QuestionDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
getRowId,
|
||||
}: DataTableProps<TData, TValue>): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
|
||||
@@ -45,14 +51,27 @@ export function QuestionDataTable<TData, TValue>({
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: getRowId ? (row) => getRowId(row) : undefined,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
})
|
||||
|
||||
const selectedIds = React.useMemo(() => {
|
||||
if (!getRowId) return []
|
||||
return table.getSelectedRowModel().rows.map((row) => getRowId(row.original))
|
||||
}, [table, getRowId])
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectedIds.length > 0 && (
|
||||
<BatchOperations selectedIds={selectedIds} onClearSelection={clearSelection} />
|
||||
)}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -96,7 +115,7 @@ export function QuestionDataTable<TData, TValue>({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
{t("table.noResults")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -105,8 +124,10 @@ export function QuestionDataTable<TData, TValue>({
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
{t("table.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
@@ -116,7 +137,7 @@ export function QuestionDataTable<TData, TValue>({
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
{t("table.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -124,7 +145,7 @@ export function QuestionDataTable<TData, TValue>({
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
{t("table.next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
@@ -11,28 +11,27 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
import { getKnowledgePointOptionsAction } from "../actions"
|
||||
import type { KnowledgePointOption } from "../types"
|
||||
import { QuestionCascadeFilter } from "./question-cascade-filter"
|
||||
|
||||
/**
|
||||
* 题库筛选栏。
|
||||
*
|
||||
* 组合了:
|
||||
* - 搜索框(nuqs URL 状态)
|
||||
* - 题型/难度下拉(nuqs URL 状态)
|
||||
* - 教材→章节→知识点级联筛选(QuestionCascadeFilter)
|
||||
*
|
||||
* 替代了原有的扁平知识点下拉,符合 K12 教学场景的级联选题工作流。
|
||||
*/
|
||||
export function QuestionFilters(): React.ReactNode {
|
||||
const t = useTranslations("questions")
|
||||
|
||||
export function QuestionFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
||||
const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all"))
|
||||
const [knowledgePointOptions, setKnowledgePointOptions] = useState<KnowledgePointOption[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgePointOptionsAction()
|
||||
.then((result) => {
|
||||
setKnowledgePointOptions(result.success && result.data ? result.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
setKnowledgePointOptions([])
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hasFilters = Boolean(
|
||||
search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all",
|
||||
search || type !== "all" || difficulty !== "all",
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -44,60 +43,43 @@ export function QuestionFilters() {
|
||||
setSearch(null)
|
||||
setType(null)
|
||||
setDifficulty(null)
|
||||
setKnowledgePointId(null)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search questions..."
|
||||
placeholder={t("search.placeholder")}
|
||||
className="flex-1 md:max-w-sm"
|
||||
inputClassName="border-muted-foreground/20 pl-8"
|
||||
/>
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
<SelectTrigger className="w-[150px]" aria-label={t("filters.type")}>
|
||||
<SelectValue placeholder={t("filters.type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
<SelectItem value="composite">Composite</SelectItem>
|
||||
<SelectItem value="all">{t("filters.typeAll")}</SelectItem>
|
||||
<SelectItem value="single_choice">{t("type.single_choice")}</SelectItem>
|
||||
<SelectItem value="multiple_choice">{t("type.multiple_choice")}</SelectItem>
|
||||
<SelectItem value="judgment">{t("type.judgment")}</SelectItem>
|
||||
<SelectItem value="text">{t("type.text")}</SelectItem>
|
||||
<SelectItem value="composite">{t("type.composite")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Difficulty" />
|
||||
<SelectTrigger className="w-[150px]" aria-label={t("filters.difficulty")}>
|
||||
<SelectValue placeholder={t("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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={knowledgePointId} onValueChange={(val) => setKnowledgePointId(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Knowledge Point" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Knowledge Points</SelectItem>
|
||||
{knowledgePointOptions.map((kp) => {
|
||||
const labelParts = [kp.textbookTitle, kp.chapterTitle, kp.name].filter(Boolean)
|
||||
const label = labelParts.join(" · ")
|
||||
return (
|
||||
<SelectItem key={kp.id} value={kp.id}>
|
||||
{label || kp.name}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
<SelectItem value="all">{t("filters.difficultyAll")}</SelectItem>
|
||||
<SelectItem value="1">{t("filters.difficulty1")}</SelectItem>
|
||||
<SelectItem value="2">{t("filters.difficulty2")}</SelectItem>
|
||||
<SelectItem value="3">{t("filters.difficulty3")}</SelectItem>
|
||||
<SelectItem value="4">{t("filters.difficulty4")}</SelectItem>
|
||||
<SelectItem value="5">{t("filters.difficulty5")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<QuestionCascadeFilter />
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
|
||||
@@ -5,9 +5,15 @@ import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared
|
||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks } from "@/modules/textbooks/data-access";
|
||||
import {
|
||||
getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks,
|
||||
getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks,
|
||||
getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks,
|
||||
getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks,
|
||||
getTextbooks as getTextbooksFromTextbooks,
|
||||
} from "@/modules/textbooks/data-access";
|
||||
import type { CreateQuestionInput } from "./schema";
|
||||
import type { KnowledgePointOption, Question, QuestionType } from "./types";
|
||||
import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types";
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||
|
||||
@@ -24,6 +30,8 @@ export type GetQuestionsParams = {
|
||||
pageSize?: number;
|
||||
ids?: string[];
|
||||
knowledgePointId?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
type?: QuestionType;
|
||||
difficulty?: number;
|
||||
};
|
||||
@@ -34,6 +42,8 @@ export const getQuestions = cache(async ({
|
||||
pageSize = 50,
|
||||
ids,
|
||||
knowledgePointId,
|
||||
textbookId,
|
||||
chapterId,
|
||||
type,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
@@ -62,13 +72,53 @@ export const getQuestions = cache(async ({
|
||||
conditions.push(eq(questions.difficulty, difficulty));
|
||||
}
|
||||
|
||||
// 级联筛选:通过 textbooks data-access 获取知识点 ID 集合,
|
||||
// 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。
|
||||
if (knowledgePointId) {
|
||||
const subQuery = db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
|
||||
|
||||
conditions.push(inArray(questions.id, subQuery));
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId))
|
||||
)
|
||||
);
|
||||
} else if (chapterId) {
|
||||
// 按章节筛选:获取该章节下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 章节下无知识点,返回空结果
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
} else if (textbookId) {
|
||||
// 按教材筛选:获取该教材下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
@@ -276,12 +326,105 @@ export async function deleteQuestionByIdRecursive(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除题目(级联删除子题)。
|
||||
*
|
||||
* 遵循权限范围:非 all 范围只能删除自己创建的题目。
|
||||
* 单事务保证原子性,任一题目删除失败则全部回滚。
|
||||
*
|
||||
* @param questionIds - 待删除的题目 ID 列表
|
||||
* @param canDeleteAll - 是否有全部数据范围权限
|
||||
* @param authorId - 当前用户 ID(用于权限过滤)
|
||||
* @returns 实际删除的题目数量
|
||||
*/
|
||||
export async function deleteQuestionsBatch(
|
||||
questionIds: string[],
|
||||
canDeleteAll: boolean,
|
||||
authorId: string
|
||||
): Promise<number> {
|
||||
if (questionIds.length === 0) return 0
|
||||
|
||||
const uniqueIds = Array.from(new Set(questionIds))
|
||||
return await db.transaction(async (tx) => {
|
||||
// 权限过滤:非 all 范围只能删除自己创建的题目
|
||||
const whereClause = canDeleteAll
|
||||
? inArray(questions.id, uniqueIds)
|
||||
: and(inArray(questions.id, uniqueIds), eq(questions.authorId, authorId))
|
||||
|
||||
const targetRows = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(whereClause)
|
||||
|
||||
const targetIds = targetRows.map((r) => r.id)
|
||||
if (targetIds.length === 0) return 0
|
||||
|
||||
for (const id of targetIds) {
|
||||
await deleteQuestionRecursive(tx, id)
|
||||
}
|
||||
|
||||
return targetIds.length
|
||||
})
|
||||
}
|
||||
|
||||
export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]> {
|
||||
// Delegate to textbooks module data-access to avoid direct queries on
|
||||
// textbooks/chapters/knowledgePoints tables (owned by textbooks module).
|
||||
return await getKnowledgePointOptionsFromTextbooks()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教材选项列表(级联筛选第一级)。
|
||||
*
|
||||
* 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。
|
||||
*/
|
||||
export async function getTextbookOptions(): Promise<TextbookOption[]> {
|
||||
const textbooks = await getTextbooksFromTextbooks()
|
||||
return textbooks.map((tb) => ({
|
||||
id: tb.id,
|
||||
title: tb.title,
|
||||
subject: tb.subject,
|
||||
grade: tb.grade,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定教材下的章节选项列表(级联筛选第二级)。
|
||||
*
|
||||
* 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。
|
||||
*/
|
||||
export async function getChapterOptions(textbookId: string): Promise<ChapterOption[]> {
|
||||
const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId)
|
||||
const options: ChapterOption[] = []
|
||||
|
||||
function flatten(chs: typeof chapters, depth = 0): void {
|
||||
for (const ch of chs) {
|
||||
options.push({
|
||||
id: ch.id,
|
||||
title: ch.title,
|
||||
parentId: ch.parentId ?? null,
|
||||
depth,
|
||||
})
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
flatten(ch.children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(chapters)
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
|
||||
*
|
||||
* 委托 textbooks 模块 data-access 获取知识点列表。
|
||||
*/
|
||||
export async function getKnowledgePointOptionsByChapter(chapterId: string): Promise<{ id: string; name: string }[]> {
|
||||
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
|
||||
return kps.map((kp) => ({ id: kp.id, name: kp.name }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces — read-only access for other modules
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -355,3 +498,136 @@ export const getQuestionsContentForErrorCollection = cache(
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 按 ID 批量获取题目类型映射。
|
||||
*
|
||||
* exams 等模块在组卷/成绩录入时需要题目 type 字段,此接口封装对 questions 表的查询,
|
||||
* 避免跨模块直接 JOIN questions 表(违反三层架构)。
|
||||
*
|
||||
* 返回 Map<questionId, type>,未找到的 ID 不会出现在 Map 中。
|
||||
*/
|
||||
export const getQuestionTypeMapByIds = cache(
|
||||
async (questionIds: string[]): Promise<Map<string, string>> => {
|
||||
const result = new Map<string, string>()
|
||||
const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({ id: questions.id, type: questions.type })
|
||||
.from(questions)
|
||||
.where(inArray(questions.id, uniqueIds))
|
||||
|
||||
for (const r of rows) {
|
||||
result.set(r.id, r.type)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导入/导出接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 导出题目格式(JSON) */
|
||||
export interface QuestionExportItem {
|
||||
id: string
|
||||
type: QuestionType
|
||||
difficulty: number
|
||||
content: unknown
|
||||
knowledgePoints: { id: string; name: string }[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出题目为结构化 JSON 格式。
|
||||
*
|
||||
* 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。
|
||||
* 遵循权限范围:非 all 范围只能导出自己创建的题目。
|
||||
*/
|
||||
export async function exportQuestions(
|
||||
questionIds?: string[],
|
||||
canExportAll = true,
|
||||
authorId?: string
|
||||
): Promise<QuestionExportItem[]> {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (questionIds && questionIds.length > 0) {
|
||||
conditions.push(inArray(questions.id, questionIds))
|
||||
}
|
||||
|
||||
if (!canExportAll && authorId) {
|
||||
conditions.push(eq(questions.authorId, authorId))
|
||||
}
|
||||
|
||||
conditions.push(sql`${questions.parentId} IS NULL`)
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const rows = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: 1000,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
difficulty: row.difficulty ?? 1,
|
||||
content: row.content,
|
||||
knowledgePoints: (row.knowledgePoints ?? []).map((rel) => ({
|
||||
id: rel.knowledgePoint.id,
|
||||
name: rel.knowledgePoint.name,
|
||||
})),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 导入题目的单条输入 */
|
||||
export interface QuestionImportItem {
|
||||
type: QuestionType
|
||||
difficulty: number
|
||||
content: unknown
|
||||
knowledgePointIds?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入题目。
|
||||
*
|
||||
* 单事务保证原子性,任一题目导入失败则全部回滚。
|
||||
* 返回创建的题目 ID 列表。
|
||||
*/
|
||||
export async function importQuestions(
|
||||
items: QuestionImportItem[],
|
||||
authorId: string
|
||||
): Promise<string[]> {
|
||||
if (items.length === 0) return []
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const createdIds: string[] = []
|
||||
for (const item of items) {
|
||||
const id = await insertQuestionWithRelations(
|
||||
tx,
|
||||
{
|
||||
content: item.content,
|
||||
type: item.type,
|
||||
difficulty: item.difficulty,
|
||||
knowledgePointIds: item.knowledgePointIds,
|
||||
},
|
||||
authorId,
|
||||
null
|
||||
)
|
||||
createdIds.push(id)
|
||||
}
|
||||
return createdIds
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from "zod"
|
||||
import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge"
|
||||
import type { StatusVariantMap } from "@/shared/components/ui/status-badge"
|
||||
import { QuestionTypeEnum } from "./schema"
|
||||
|
||||
export type { QuestionContent, QuestionOption } from "./utils/parse-content"
|
||||
|
||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
||||
|
||||
/** 题型 → Badge variant 映射 */
|
||||
@@ -13,13 +15,22 @@ export const QUESTION_TYPE_VARIANT: StatusVariantMap<QuestionType> = {
|
||||
composite: "secondary",
|
||||
}
|
||||
|
||||
/** 题型 → 展示文本映射 */
|
||||
export const QUESTION_TYPE_LABEL: StatusLabelMap<QuestionType> = {
|
||||
single_choice: "Single Choice",
|
||||
multiple_choice: "Multiple Choice",
|
||||
judgment: "True/False",
|
||||
text: "Short Answer",
|
||||
composite: "Composite",
|
||||
/** 题型 → i18n 键映射(供 useTranslations 使用) */
|
||||
export const QUESTION_TYPE_I18N_KEY: Record<QuestionType, string> = {
|
||||
single_choice: "type.single_choice",
|
||||
multiple_choice: "type.multiple_choice",
|
||||
judgment: "type.judgment",
|
||||
text: "type.text",
|
||||
composite: "type.composite",
|
||||
}
|
||||
|
||||
/** 难度 → i18n 键映射 */
|
||||
export const DIFFICULTY_I18N_KEY: Record<number, string> = {
|
||||
1: "difficulty.1",
|
||||
2: "difficulty.2",
|
||||
3: "difficulty.3",
|
||||
4: "difficulty.4",
|
||||
5: "difficulty.5",
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
@@ -51,3 +62,20 @@ export type KnowledgePointOption = {
|
||||
subject: string | null
|
||||
grade: string | null
|
||||
}
|
||||
|
||||
/** 教材选项(级联筛选第一级) */
|
||||
export type TextbookOption = {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
grade: string | null
|
||||
}
|
||||
|
||||
/** 章节选项(级联筛选第二级) */
|
||||
export type ChapterOption = {
|
||||
id: string
|
||||
title: string
|
||||
parentId: string | null
|
||||
/** 层级深度,用于缩进显示 */
|
||||
depth: number
|
||||
}
|
||||
|
||||
128
src/modules/questions/utils/parse-content.ts
Normal file
128
src/modules/questions/utils/parse-content.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 题目内容类型定义与类型守卫。
|
||||
*
|
||||
* 题目 content 存储为 JSON(unknown),实际结构为:
|
||||
* - { text: string } — 简答题/判断题
|
||||
* - { text: string, options: QuestionOption[] } — 选择题
|
||||
* - { text: string, options: QuestionOption[], answer?: string, explanation?: string } — 完整结构
|
||||
*
|
||||
* 所有从 unknown 到具体类型的转换必须使用本文件的类型守卫函数,
|
||||
* 禁止使用 `as` 断言。
|
||||
*/
|
||||
|
||||
/** 选择题选项 */
|
||||
export interface QuestionOption {
|
||||
id: string
|
||||
text: string
|
||||
isCorrect: boolean
|
||||
}
|
||||
|
||||
/** 题目内容结构化类型 */
|
||||
export interface QuestionContent {
|
||||
text: string
|
||||
options?: QuestionOption[]
|
||||
answer?: string
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
/** 类型守卫:判断值是否为字符串 */
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string"
|
||||
}
|
||||
|
||||
/** 类型守卫:判断值是否为非 null 对象 */
|
||||
function isNonNullObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
/** 类型守卫:判断值是否为 QuestionOption */
|
||||
function isQuestionOption(value: unknown): value is QuestionOption {
|
||||
if (!isNonNullObject(value)) return false
|
||||
const id = value.id
|
||||
const text = value.text
|
||||
const isCorrect = value.isCorrect
|
||||
return (
|
||||
(isString(id) || id === undefined) &&
|
||||
isString(text) &&
|
||||
typeof isCorrect === "boolean"
|
||||
)
|
||||
}
|
||||
|
||||
/** 类型守卫:判断值是否为 QuestionOption 数组 */
|
||||
function isQuestionOptionArray(value: unknown): value is QuestionOption[] {
|
||||
return Array.isArray(value) && value.every(isQuestionOption)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 unknown 安全解析为 QuestionContent。
|
||||
*
|
||||
* 处理以下情况:
|
||||
* - 字符串:转为 { text: string }
|
||||
* - 对象:提取 text/options/answer/explanation 字段
|
||||
* - null/undefined:返回 { text: "" }
|
||||
* - 其他:JSON.stringify 后转为 { text }
|
||||
*
|
||||
* @example
|
||||
* const content = parseQuestionContent(question.content)
|
||||
* console.log(content.text) // 题干文本
|
||||
* console.log(content.options) // 选项列表(选择题)
|
||||
*/
|
||||
export function parseQuestionContent(raw: unknown): QuestionContent {
|
||||
if (isString(raw)) {
|
||||
return { text: raw }
|
||||
}
|
||||
|
||||
if (isNonNullObject(raw)) {
|
||||
const text = isString(raw.text) ? raw.text : ""
|
||||
const optionsRaw = raw.options
|
||||
const options = isQuestionOptionArray(optionsRaw)
|
||||
? optionsRaw.map((opt) => ({
|
||||
id: opt.id ?? opt.text,
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect,
|
||||
}))
|
||||
: undefined
|
||||
const answer = isString(raw.answer) ? raw.answer : undefined
|
||||
const explanation = isString(raw.explanation) ? raw.explanation : undefined
|
||||
|
||||
return { text, options, answer, explanation }
|
||||
}
|
||||
|
||||
if (raw == null) {
|
||||
return { text: "" }
|
||||
}
|
||||
|
||||
try {
|
||||
return { text: JSON.stringify(raw) }
|
||||
} catch {
|
||||
return { text: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 QuestionContent 提取纯文本预览(截断到指定长度)。
|
||||
*
|
||||
* @param raw - 原始 content(unknown)
|
||||
* @param maxLength - 最大长度,默认 80
|
||||
*/
|
||||
export function getQuestionPreview(raw: unknown, maxLength = 80): string {
|
||||
const content = parseQuestionContent(raw)
|
||||
return content.text.slice(0, maxLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 QuestionContent 提取选项列表(用于表单回填)。
|
||||
*
|
||||
* @returns 选项数组,无选项时返回 undefined
|
||||
*/
|
||||
export function getQuestionOptions(raw: unknown): QuestionOption[] | undefined {
|
||||
const content = parseQuestionContent(raw)
|
||||
return content.options
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 QuestionContent 提取纯文本(用于 AI 变体生成等场景)。
|
||||
*/
|
||||
export function getQuestionText(raw: unknown): string {
|
||||
return parseQuestionContent(raw).text
|
||||
}
|
||||
90
src/modules/questions/utils/track-event.ts
Normal file
90
src/modules/questions/utils/track-event.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 题库模块监控埋点接口。
|
||||
*
|
||||
* 预留关键操作埋点接口,当前为空实现(no-op)。
|
||||
* 接入监控系统时,替换 `trackQuestionEvent` 的实现即可,
|
||||
* 无需修改调用方代码。
|
||||
*/
|
||||
|
||||
/** 题库操作事件类型 */
|
||||
export type QuestionEventType =
|
||||
| "question:create"
|
||||
| "question:update"
|
||||
| "question:delete"
|
||||
| "question:search"
|
||||
| "question:filter_cascade"
|
||||
|
||||
/** 埋点事件载荷 */
|
||||
export interface QuestionEventPayload {
|
||||
/** 事件类型 */
|
||||
type: QuestionEventType
|
||||
/** 题目 ID(创建/更新/删除时) */
|
||||
questionId?: string
|
||||
/** 题型 */
|
||||
questionType?: string
|
||||
/** 难度 */
|
||||
difficulty?: number
|
||||
/** 搜索关键词(搜索时) */
|
||||
searchQuery?: string
|
||||
/** 筛选条件(筛选时) */
|
||||
filters?: Record<string, unknown>
|
||||
/** 时间戳 */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 埋点函数(no-op 实现)。
|
||||
*
|
||||
* 接入监控系统时替换此函数实现,例如:
|
||||
* ```ts
|
||||
* export function trackQuestionEvent(payload: QuestionEventPayload): void {
|
||||
* analytics.track("question_event", payload)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function trackQuestionEvent(payload: QuestionEventPayload): void {
|
||||
// no-op: 预留接口,接入监控系统时替换实现
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("[trackQuestionEvent]", payload)
|
||||
}
|
||||
}
|
||||
|
||||
/** 便捷方法:记录创建题目事件 */
|
||||
export function trackQuestionCreated(questionId: string, questionType: string, difficulty: number): void {
|
||||
trackQuestionEvent({
|
||||
type: "question:create",
|
||||
questionId,
|
||||
questionType,
|
||||
difficulty,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 便捷方法:记录更新题目事件 */
|
||||
export function trackQuestionUpdated(questionId: string, questionType: string, difficulty: number): void {
|
||||
trackQuestionEvent({
|
||||
type: "question:update",
|
||||
questionId,
|
||||
questionType,
|
||||
difficulty,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 便捷方法:记录删除题目事件 */
|
||||
export function trackQuestionDeleted(questionId: string): void {
|
||||
trackQuestionEvent({
|
||||
type: "question:delete",
|
||||
questionId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** 便捷方法:记录搜索事件 */
|
||||
export function trackQuestionSearched(searchQuery: string): void {
|
||||
trackQuestionEvent({
|
||||
type: "question:search",
|
||||
searchQuery,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user