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:
SpecialX
2026-07-03 10:26:00 +08:00
parent 138b6f1b00
commit f3c223d914
77 changed files with 5397 additions and 2864 deletions

View File

@@ -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)
}
}

View 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>
</>
)
}

View File

@@ -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} />
</>

View File

@@ -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>

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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">

View File

@@ -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 数据与客户端 HookuseQuestionColumns
* useQuestionColumns 内部使用 useTranslations必须在客户端组件中调用。
*/
export function QuestionBankResultsClient({ questions }: QuestionBankResultsClientProps): React.ReactNode {
const columns = useQuestionColumns()
return <QuestionDataTable columns={columns} data={questions} getRowId={(q) => q.id} />
}

View 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>
)
}

View File

@@ -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])
}

View 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.contentunknown解析后渲染
* - 题干文本
* - 选项列表(选择题):单选显示圆点,多选显示方框
* - 正确答案高亮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>
)
}

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -0,0 +1,128 @@
/**
* 题目内容类型定义与类型守卫。
*
* 题目 content 存储为 JSONunknown实际结构为
* - { 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 - 原始 contentunknown
* @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
}

View 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(),
})
}