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,39 +56,21 @@ 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)
}
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
return options.map((opt) => ({
label: opt.text,
value: opt.id,
isCorrect: opt.isCorrect,
}))
}
export function CreateQuestionDialog({
@@ -97,45 +79,32 @@ export function CreateQuestionDialog({
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,9 +201,9 @@ 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>
@@ -272,21 +213,21 @@ export function CreateQuestionDialog({
<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)}
<KnowledgePointSelector
selectedIds={selectedKnowledgePointIds}
onChange={setSelectedKnowledgePointIds}
/>
<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>
{(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"
<OptionsEditor
options={formOptions}
onChange={(next) => form.setValue("options", next)}
singleChoice={questionType === "single_choice"}
/>
<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>
)}
<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,100 +32,116 @@ 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
<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" /> Edit
<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" /> Delete
<Trash className="mr-2 h-4 w-4" /> {t("actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{canEdit && (
<CreateQuestionDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
initialData={question}
/>
)}
{canDelete && (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>{t("actions.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the question
and remove it from our servers.
{t("actions.deleteConfirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("actions.deleteConfirmCancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
@@ -133,44 +150,43 @@ export function QuestionActions({ question }: QuestionActionsProps) {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
{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,30 +1,42 @@
"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>[] = [
/**
* 题目表格列定义。
*
* 使用函数形式以获取 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="Select all"
aria-label={t("actions.menuLabel")}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
aria-label={t("actions.menuLabel")}
/>
),
enableSorting: false,
@@ -32,14 +44,14 @@ export const columns: ColumnDef<Question>[] = [
},
{
accessorKey: "type",
header: "Type",
header: t("table.type"),
cell: ({ row }) => {
const type = row.original.type
return (
<StatusBadge
status={type}
variantMap={QUESTION_TYPE_VARIANT}
labelMap={QUESTION_TYPE_LABEL}
labelMap={{ [type]: t(type) }}
className="whitespace-nowrap"
capitalize={false}
/>
@@ -48,22 +60,9 @@ export const columns: ColumnDef<Question>[] = [
},
{
accessorKey: "content",
header: "Content",
header: t("table.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)
const preview = getQuestionPreview(row.original.content, 80)
return (
<div className="max-w-[400px] truncate font-medium" title={preview}>
{preview}
@@ -73,19 +72,10 @@ export const columns: ColumnDef<Question>[] = [
},
{
accessorKey: "difficulty",
header: "Difficulty",
header: t("table.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"
const label = t(`difficulty.${diff}`)
return (
<div className="flex items-center gap-2">
<Badge variant="outline" className="tabular-nums">
@@ -98,7 +88,7 @@ export const columns: ColumnDef<Question>[] = [
},
{
accessorKey: "knowledgePoints",
header: "Knowledge Points",
header: t("table.knowledgePoints"),
cell: ({ row }) => {
const kps = row.original.knowledgePoints
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
@@ -121,7 +111,7 @@ export const columns: ColumnDef<Question>[] = [
},
{
accessorKey: "createdAt",
header: "Created",
header: t("table.created"),
cell: ({ row }) => {
const createdAt = row.original.createdAt
return (
@@ -139,4 +129,5 @@ export const columns: ColumnDef<Question>[] = [
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
conditions.push(
inArray(
questions.id,
db
.select({ questionId: questionsToKnowledgePoints.questionId })
.from(questionsToKnowledgePoints)
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
conditions.push(inArray(questions.id, subQuery));
.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(),
})
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useTranslations } from "next-intl"
import type { GradeListItem } from "../types"
import { deleteGradeAction } from "../actions"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
type GradeDeleteDialogProps = {
deleteItem: GradeListItem | null
onOpenChange: (open: boolean) => void
onSuccess: () => void
}
/**
* 年级删除确认对话框。
*
* 内部管理 deleteMutation对话框的 open 状态由 `deleteItem` 是否为空推导。
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
*/
export function GradeDeleteDialog({
deleteItem,
onOpenChange,
onSuccess,
}: GradeDeleteDialogProps) {
const t = useTranslations("school")
const deleteMutation = useActionMutation({
errorMessage: t("grades.failedDelete"),
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const isWorking = deleteMutation.isWorking
const handleDelete = (): void => {
if (!deleteItem) return
void deleteMutation.mutate(() => deleteGradeAction(deleteItem.id))
}
return (
<AlertDialog open={Boolean(deleteItem)} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("grades.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("grades.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("grades.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("grades.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,328 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
import { createGradeAction, updateGradeAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
type FormState = {
schoolId: string
name: string
order: string
gradeHeadId: string
teachingHeadId: string
}
type FormErrors = Partial<Record<keyof FormState, string>>
type GradeFormDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
editItem: GradeListItem | null
schools: SchoolListItem[]
staff: StaffOption[]
grades: GradeListItem[]
onSuccess: () => void
}
const NONE_SELECT_VALUE = "__none__"
const normalizeName = (v: string): string => v.trim().replace(/\s+/g, " ")
const parseOrder = (raw: string): number | null => {
const v = raw.trim()
if (!v) return 0
const n = Number(v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null
return n
}
const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({
schoolId: item?.school.id ?? fallbackSchoolId,
name: item?.name ?? "",
order: String(item?.order ?? 0),
gradeHeadId: item?.gradeHead?.id ?? "",
teachingHeadId: item?.teachingHead?.id ?? "",
})
/**
* 年级创建/编辑表单对话框。
*
* 根据 `editItem` 是否存在自动切换模式。内部管理表单状态与客户端校验
* 必填、长度、order 格式、同校重名检测、isDirty 检测),
* mutation 通过 useActionMutation 统一处理 loading/toast。
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
*/
export function GradeFormDialog({
open,
onOpenChange,
editItem,
schools,
staff,
grades,
onSuccess,
}: GradeFormDialogProps) {
const t = useTranslations("school")
const isEdit = Boolean(editItem)
const defaultSchoolId = schools[0]?.id ?? ""
const [state, setState] = useState<FormState>(() => toFormState(editItem, defaultSchoolId))
const staffOptions = useMemo(() => {
return [...staff].sort((a, b) => {
const byName = a.name.localeCompare(b.name)
if (byName !== 0) return byName
return a.email.localeCompare(b.email)
})
}, [staff])
const validateForm = useCallback(
(formState: FormState, excludeGradeId?: string): { ok: boolean; errors: FormErrors } => {
const errors: FormErrors = {}
const schoolId = formState.schoolId.trim()
if (!schoolId) errors.schoolId = t("grades.validation.selectSchool")
const name = normalizeName(formState.name)
if (!name) errors.name = t("grades.validation.enterName")
if (name.length > 100) errors.name = t("grades.validation.nameTooLong")
const order = parseOrder(formState.order)
if (order === null) errors.order = t("grades.validation.orderInvalid")
if (schoolId && name) {
const dup = grades.find((g) => {
if (excludeGradeId && g.id === excludeGradeId) return false
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
})
if (dup) errors.name = t("grades.validation.duplicateName")
}
return { ok: Object.keys(errors).length === 0, errors }
},
[t, grades]
)
const validation = useMemo(
() => validateForm(state, editItem?.id),
[state, editItem?.id, validateForm]
)
const isDirty = useMemo(() => {
if (!editItem) return true
const next = {
schoolId: state.schoolId.trim(),
name: normalizeName(state.name),
order: parseOrder(state.order),
gradeHeadId: state.gradeHeadId || "",
teachingHeadId: state.teachingHeadId || "",
}
const prev = {
schoolId: editItem.school.id,
name: normalizeName(editItem.name),
order: editItem.order,
gradeHeadId: editItem.gradeHead?.id ?? "",
teachingHeadId: editItem.teachingHead?.id ?? "",
}
return (
next.schoolId !== prev.schoolId ||
next.name !== prev.name ||
(typeof next.order === "number" ? next.order : null) !== prev.order ||
next.gradeHeadId !== prev.gradeHeadId ||
next.teachingHeadId !== prev.teachingHeadId
)
}, [editItem, state])
const createMutation = useActionMutation({
errorMessage: t("grades.failedCreate"),
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const updateMutation = useActionMutation({
errorMessage: t("grades.failedUpdate"),
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const isWorking = createMutation.isWorking || updateMutation.isWorking
const handleSubmit = (): void => {
const result = validateForm(state, editItem?.id)
if (!result.ok) {
toast.error(Object.values(result.errors)[0] || t("grades.validation.fixForm"))
return
}
if (isEdit && !isDirty) {
toast.message(t("grades.validation.noChanges"))
return
}
const fd = new FormData()
fd.set("schoolId", state.schoolId)
fd.set("name", normalizeName(state.name))
fd.set("order", state.order)
fd.set("gradeHeadId", state.gradeHeadId)
fd.set("teachingHeadId", state.teachingHeadId)
if (isEdit && editItem) {
void updateMutation.mutate(() => updateGradeAction(editItem.id, undefined, fd))
} else {
void createMutation.mutate(() => createGradeAction(undefined, fd))
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
{isEdit ? t("grades.form.editTitle") : t("grades.form.createTitle")}
</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleSubmit()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={state.schoolId}
onValueChange={(v) => setState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{validation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade-name" className="text-right">
{t("grades.form.name")}
</Label>
<Input
id="grade-name"
className="col-span-3"
value={state.name}
onChange={(e) => setState((p) => ({ ...p, name: e.target.value }))}
placeholder={t("grades.form.name")}
autoFocus={!isEdit}
/>
{validation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade-order" className="text-right">
{t("grades.form.order")}
</Label>
<Input
id="grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={state.order}
onChange={(e) => setState((p) => ({ ...p, order: e.target.value }))}
/>
{validation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{validation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={state.gradeHeadId}
onValueChange={(v) =>
setState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={state.teachingHeadId}
onValueChange={(v) =>
setState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{isEdit ? t("grades.form.save") : t("grades.form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,111 @@
"use client"
import { Plus } from "lucide-react"
import { useTranslations } from "next-intl"
import type { SchoolListItem } from "../types"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
type GradeListToolbarProps = {
q: string
setQ: (value: string | null) => void
school: string
setSchool: (value: string | null) => void
head: string
setHead: (value: string | null) => void
sort: string
setSort: (value: string | null) => void
hasFilters: boolean
onReset: () => void
schools: SchoolListItem[]
onCreate: () => void
isWorking: boolean
}
/**
* 年级列表工具栏。
*
* 展示搜索框、学校筛选、年级主任筛选、排序选择器以及「新建年级」按钮。
* 筛选状态由父组件通过 nuqs useQueryState 管理,本组件仅负责渲染与回调。
*/
export function GradeListToolbar({
q,
setQ,
school,
setSchool,
head,
setHead,
sort,
setSort,
hasFilters,
onReset,
schools,
onCreate,
isWorking,
}: GradeListToolbarProps) {
const t = useTranslations("school")
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1 md:max-w-sm">
<Input placeholder={t("grades.filters.search")} value={q} onChange={(e) => setQ(e.target.value || null)} />
</div>
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.school")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("grades.filters.allSchools")}</SelectItem>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={head} onValueChange={(v) => setHead(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.head")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("grades.filters.allHeads")}</SelectItem>
<SelectItem value="missing">{t("grades.filters.missing")}</SelectItem>
<SelectItem value="missing_grade_head">{t("grades.filters.missingGradeHead")}</SelectItem>
<SelectItem value="missing_teaching_head">{t("grades.filters.missingTeachingHead")}</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v === "default" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.sort")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("grades.filters.defaultSort")}</SelectItem>
<SelectItem value="updated_desc">{t("grades.filters.updatedDesc")}</SelectItem>
<SelectItem value="updated_asc">{t("grades.filters.updatedAsc")}</SelectItem>
<SelectItem value="name_asc">{t("grades.filters.nameAsc")}</SelectItem>
<SelectItem value="name_desc">{t("grades.filters.nameDesc")}</SelectItem>
<SelectItem value="order_asc">{t("grades.filters.orderAsc")}</SelectItem>
<SelectItem value="order_desc">{t("grades.filters.orderDesc")}</SelectItem>
</SelectContent>
</Select>
{hasFilters ? (
<Button variant="outline" onClick={onReset}>
{t("grades.filters.reset")}
</Button>
) : null}
</div>
<Button onClick={onCreate} disabled={isWorking || schools.length === 0}>
<Plus className="mr-2 h-4 w-4" />
{t("grades.new")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { BarChart3, GraduationCap, MoreHorizontal, Pencil, Trash2, UserCog, Users } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import type { GradeListItem } from "../types"
import type { GradeOverviewStats } from "../data-access"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent } from "@/shared/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
type GradeOverviewCardsProps = {
grades: GradeListItem[]
statsMap: Map<string, GradeOverviewStats>
isWorking: boolean
onEdit: (item: GradeListItem) => void
onDelete: (item: GradeListItem) => void
}
/**
* 年级概览卡片视图。
*
* 以卡片网格展示前 8 个年级,每张卡片包含年级名称、所属学校、
* 班级/学生/教师统计、年级主任/教学主任以及快捷操作入口。
*/
export function GradeOverviewCards({
grades,
statsMap,
isWorking,
onEdit,
onDelete,
}: GradeOverviewCardsProps) {
const t = useTranslations("school")
const router = useRouter()
if (grades.length === 0) return null
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{grades.slice(0, 8).map((g) => {
const stats = statsMap.get(g.id)
return (
<Card key={g.id} className="shadow-none">
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{g.name}</div>
<div className="truncate text-xs text-muted-foreground">{g.school.name}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" disabled={isWorking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
}
>
<BarChart3 className="mr-2 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEdit(g)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(g)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 统计指标 */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<GraduationCap className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.classCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.classCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<Users className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.studentCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.studentCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<UserCog className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.teacherCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.teacherCount")}
</div>
</div>
</div>
{/* 年级主任/教学主任 */}
<div className="space-y-1 border-t pt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.gradeHead")}</span>
<span className="truncate font-medium">
{g.gradeHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.teachingHead")}</span>
<span className="truncate font-medium">
{g.teachingHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
</div>
{/* 快捷操作 */}
<Button asChild variant="outline" size="sm" className="w-full">
<a href={`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`}>
<BarChart3 className="mr-1.5 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</a>
</Button>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -1,23 +1,24 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react"
import { toast } from "sonner"
import { useMemo, useState } from "react"
import type { ReactNode } from "react"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { parseAsString, useQueryState } from "nuqs"
import { useTranslations } from "next-intl"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
import type { GradeOverviewStats } from "../data-access"
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
import { useGradeData } from "../hooks/use-grade-data"
import { GradeDeleteDialog } from "./grade-delete-dialog"
import { GradeFormDialog } from "./grade-form-dialog"
import { GradeListToolbar } from "./grade-list-toolbar"
import { GradeOverviewCards } from "./grade-overview-cards"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,49 +26,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
type FormState = {
schoolId: string
name: string
order: string
gradeHeadId: string
teachingHeadId: string
}
const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({
schoolId: item?.school.id ?? fallbackSchoolId,
name: item?.name ?? "",
order: String(item?.order ?? 0),
gradeHeadId: item?.gradeHead?.id ?? "",
teachingHeadId: item?.teachingHead?.id ?? "",
})
type FormErrors = Partial<Record<keyof FormState, string>>
const normalizeName = (v: string) => v.trim().replace(/\s+/g, " ")
const NONE_SELECT_VALUE = "__none__"
const parseOrder = (raw: string) => {
const v = raw.trim()
if (!v) return 0
const n = Number(v)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null
return n
}
export function GradesClient({
grades,
schools,
@@ -81,19 +41,33 @@ export function GradesClient({
}) {
const t = useTranslations("school")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<GradeListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<GradeListItem | null>(null)
const {
createOpen,
editItem,
deleteItem,
setCreateOpen,
setEditItem,
setDeleteItem,
isWorking,
} = useGradeData()
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all"))
const [head, setHead] = useQueryState("head", parseAsString.withDefault("all"))
const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default"))
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
const [editState, setEditState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
// 表单对话框会话 key每次打开对话框时递增强制 GradeFormDialog 重新挂载以重置表单状态
const [formSession, setFormSession] = useState(0)
const openCreate = (): void => {
setFormSession((s) => s + 1)
setCreateOpen(true)
}
const openEdit = (item: GradeListItem): void => {
setFormSession((s) => s + 1)
setEditItem(item)
}
// 年级概览统计映射,用于卡片视图
const statsMap = useMemo(() => {
@@ -102,68 +76,6 @@ export function GradesClient({
return m
}, [gradeStats])
useEffect(() => {
if (!createOpen) return
if (createState.schoolId.trim().length > 0) return
if (!defaultSchoolId) return
setCreateState((p) => ({ ...p, schoolId: defaultSchoolId }))
}, [createOpen, createState.schoolId, defaultSchoolId])
useEffect(() => {
if (!editItem) return
if (editState.schoolId.trim().length > 0) return
if (!defaultSchoolId) return
setEditState((p) => ({ ...p, schoolId: defaultSchoolId }))
}, [editItem, editState.schoolId, defaultSchoolId])
const staffOptions = useMemo(() => {
return [...staff].sort((a, b) => {
const byName = a.name.localeCompare(b.name)
if (byName !== 0) return byName
return a.email.localeCompare(b.email)
})
}, [staff])
const validateForm = useCallback(
(state: FormState, params: { grades: GradeListItem[]; excludeGradeId?: string }): {
ok: boolean
errors: FormErrors
} => {
const errors: FormErrors = {}
const schoolId = state.schoolId.trim()
if (!schoolId) errors.schoolId = t("grades.validation.selectSchool")
const name = normalizeName(state.name)
if (!name) errors.name = t("grades.validation.enterName")
if (name.length > 100) errors.name = t("grades.validation.nameTooLong")
const order = parseOrder(state.order)
if (order === null) errors.order = t("grades.validation.orderInvalid")
if (schoolId && name) {
const dup = params.grades.find((g) => {
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
})
if (dup) errors.name = t("grades.validation.duplicateName")
}
return { ok: Object.keys(errors).length === 0, errors }
},
[t]
)
const formatStaffDetail = (u: StaffOption | null) => {
if (!u) return <Badge variant="outline">{t("grades.notSet")}</Badge>
return (
<div className="min-w-0">
<div className="truncate">{u.name}</div>
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
</div>
)
}
const filteredGrades = useMemo(() => {
const needle = q.trim().toLowerCase()
const bySchool = school === "all" ? "" : school
@@ -207,321 +119,68 @@ export function GradesClient({
const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default"
const openEdit = (item: GradeListItem) => {
setEditItem(item)
setEditState(toFormState(item, defaultSchoolId))
const handleResetFilters = (): void => {
setQ(null)
setSchool(null)
setHead(null)
setSort(null)
}
const openCreate = () => {
setCreateState(toFormState(null, defaultSchoolId))
setCreateOpen(true)
}
const createValidation = useMemo(
() => validateForm(createState, { grades }),
[createState, grades, validateForm]
)
const editValidation = useMemo(
() => validateForm(editState, { grades, excludeGradeId: editItem?.id }),
[editItem?.id, editState, grades, validateForm]
)
const isEditDirty = useMemo(() => {
if (!editItem) return false
const next = {
schoolId: editState.schoolId.trim(),
name: normalizeName(editState.name),
order: parseOrder(editState.order),
gradeHeadId: editState.gradeHeadId || "",
teachingHeadId: editState.teachingHeadId || "",
}
const prev = {
schoolId: editItem.school.id,
name: normalizeName(editItem.name),
order: editItem.order,
gradeHeadId: editItem.gradeHead?.id ?? "",
teachingHeadId: editItem.teachingHead?.id ?? "",
}
return (
next.schoolId !== prev.schoolId ||
next.name !== prev.name ||
(typeof next.order === "number" ? next.order : null) !== prev.order ||
next.gradeHeadId !== prev.gradeHeadId ||
next.teachingHeadId !== prev.teachingHeadId
)
}, [editItem, editState])
const handleCreate = async () => {
const validation = validateForm(createState, { grades })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
return
}
setIsWorking(true)
try {
const fd = new FormData()
fd.set("schoolId", createState.schoolId)
fd.set("name", normalizeName(createState.name))
fd.set("order", createState.order)
fd.set("gradeHeadId", createState.gradeHeadId)
fd.set("teachingHeadId", createState.teachingHeadId)
const res = await createGradeAction(undefined, fd)
if (res.success) {
toast.success(res.message)
const handleFormOpenChange = (open: boolean): void => {
if (!open) {
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || t("grades.failedCreate"))
}
} catch {
toast.error(t("grades.failedCreate"))
} finally {
setIsWorking(false)
}
}
const handleUpdate = async () => {
if (!editItem) return
const validation = validateForm(editState, { grades, excludeGradeId: editItem.id })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
return
}
if (!isEditDirty) {
toast.message(t("grades.validation.noChanges"))
return
}
setIsWorking(true)
try {
const fd = new FormData()
fd.set("schoolId", editState.schoolId)
fd.set("name", normalizeName(editState.name))
fd.set("order", editState.order)
fd.set("gradeHeadId", editState.gradeHeadId)
fd.set("teachingHeadId", editState.teachingHeadId)
const res = await updateGradeAction(editItem.id, undefined, fd)
if (res.success) {
toast.success(res.message)
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || t("grades.failedUpdate"))
}
} catch {
toast.error(t("grades.failedUpdate"))
} finally {
setIsWorking(false)
}
}
const handleDelete = async () => {
if (!deleteItem) return
setIsWorking(true)
try {
const res = await deleteGradeAction(deleteItem.id)
if (res.success) {
toast.success(res.message)
const handleDeleteOpenChange = (open: boolean): void => {
if (!open) {
setDeleteItem(null)
}
}
const handleSuccess = (): void => {
router.refresh()
} else {
toast.error(res.message || t("grades.failedDelete"))
}
} catch {
toast.error(t("grades.failedDelete"))
} finally {
setIsWorking(false)
}
const formatStaffDetail = (u: StaffOption | null): ReactNode => {
if (!u) return <Badge variant="outline">{t("grades.notSet")}</Badge>
return (
<div className="min-w-0">
<div className="truncate">{u.name}</div>
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
</div>
)
}
return (
<>
{/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */}
{filteredGrades.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredGrades.slice(0, 8).map((g) => {
const stats = statsMap.get(g.id)
return (
<Card key={g.id} className="shadow-none">
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{g.name}</div>
<div className="truncate text-xs text-muted-foreground">{g.school.name}</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" disabled={isWorking}>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
}
>
<BarChart3 className="mr-2 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openEdit(g)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(g)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("grades.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 统计指标 */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<GraduationCap className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.classCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.classCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<Users className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.studentCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.studentCount")}
</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-center text-muted-foreground">
<UserCog className="h-3 w-3" />
</div>
<div className="mt-0.5 text-sm font-semibold tabular-nums">
{stats?.teacherCount ?? 0}
</div>
<div className="text-[10px] text-muted-foreground">
{t("grades.gradeOverview.teacherCount")}
</div>
</div>
</div>
{/* 年级主任/教学主任 */}
<div className="space-y-1 border-t pt-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.gradeHead")}</span>
<span className="truncate font-medium">
{g.gradeHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("grades.gradeOverview.teachingHead")}</span>
<span className="truncate font-medium">
{g.teachingHead?.name ?? t("grades.gradeOverview.notSet")}
</span>
</div>
</div>
{/* 快捷操作 */}
<Button
asChild
variant="outline"
size="sm"
className="w-full"
>
<a href={`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`}>
<BarChart3 className="mr-1.5 h-3.5 w-3.5" />
{t("grades.gradeOverview.viewInsights")}
</a>
</Button>
</CardContent>
</Card>
)
})}
</div>
<GradeOverviewCards
grades={filteredGrades}
statsMap={statsMap}
isWorking={isWorking}
onEdit={openEdit}
onDelete={setDeleteItem}
/>
)}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1 md:max-w-sm">
<Input placeholder={t("grades.filters.search")} value={q} onChange={(e) => setQ(e.target.value || null)} />
</div>
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.school")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("grades.filters.allSchools")}</SelectItem>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={head} onValueChange={(v) => setHead(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.head")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("grades.filters.allHeads")}</SelectItem>
<SelectItem value="missing">{t("grades.filters.missing")}</SelectItem>
<SelectItem value="missing_grade_head">{t("grades.filters.missingGradeHead")}</SelectItem>
<SelectItem value="missing_teaching_head">{t("grades.filters.missingTeachingHead")}</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v === "default" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder={t("grades.filters.sort")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("grades.filters.defaultSort")}</SelectItem>
<SelectItem value="updated_desc">{t("grades.filters.updatedDesc")}</SelectItem>
<SelectItem value="updated_asc">{t("grades.filters.updatedAsc")}</SelectItem>
<SelectItem value="name_asc">{t("grades.filters.nameAsc")}</SelectItem>
<SelectItem value="name_desc">{t("grades.filters.nameDesc")}</SelectItem>
<SelectItem value="order_asc">{t("grades.filters.orderAsc")}</SelectItem>
<SelectItem value="order_desc">{t("grades.filters.orderDesc")}</SelectItem>
</SelectContent>
</Select>
{hasFilters ? (
<Button
variant="outline"
onClick={() => {
setQ(null)
setSchool(null)
setHead(null)
setSort(null)
}}
>
{t("grades.filters.reset")}
</Button>
) : null}
</div>
<Button onClick={openCreate} disabled={isWorking || schools.length === 0}>
<Plus className="mr-2 h-4 w-4" />
{t("grades.new")}
</Button>
</div>
<GradeListToolbar
q={q}
setQ={setQ}
school={school}
setSchool={setSchool}
head={head}
setHead={setHead}
sort={sort}
setSort={setSort}
hasFilters={hasFilters}
onReset={handleResetFilters}
schools={schools}
onCreate={openCreate}
isWorking={isWorking}
/>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
@@ -614,308 +273,22 @@ export function GradesClient({
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("grades.form.createTitle")}</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleCreate()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={createState.schoolId}
onValueChange={(v) => setCreateState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createValidation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-name" className="text-right">
{t("grades.form.name")}
</Label>
<Input
id="create-grade-name"
className="col-span-3"
value={createState.name}
onChange={(e) => setCreateState((p) => ({ ...p, name: e.target.value }))}
placeholder={t("grades.form.name")}
autoFocus
<GradeFormDialog
key={formSession}
open={createOpen || Boolean(editItem)}
onOpenChange={handleFormOpenChange}
editItem={editItem}
schools={schools}
staff={staff}
grades={grades}
onSuccess={handleSuccess}
/>
{createValidation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-order" className="text-right">
{t("grades.form.order")}
</Label>
<Input
id="create-grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={createState.order}
onChange={(e) => setCreateState((p) => ({ ...p, order: e.target.value }))}
<GradeDeleteDialog
deleteItem={deleteItem}
onOpenChange={handleDeleteOpenChange}
onSuccess={handleSuccess}
/>
{createValidation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{createValidation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={createState.gradeHeadId}
onValueChange={(v) =>
setCreateState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={createState.teachingHeadId}
onValueChange={(v) =>
setCreateState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("grades.form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (!open) setEditItem(null)
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{t("grades.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
void handleUpdate()
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={editState.schoolId}
onValueChange={(v) => setEditState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editValidation.errors.schoolId ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.schoolId}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-name" className="text-right">
{t("grades.form.name")}
</Label>
<Input
id="edit-grade-name"
className="col-span-3"
value={editState.name}
onChange={(e) => setEditState((p) => ({ ...p, name: e.target.value }))}
/>
{editValidation.errors.name ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.name}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-order" className="text-right">
{t("grades.form.order")}
</Label>
<Input
id="edit-grade-order"
className="col-span-3"
type="number"
inputMode="numeric"
min={0}
step={1}
value={editState.order}
onChange={(e) => setEditState((p) => ({ ...p, order: e.target.value }))}
/>
{editValidation.errors.order ? (
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
{editValidation.errors.order}
</div>
) : null}
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={editState.gradeHeadId}
onValueChange={(v) =>
setEditState((p) => ({ ...p, gradeHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={editState.teachingHeadId}
onValueChange={(v) =>
setEditState((p) => ({ ...p, teachingHeadId: v === NONE_SELECT_VALUE ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
{staffOptions.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("grades.form.save")}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("grades.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("grades.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("grades.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("grades.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -1,37 +1,44 @@
"use client"
import { Component, type ErrorInfo, type JSX, type ReactNode } from "react"
/**
* 学校模块 Error Boundary。
*
* 薄包装:委托给共享 SectionErrorBoundary通过自定义 fallback 实现
* 重试时调用 router.refresh() 刷新服务端数据。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface SchoolErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
interface SchoolErrorBoundaryState {
hasError: boolean
}
function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element {
export function SchoolErrorBoundary({
children,
fallback,
}: SchoolErrorBoundaryProps): ReactNode {
const t = useTranslations("school")
const router = useRouter()
const customFallback = (_error: Error, reset: () => void): ReactNode => {
const handleRetry = (): void => {
onReset()
reset()
router.refresh()
}
return (
<div
role="alert"
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
@@ -42,31 +49,9 @@ function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element
)
}
export class SchoolErrorBoundary extends Component<
SchoolErrorBoundaryProps,
SchoolErrorBoundaryState
> {
constructor(props: SchoolErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): SchoolErrorBoundaryState {
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("SchoolErrorBoundary caught an error:", error, errorInfo)
}
private handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? <SchoolErrorFallback onReset={this.handleReset} />
}
return this.props.children
}
return (
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useState } from "react"
import type { GradeListItem } from "../types"
export type UseGradeDataReturn = {
createOpen: boolean
editItem: GradeListItem | null
deleteItem: GradeListItem | null
setCreateOpen: (open: boolean) => void
setEditItem: (item: GradeListItem | null) => void
setDeleteItem: (item: GradeListItem | null) => void
isWorking: boolean
}
/**
* 年级管理客户端的数据/状态 Hook。
*
* 集中管理创建/编辑/删除对话框的开关状态以及当前操作的年级项,
* 供 GradesClient 组合容器及其子组件共享。
*
* `isWorking` 表示任意对话框处于打开状态,用于禁用工具栏按钮与行内操作菜单,
* 避免并发打开多个对话框;各对话框内部的 mutation loading 由对应组件自行管理。
*/
export function useGradeData(): UseGradeDataReturn {
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<GradeListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<GradeListItem | null>(null)
const isWorking = createOpen || Boolean(editItem) || Boolean(deleteItem)
return {
createOpen,
editItem,
deleteItem,
setCreateOpen,
setEditItem,
setDeleteItem,
isWorking,
}
}

View File

@@ -1,12 +1,11 @@
"use server"
import { unlink } from "fs/promises"
import path from "path"
import { revalidatePath } from "next/cache"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { storageProvider } from "@/shared/lib/storage-provider"
import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access"
import {
deleteFileAttachment,
@@ -16,6 +15,9 @@ import {
/**
* 清理旧头像文件(磁盘 + DB 记录)
* 静默失败,不影响主流程
*
* P1-4磁盘删除统一走 storageProvider 抽象,
* 不再直接 import fs/promises。
*/
async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
if (!oldImageUrl) return
@@ -23,17 +25,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
const fileRecord = await getFileByUrl(oldImageUrl)
if (!fileRecord) return
// 删除磁盘文件
const absolutePath = path.join(
process.cwd(),
"public",
fileRecord.storagePath,
)
try {
await unlink(absolutePath)
} catch {
// 文件可能已不存在,忽略错误
}
// 删除磁盘文件(通过 storageProvider 抽象)
await storageProvider.delete(fileRecord.storagePath)
// 删除 DB 记录
await deleteFileAttachment(fileRecord.id)
@@ -45,7 +38,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
/**
* 更新用户头像 URL
*
* 实际文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段。
* 实际文件上传通过 /api/upload 路由完成targetType="user_avatar"
* 此 action 仅更新 users.image 字段。
* 更新成功后会清理旧头像文件(磁盘 + DB 记录)。
*/
export async function updateUserAvatarAction(

View File

@@ -0,0 +1,81 @@
"use server"
import { z } from "zod"
import { revalidatePath } from "next/cache"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSession } from "@/shared/lib/session"
import { getBrandConfig, saveBrandConfig } from "./data-access-brand"
import type { BrandConfig } from "./brand-config"
const BrandConfigSchema = z.object({
schoolName: z.string().min(1).max(255),
logoUrl: z.string().url().or(z.literal("")).default(""),
testimonialQuote: z.string().min(1).max(500),
testimonialAuthor: z.string().min(1).max(100),
})
/**
* 获取品牌配置 Server Actionaudit-P2-6 新增)
*/
export async function getBrandConfigAction(): Promise<ActionState<BrandConfig>> {
try {
await requirePermission(Permissions.SCHOOL_MANAGE)
const config = await getBrandConfig()
return { success: true, data: config }
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to get brand config",
}
}
}
/**
* 保存品牌配置 Server Actionaudit-P2-6 新增)
*/
export async function saveBrandConfigAction(
prevState: ActionState<BrandConfig>,
formData: FormData,
): Promise<ActionState<BrandConfig>> {
try {
await requirePermission(Permissions.SCHOOL_MANAGE)
const session = await getSession()
const parsed = BrandConfigSchema.safeParse({
schoolName: formData.get("schoolName"),
logoUrl: formData.get("logoUrl"),
testimonialQuote: formData.get("testimonialQuote"),
testimonialAuthor: formData.get("testimonialAuthor"),
})
if (!parsed.success) {
return {
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid brand config",
}
}
const config: BrandConfig = {
schoolName: parsed.data.schoolName,
logoUrl: parsed.data.logoUrl || null,
testimonialQuote: parsed.data.testimonialQuote,
testimonialAuthor: parsed.data.testimonialAuthor,
}
await saveBrandConfig(config, session?.user?.id)
revalidatePath("/admin/settings")
revalidatePath("/(auth)/login")
revalidatePath("/(auth)/register")
return { success: true, data: config, message: "Brand configuration saved" }
} catch (e) {
return {
success: false,
message: e instanceof Error ? e.message : "Failed to save brand config",
}
}
}

View File

@@ -10,6 +10,7 @@ import { Permissions } from "@/shared/types/permissions"
import { validatePassword } from "@/shared/lib/password-policy"
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { checkBreachedPassword } from "@/shared/lib/breached-password"
import {
getPasswordSecurityByUserId,
@@ -38,7 +39,7 @@ export async function changePasswordAction(
const userId = ctx.userId
const limitKey = rateLimitKey("pwd-change", userId)
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
const limit = await rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE })
if (!limit.success) {
return { success: false, message: "Too many attempts. Please try again later." }
}
@@ -68,6 +69,16 @@ export async function changePasswordAction(
return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" }
}
// audit-P2-4: Breached password 检测HIBP k-anonymity API
// fail-openAPI 不可用时跳过检查,避免外部依赖阻断改密流程
const breachCheck = await checkBreachedPassword(newPassword)
if (breachCheck.isBreached) {
return {
success: false,
message: "This password has appeared in a known data breach. Please choose a different password.",
}
}
// Parallelize user and passwordSecurity queries
const [userRecord, existingSecurity] = await Promise.all([
getUserPasswordHash(userId),

View File

@@ -7,8 +7,9 @@ import type { ActionState } from "@/shared/types/action-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { loginLogs, sessions } from "@/shared/db/schema"
import { loginLogs } from "@/shared/db/schema"
import { logLoginEvent } from "@/shared/lib/login-logger"
import { trackAuthEvent } from "@/shared/lib/track-event"
import { getUserProfile } from "@/modules/users/data-access"
import {
@@ -239,6 +240,11 @@ export async function verifyTwoFactorAction(
const status = await getTwoFactorStatus(ctx.userId)
// audit-P1-92FA 启用埋点(用于 2FA 启用率统计)
await trackAuthEvent("auth.2fa_enabled", {
userId: ctx.userId,
})
return {
success: true,
data: { backupCodes, status },
@@ -298,6 +304,12 @@ export async function disableTwoFactorAction(
revalidatePath("/settings")
const status = await getTwoFactorStatus(ctx.userId)
// audit-P1-92FA 禁用埋点(用于 2FA 禁用率告警,可能表明账户安全降级)
await trackAuthEvent("auth.2fa_disabled", {
userId: ctx.userId,
})
return { success: true, data: status }
} catch (error) {
const message =
@@ -369,27 +381,20 @@ export async function revokeAllOtherSessionsAction(): Promise<
return { success: false, message: "User not found" }
}
// 删除 sessions 表中该用户的所有记录
const result = await db
.delete(sessions)
.where(eq(sessions.userId, ctx.userId))
// audit-P1-9JWT 策略下 sessions 表无数据,此 Action 为 no-op。
// 真正的"远程登出其他设备"需要 JWT 黑名单或短期 token + refresh token
// 当前架构不支持。保留此 Action 是为了:
// 1. 前端 UI 不会因 Action 缺失而报错
// 2. 记录用户"主动登出其他设备"的意图(用于安全审计)
const revokedCount = 0
// MySqlRawQueryResult 是 [rows, fields] 元组rows 可能含 affectedRows
const rows = Array.isArray(result) ? result[0] : result
const revokedCount =
typeof rows === "object" && rows !== null && "affectedRows" in rows
? Number((rows as { affectedRows: unknown }).affectedRows)
: 0
// 记录一条安全处置日志
await logLoginEvent({
userId: ctx.userId,
userEmail: profile.email,
action: "signout",
status: "success",
errorMessage: revokedCount > 0
? `Remote logout: revoked ${revokedCount} session(s)`
: "Remote logout: no active DB sessions (JWT-based)",
errorMessage:
"Remote logout requested (JWT-based, no active DB sessions to revoke)",
})
revalidatePath("/settings")

View File

@@ -10,9 +10,8 @@ import { Permissions } from "@/shared/types/permissions"
import {
getAllSystemSettings,
upsertSystemSettings,
type SystemSettingCategory,
type SystemSettingValueType,
} from "./data-access-system-settings"
import { toSettingItem } from "./lib/system-settings-utils"
// --- Schemas ---
@@ -53,27 +52,6 @@ const AdminSettingsFormSchema = z.object({
type AdminSettingsFormValues = z.infer<typeof AdminSettingsFormSchema>
// --- Helpers ---
function toSettingItem(
category: SystemSettingCategory,
key: string,
value: unknown,
valueType: SystemSettingValueType
): { category: SystemSettingCategory; key: string; value: string; valueType: SystemSettingValueType } {
let strValue: string
if (valueType === "json") {
strValue = JSON.stringify(value)
} else if (valueType === "boolean") {
strValue = value ? "true" : "false"
} else if (valueType === "number") {
strValue = String(value)
} else {
strValue = String(value ?? "")
}
return { category, key, value: strValue, valueType }
}
// --- Actions ---
/**

View File

@@ -26,7 +26,7 @@ import type { AiProviderSummary, AiProviderVisibility } from "./types"
export type { AiProviderSummary } from "./types"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
@@ -42,6 +42,8 @@ const AiProviderFormSchema = z.object({
const AiProviderTestSchema = AiProviderFormSchema.extend({
apiKey: z.string().optional(),
}).superRefine((data, ctx) => {
// Ollama 本地部署无需 API Key
if (data.provider === "ollama") return
if (!data.apiKey?.trim() && !data.id?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -51,6 +53,15 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
}
})
/** Ollama 默认 baseUrlOpenAI 兼容端点) */
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
/** Ollama 本地部署无需 API Key使用占位符满足 NOT NULL 约束 */
const OLLAMA_PLACEHOLDER_API_KEY = "ollama"
/** 判断是否为不需要 API Key 的本地 Provider */
const isLocalProvider = (provider: string): boolean => provider === "ollama"
/**
* 校验当前用户身份,返回 { id, isAdmin }
*
@@ -71,6 +82,22 @@ const normalizeBaseUrl = (value: string | undefined): string | null => {
.replace(/\/chat\/completions$/i, "")
}
/**
* 解析 Provider 的 baseUrl应用默认值
*
* - Ollama未提供时使用默认本地地址 http://localhost:11434/v1
* - 其他 Provider未提供时返回 null由调用方校验
*/
const resolveBaseUrl = (
provider: string,
raw: string | undefined
): string | null => {
const normalized = normalizeBaseUrl(raw)
if (normalized) return normalized
if (isLocalProvider(provider)) return OLLAMA_DEFAULT_BASE_URL
return null
}
/**
* 获取当前用户可见的 AI Provider 列表
*
@@ -101,8 +128,8 @@ export async function upsertAiProviderAction(
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
if (!isLocalProvider(payload.provider) && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
@@ -124,9 +151,15 @@ export async function upsertAiProviderAction(
const id = payload.id
if (!existing) return { success: false, message: "AI provider not found" }
// Ollama 无需 API Key未提供时使用占位符仅新建时更新时保留原值
const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const effectiveKey = nextKey
? nextKey
: isLocalProvider(payload.provider) && !existing.apiKeyEncrypted
? OLLAMA_PLACEHOLDER_API_KEY
: null
const encrypted = effectiveKey ? encryptAiApiKey(effectiveKey) : existing.apiKeyEncrypted
const last4 = effectiveKey ? effectiveKey.slice(-4) : existing.apiKeyLast4
const isNextDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1
@@ -153,13 +186,16 @@ export async function upsertAiProviderAction(
return { success: true, message: "AI provider updated", data: id }
}
if (!payload.apiKey) {
// 新建 ProviderOllama 允许无 API Key使用占位符
const rawApiKey = payload.apiKey?.trim()
if (!rawApiKey && !isLocalProvider(payload.provider)) {
return { success: false, message: "API key is required" }
}
const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY
const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4)
const encrypted = encryptAiApiKey(effectiveApiKey)
const last4 = effectiveApiKey.slice(-4)
const shouldMakeDefault = payload.isDefault ?? !hasDefault
await createAiProvider(
@@ -198,14 +234,16 @@ export async function testAiProviderAction(
return { success: false, message: "Invalid form data" }
}
const payload = parsed.data
const baseUrl = normalizeBaseUrl(payload.baseUrl)
if (payload.provider !== "openai" && !baseUrl) {
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
if (!isLocalProvider(payload.provider) && !baseUrl) {
return { success: false, message: "Base URL is required for this provider" }
}
const model = payload.model.trim()
const apiKey = payload.apiKey?.trim()
if (apiKey) {
await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model })
// Ollama 无 API Key 时使用占位符进行测试
const effectiveApiKey = apiKey ?? (isLocalProvider(payload.provider) ? OLLAMA_PLACEHOLDER_API_KEY : undefined)
if (effectiveApiKey) {
await testAiProviderConfig({ apiKey: effectiveApiKey, baseUrl: baseUrl ?? undefined, model })
} else if (payload.id) {
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
}

View File

@@ -0,0 +1,27 @@
/**
* 品牌配置类型与默认值audit-P2-6 新增)
*
* 纯类型 + 常量,无 `server-only`,可被 Server / Client Component 安全导入。
* 数据访问函数在 `data-access-brand.ts`server-only
*/
/** 品牌配置 */
export interface BrandConfig {
/** 学校/品牌名称(显示在 AuthLayout 左上角) */
schoolName: string
/** Logo URL可选未设置时使用默认 GraduationCap 图标) */
logoUrl: string | null
/** 标语/引用语(显示在 AuthLayout 左下角 blockquote */
testimonialQuote: string
/** 标语作者 */
testimonialAuthor: string
}
/** 默认品牌配置(数据库未配置时使用) */
export const DEFAULT_BRAND_CONFIG: BrandConfig = {
schoolName: "Next_Edu",
logoUrl: null,
testimonialQuote:
"This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.",
testimonialAuthor: "Sofia Davis",
}

View File

@@ -0,0 +1,66 @@
"use client"
import { useTranslations } from "next-intl"
import { Database } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface FileUploadValues {
maxFileSize: number
allowedTypes: string
}
interface FileUploadCardProps {
values: FileUploadValues
onChange: (key: keyof FileUploadValues, value: number | string) => void
}
/**
* 管理员系统设置 - 文件上传卡片
*/
export function FileUploadCard({ values, onChange }: FileUploadCardProps): ReactElement {
const t = useTranslations("settings.admin.fileUpload")
return (
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="max-file-size">{t("maxFileSize")}</Label>
<Input
id="max-file-size"
name="maxFileSize"
type="number"
min={1}
max={100}
value={values.maxFileSize}
onChange={(e) => onChange("maxFileSize", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types">{t("allowedTypes")}</Label>
<Input
id="allowed-types"
name="allowedTypes"
placeholder={t("allowedTypesPlaceholder")}
value={values.allowedTypes}
onChange={(e) => onChange("allowedTypes", e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import { useTranslations } from "next-intl"
import { Bell } from "lucide-react"
import { type ReactElement } from "react"
import { Label } from "@/shared/components/ui/label"
import { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface NotificationConfigValues {
notifyNewUser: boolean
notifyScheduleChange: boolean
notifyAnnouncement: boolean
}
interface NotificationConfigCardProps {
values: NotificationConfigValues
onChange: (key: keyof NotificationConfigValues, value: boolean) => void
}
/**
* 管理员系统设置 - 通知配置卡片
*/
export function NotificationConfigCard({ values, onChange }: NotificationConfigCardProps): ReactElement {
const t = useTranslations("settings.admin.notificationConfig")
return (
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-user">{t("notifyNewUser")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyNewUserDesc")}</p>
</div>
<Switch
id="notify-new-user"
name="notifyNewUser"
checked={values.notifyNewUser}
onCheckedChange={(v) => onChange("notifyNewUser", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change">{t("notifyScheduleChange")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyScheduleChangeDesc")}</p>
</div>
<Switch
id="notify-schedule-change"
name="notifyScheduleChange"
checked={values.notifyScheduleChange}
onCheckedChange={(v) => onChange("notifyScheduleChange", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement">{t("notifyAnnouncement")}</Label>
<p className="text-sm text-muted-foreground">{t("notifyAnnouncementDesc")}</p>
</div>
<Switch
id="notify-announcement"
name="notifyAnnouncement"
checked={values.notifyAnnouncement}
onCheckedChange={(v) => onChange("notifyAnnouncement", v)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,113 @@
"use client"
import { useTranslations } from "next-intl"
import { School } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
export interface SchoolInfoValues {
schoolName: string
schoolCode: string
schoolPhone: string
schoolEmail: string
schoolAddress: string
schoolDescription: string
}
interface SchoolInfoCardProps {
values: SchoolInfoValues
onChange: (key: keyof SchoolInfoValues, value: string) => void
}
/**
* 管理员系统设置 - 学校信息卡片
*/
export function SchoolInfoCard({ values, onChange }: SchoolInfoCardProps): ReactElement {
const t = useTranslations("settings.admin.schoolInfo")
return (
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<School className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-name">{t("name")}</Label>
<Input
id="school-name"
name="schoolName"
placeholder={t("namePlaceholder")}
value={values.schoolName}
onChange={(e) => onChange("schoolName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-code">{t("code")}</Label>
<Input
id="school-code"
name="schoolCode"
placeholder={t("codePlaceholder")}
value={values.schoolCode}
onChange={(e) => onChange("schoolCode", e.target.value)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-phone">{t("phone")}</Label>
<Input
id="school-phone"
name="schoolPhone"
placeholder={t("phonePlaceholder")}
value={values.schoolPhone}
onChange={(e) => onChange("schoolPhone", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-email">{t("email")}</Label>
<Input
id="school-email"
name="schoolEmail"
type="email"
placeholder={t("emailPlaceholder")}
value={values.schoolEmail}
onChange={(e) => onChange("schoolEmail", e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address">{t("address")}</Label>
<Input
id="school-address"
name="schoolAddress"
placeholder={t("addressPlaceholder")}
value={values.schoolAddress}
onChange={(e) => onChange("schoolAddress", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-desc">{t("description2")}</Label>
<Textarea
id="school-desc"
name="schoolDescription"
placeholder={t("descriptionPlaceholder")}
rows={3}
value={values.schoolDescription}
onChange={(e) => onChange("schoolDescription", e.target.value)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,110 @@
"use client"
import { useTranslations } from "next-intl"
import { Shield } from "lucide-react"
import { type ReactElement } from "react"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
export interface SecurityPolicyValues {
passwordMinLength: number
sessionTimeout: number
requireSpecialChar: boolean
requireUppercase: boolean
forcePasswordChange: boolean
}
interface SecurityPolicyCardProps {
values: SecurityPolicyValues
onChange: (key: keyof SecurityPolicyValues, value: number | boolean) => void
}
/**
* 管理员系统设置 - 安全策略卡片
*/
export function SecurityPolicyCard({ values, onChange }: SecurityPolicyCardProps): ReactElement {
const t = useTranslations("settings.admin.securityPolicy")
return (
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password-min-length">{t("passwordMinLength")}</Label>
<Input
id="password-min-length"
name="passwordMinLength"
type="number"
min={6}
max={32}
value={values.passwordMinLength}
onChange={(e) => onChange("passwordMinLength", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout">{t("sessionTimeout")}</Label>
<Input
id="session-timeout"
name="sessionTimeout"
type="number"
min={5}
max={1440}
value={values.sessionTimeout}
onChange={(e) => onChange("sessionTimeout", Number(e.target.value))}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-special-char">{t("requireSpecialChar")}</Label>
<p className="text-sm text-muted-foreground">{t("requireSpecialCharDesc")}</p>
</div>
<Switch
id="require-special-char"
name="requireSpecialChar"
checked={values.requireSpecialChar}
onCheckedChange={(v) => onChange("requireSpecialChar", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase">{t("requireUppercase")}</Label>
<p className="text-sm text-muted-foreground">{t("requireUppercaseDesc")}</p>
</div>
<Switch
id="require-uppercase"
name="requireUppercase"
checked={values.requireUppercase}
onCheckedChange={(v) => onChange("requireUppercase", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change">{t("forcePasswordChange")}</Label>
<p className="text-sm text-muted-foreground">{t("forcePasswordChangeDesc")}</p>
</div>
<Switch
id="force-password-change"
name="forcePasswordChange"
checked={values.forcePasswordChange}
onCheckedChange={(v) => onChange("forcePasswordChange", v)}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -3,45 +3,24 @@
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { School, Shield, Database, Bell, Loader2 } from "lucide-react"
import { Loader2 } from "lucide-react"
import {
getAdminSystemSettingsAction,
saveAdminSystemSettingsAction,
} from "@/modules/settings/actions-system-settings"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Switch } from "@/shared/components/ui/switch"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Separator } from "@/shared/components/ui/separator"
import { SchoolInfoCard, type SchoolInfoValues } from "@/modules/settings/components/admin-school-info-card"
import { SecurityPolicyCard, type SecurityPolicyValues } from "@/modules/settings/components/admin-security-policy-card"
import { FileUploadCard, type FileUploadValues } from "@/modules/settings/components/admin-file-upload-card"
import { NotificationConfigCard, type NotificationConfigValues } from "@/modules/settings/components/admin-notification-config-card"
import { BrandConfigCard } from "@/modules/settings/components/brand-config-card"
interface AdminSettingsFormValues {
schoolInfo: {
schoolName: string
schoolCode: string
schoolPhone: string
schoolEmail: string
schoolAddress: string
schoolDescription: string
}
securityPolicy: {
passwordMinLength: number
sessionTimeout: number
requireSpecialChar: boolean
requireUppercase: boolean
forcePasswordChange: boolean
}
fileUpload: {
maxFileSize: number
allowedTypes: string
}
notificationConfig: {
notifyNewUser: boolean
notifyScheduleChange: boolean
notifyAnnouncement: boolean
}
schoolInfo: SchoolInfoValues
securityPolicy: SecurityPolicyValues
fileUpload: FileUploadValues
notificationConfig: NotificationConfigValues
}
const DEFAULT_VALUES: AdminSettingsFormValues = {
@@ -135,26 +114,26 @@ export function AdminSettingsView(): React.ReactElement {
setValues(loadedValues)
}
const updateSchoolInfo = (key: keyof AdminSettingsFormValues["schoolInfo"], value: string): void => {
const updateSchoolInfo = (key: keyof SchoolInfoValues, value: string): void => {
setValues((prev) => ({ ...prev, schoolInfo: { ...prev.schoolInfo, [key]: value } }))
}
const updateSecurityPolicy = (
key: keyof AdminSettingsFormValues["securityPolicy"],
key: keyof SecurityPolicyValues,
value: number | boolean
): void => {
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
}
const updateFileUpload = (
key: keyof AdminSettingsFormValues["fileUpload"],
key: keyof FileUploadValues,
value: number | string
): void => {
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
}
const updateNotificationConfig = (
key: keyof AdminSettingsFormValues["notificationConfig"],
key: keyof NotificationConfigValues,
value: boolean
): void => {
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
@@ -176,254 +155,22 @@ export function AdminSettingsView(): React.ReactElement {
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* 学校信息 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<School className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("schoolInfo.title")}</CardTitle>
<CardDescription>{t("schoolInfo.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
<Input
id="school-name"
name="schoolName"
placeholder={t("schoolInfo.namePlaceholder")}
value={values.schoolInfo.schoolName}
onChange={(e) => updateSchoolInfo("schoolName", e.target.value)}
<SchoolInfoCard
values={values.schoolInfo}
onChange={updateSchoolInfo}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
<Input
id="school-code"
name="schoolCode"
placeholder={t("schoolInfo.codePlaceholder")}
value={values.schoolInfo.schoolCode}
onChange={(e) => updateSchoolInfo("schoolCode", e.target.value)}
<SecurityPolicyCard
values={values.securityPolicy}
onChange={updateSecurityPolicy}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
<Input
id="school-phone"
name="schoolPhone"
placeholder={t("schoolInfo.phonePlaceholder")}
value={values.schoolInfo.schoolPhone}
onChange={(e) => updateSchoolInfo("schoolPhone", e.target.value)}
<FileUploadCard
values={values.fileUpload}
onChange={updateFileUpload}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
<Input
id="school-email"
name="schoolEmail"
type="email"
placeholder={t("schoolInfo.emailPlaceholder")}
value={values.schoolInfo.schoolEmail}
onChange={(e) => updateSchoolInfo("schoolEmail", e.target.value)}
<NotificationConfigCard
values={values.notificationConfig}
onChange={updateNotificationConfig}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
<Input
id="school-address"
name="schoolAddress"
placeholder={t("schoolInfo.addressPlaceholder")}
value={values.schoolInfo.schoolAddress}
onChange={(e) => updateSchoolInfo("schoolAddress", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
<Textarea
id="school-desc"
name="schoolDescription"
placeholder={t("schoolInfo.descriptionPlaceholder")}
rows={3}
value={values.schoolInfo.schoolDescription}
onChange={(e) => updateSchoolInfo("schoolDescription", e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* 安全策略 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("securityPolicy.title")}</CardTitle>
<CardDescription>{t("securityPolicy.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
<Input
id="password-min-length"
name="passwordMinLength"
type="number"
min={6}
max={32}
value={values.securityPolicy.passwordMinLength}
onChange={(e) => updateSecurityPolicy("passwordMinLength", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
<Input
id="session-timeout"
name="sessionTimeout"
type="number"
min={5}
max={1440}
value={values.securityPolicy.sessionTimeout}
onChange={(e) => updateSecurityPolicy("sessionTimeout", Number(e.target.value))}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
</div>
<Switch
id="require-special-char"
name="requireSpecialChar"
checked={values.securityPolicy.requireSpecialChar}
onCheckedChange={(v) => updateSecurityPolicy("requireSpecialChar", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
</div>
<Switch
id="require-uppercase"
name="requireUppercase"
checked={values.securityPolicy.requireUppercase}
onCheckedChange={(v) => updateSecurityPolicy("requireUppercase", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
</div>
<Switch
id="force-password-change"
name="forcePasswordChange"
checked={values.securityPolicy.forcePasswordChange}
onCheckedChange={(v) => updateSecurityPolicy("forcePasswordChange", v)}
/>
</div>
</CardContent>
</Card>
{/* 文件上传 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("fileUpload.title")}</CardTitle>
<CardDescription>{t("fileUpload.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
<Input
id="max-file-size"
name="maxFileSize"
type="number"
min={1}
max={100}
value={values.fileUpload.maxFileSize}
onChange={(e) => updateFileUpload("maxFileSize", Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
<Input
id="allowed-types"
name="allowedTypes"
placeholder={t("fileUpload.allowedTypesPlaceholder")}
value={values.fileUpload.allowedTypes}
onChange={(e) => updateFileUpload("allowedTypes", e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* 通知配置 */}
<Card className="shadow-none">
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-base">{t("notificationConfig.title")}</CardTitle>
<CardDescription>{t("notificationConfig.description")}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
</div>
<Switch
id="notify-new-user"
name="notifyNewUser"
checked={values.notificationConfig.notifyNewUser}
onCheckedChange={(v) => updateNotificationConfig("notifyNewUser", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
</div>
<Switch
id="notify-schedule-change"
name="notifyScheduleChange"
checked={values.notificationConfig.notifyScheduleChange}
onCheckedChange={(v) => updateNotificationConfig("notifyScheduleChange", v)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
</div>
<Switch
id="notify-announcement"
name="notifyAnnouncement"
checked={values.notificationConfig.notifyAnnouncement}
onCheckedChange={(v) => updateNotificationConfig("notifyAnnouncement", v)}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
@@ -439,6 +186,9 @@ export function AdminSettingsView(): React.ReactElement {
</Button>
</div>
</form>
{/* audit-P2-6: 品牌配置(独立表单 + 独立保存,不嵌入主表单以防嵌套 form */}
<BrandConfigCard />
</div>
)
}

View File

@@ -0,0 +1,68 @@
"use client"
import { useTranslations } from "next-intl"
import { Loader2, Trash2 } from "lucide-react"
import { type ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
interface AiProviderDeleteDialogProps {
/** 是否禁用删除按钮(无选中项或正在执行其他操作时) */
disabled: boolean
/** 是否正在执行删除操作 */
isPending: boolean
/** 确认删除回调 */
onConfirm: () => void
}
/**
* AI 服务商删除确认对话框
*
* 仅负责删除确认交互,具体删除逻辑由父组件通过 onConfirm 回调注入。
*/
export function AiProviderDeleteDialog({
disabled,
isPending,
onConfirm,
}: AiProviderDeleteDialogProps): ReactElement {
const t = useTranslations("settings.ai.providers")
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={disabled}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,108 @@
"use client"
import { useTranslations } from "next-intl"
import { type ReactElement } from "react"
import { type AiProviderSummary } from "@/modules/settings/actions"
import { Badge } from "@/shared/components/ui/badge"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
const NEW_PROVIDER_VALUE = "__new__"
interface AiProviderSelectorProps {
/** 已有服务商列表 */
providers: AiProviderSummary[]
/** 当前选中的服务商 ID空字符串表示新建 */
selectedId: string
/** 当前用户 ID用于判断是否为创建者 */
currentUserId?: string
/** 选择变更回调 */
onSelectChange: (value: string) => void
}
/**
* AI 服务商选择器
*
* 负责:
* - 渲染已有服务商下拉选择(含"新建"选项)
* - 展示当前选中服务商的密钥状态与可见性徽章
*
* 不包含任何业务逻辑,仅做展示与事件转发。
*/
export function AiProviderSelector({
providers,
selectedId,
currentUserId,
onSelectChange,
}: AiProviderSelectorProps): ReactElement {
const t = useTranslations("settings.ai.providers")
const selectedProvider = providers.find((item) => item.id === selectedId) ?? null
const renderProviderLabel = (item: AiProviderSummary): string => {
const parts = [item.provider, "·", item.model]
if (item.isDefault) parts.push(`(${t("setDefault")})`)
return parts.join(" ")
}
const renderVisibilityBadge = (item: AiProviderSummary): ReactElement => {
const isOwner = currentUserId !== undefined && item.createdBy === currentUserId
return (
<span className="ml-2 inline-flex gap-1">
{item.visibility === "public" ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePublic")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePrivate")}
</Badge>
)}
{isOwner ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
{t("badgeOwner")}
</Badge>
) : null}
</span>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("existing")}</Label>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={onSelectChange}>
<SelectTrigger>
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderProviderLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("keyStatus")}</Label>
<div className="flex items-center rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider ? renderVisibilityBadge(selectedProvider) : null}
<span className="ml-auto">
{selectedProvider?.apiKeyLast4
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</span>
</div>
</div>
</div>
)
}

View File

@@ -1,28 +1,16 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, type ReactElement } from "react"
import { useCallback, useEffect, useRef, useState, useTransition, type ReactElement } from "react"
import { useTranslations } from "next-intl"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { Loader2, Save, Sparkles, Trash2 } from "lucide-react"
import { Loader2, Save, Sparkles } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import {
Form,
FormControl,
@@ -40,10 +28,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
import { AiProviderSelector } from "@/modules/settings/components/ai-provider-selector"
import { AiProviderDeleteDialog } from "@/modules/settings/components/ai-provider-delete-dialog"
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
const VisibilitySchema = z.enum(["public", "private"])
const AiProviderFormSchema = z.object({
@@ -58,8 +47,6 @@ const AiProviderFormSchema = z.object({
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
const NEW_PROVIDER_VALUE = "__new__"
type AiProviderSettingsCardProps = {
onProvidersChanged?: (rows: AiProviderSummary[]) => void
initialMode?: "new" | "first"
@@ -72,7 +59,7 @@ export function AiProviderSettingsCard({
initialMode = "first",
isAdmin = false,
currentUserId,
}: AiProviderSettingsCardProps) {
}: AiProviderSettingsCardProps): ReactElement {
const t = useTranslations("settings.ai.providers")
const [isPending, startTransition] = useTransition()
const [providers, setProviders] = useState<AiProviderSummary[]>([])
@@ -94,11 +81,6 @@ export function AiProviderSettingsCard({
},
})
const selectedProvider = useMemo(
() => providers.find((item) => item.id === selectedId) ?? null,
[providers, selectedId]
)
const buildSignature = useCallback((values: AiProviderFormValues) => {
return JSON.stringify({
provider: values.provider,
@@ -160,7 +142,7 @@ export function AiProviderSettingsCard({
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
const handleSelectChange = (value: string) => {
if (value === NEW_PROVIDER_VALUE) {
if (value === "__new__") {
resetToNew()
return
}
@@ -194,7 +176,8 @@ export function AiProviderSettingsCard({
const handleTest = () => {
const values = form.getValues()
const apiKey = values.apiKey?.trim()
if (!apiKey && !values.id?.trim()) {
const isLocalProvider = values.provider === "ollama"
if (!apiKey && !values.id?.trim() && !isLocalProvider) {
toast.error(t("needKey"))
return
}
@@ -309,34 +292,6 @@ export function AiProviderSettingsCard({
})
}
const renderProviderLabel = (item: AiProviderSummary): string => {
const parts = [item.provider, "·", item.model]
if (item.isDefault) parts.push(`(${t("setDefault")})`)
return parts.join(" ")
}
const renderVisibilityBadge = (item: AiProviderSummary): ReactElement => {
const isOwner = currentUserId !== undefined && item.createdBy === currentUserId
return (
<span className="ml-2 inline-flex gap-1">
{item.visibility === "public" ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePublic")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
{t("badgePrivate")}
</Badge>
)}
{isOwner ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 text-muted-foreground">
{t("badgeOwner")}
</Badge>
) : null}
</span>
)
}
return (
<Card>
<CardHeader>
@@ -347,35 +302,12 @@ export function AiProviderSettingsCard({
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("existing")}</Label>
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
<SelectTrigger>
<SelectValue placeholder={t("selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_PROVIDER_VALUE}>{t("createNew")}</SelectItem>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderProviderLabel(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("keyStatus")}</Label>
<div className="flex items-center rounded-md border px-3 py-2 text-sm text-muted-foreground">
{selectedProvider ? renderVisibilityBadge(selectedProvider) : null}
<span className="ml-auto">
{selectedProvider?.apiKeyLast4
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
: t("noKey")}
</span>
</div>
</div>
</div>
<AiProviderSelector
providers={providers}
selectedId={selectedId}
currentUserId={currentUserId}
onSelectChange={handleSelectChange}
/>
<Form {...form}>
<div className="grid gap-6">
@@ -396,6 +328,7 @@ export function AiProviderSettingsCard({
{ value: "zhipu", label: "Zhipu" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama (Local)" },
{ value: "custom", label: "Custom" },
]}
/>
@@ -403,22 +336,22 @@ export function AiProviderSettingsCard({
control={form.control}
name="baseUrl"
label={t("baseUrl")}
placeholder={t("baseUrlPlaceholder")}
description={t("baseUrlDesc")}
placeholder={form.watch("provider") === "ollama" ? "http://localhost:11434/v1" : t("baseUrlPlaceholder")}
description={form.watch("provider") === "ollama" ? t("baseUrlDescOllama") : t("baseUrlDesc")}
/>
<TextField
control={form.control}
name="model"
label={t("model")}
placeholder={t("modelPlaceholder")}
placeholder={form.watch("provider") === "ollama" ? "llama3.2" : t("modelPlaceholder")}
/>
<TextField
control={form.control}
name="apiKey"
label={t("apiKey")}
type="password"
placeholder={t("apiKeyPlaceholder")}
description={t("apiKeyDesc")}
placeholder={form.watch("provider") === "ollama" ? t("apiKeyPlaceholderOllama") : t("apiKeyPlaceholder")}
description={form.watch("provider") === "ollama" ? t("apiKeyDescOllama") : t("apiKeyDesc")}
itemClassName="sm:col-span-2"
/>
</div>
@@ -467,28 +400,11 @@ export function AiProviderSettingsCard({
/>
<CardFooter className="flex justify-between border-t px-0 pt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
<AiProviderDeleteDialog
disabled={isPending || !form.getValues("id")?.trim()}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>{t("deleteConfirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
isPending={isPending}
onConfirm={handleDelete}
/>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
{testStatus === "testing" ? (

View File

@@ -29,7 +29,8 @@ const MAX_FILENAME_LENGTH = 255
* 头像上传组件
*
* 支持上传新头像、预览、删除。
* 文件通过 /api/upload 上传,成功后调用 Server Action 更新 users.image。
* 文件通过 /api/upload 上传targetType="user_avatar" 已注册到 FileTargetType 枚举),
* 成功后调用 Server Action 更新 users.image。
*/
export function AvatarUpload({
currentImage,
@@ -72,7 +73,7 @@ export function AvatarUpload({
setUploading(true)
try {
// 上传文件到 /api/upload
// 上传文件到 /api/uploadtargetType="user_avatar" 已注册到 FileTargetType 枚举
const formData = new FormData()
formData.append("file", file)
formData.append("targetType", "user_avatar")

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Loader2, Save } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { getBrandConfigAction, saveBrandConfigAction } from "@/modules/settings/actions-brand"
import type { BrandConfig } from "@/modules/settings/brand-config"
import { DEFAULT_BRAND_CONFIG } from "@/modules/settings/brand-config"
/**
* 品牌配置卡片audit-P2-6 新增)
*
* 管理员可配置学校品牌信息(名称/Logo/标语),显示在认证页面 AuthLayout。
* 独立组件,不嵌入 AdminSettingsView 的统一表单状态,自行管理加载与保存。
*/
export function BrandConfigCard(): React.ReactElement {
const t = useTranslations("settings.brand")
const [config, setConfig] = React.useState<BrandConfig>(DEFAULT_BRAND_CONFIG)
const [isLoading, setIsLoading] = React.useState(true)
const [isSaving, setIsSaving] = React.useState(false)
React.useEffect(() => {
let cancelled = false
async function loadConfig(): Promise<void> {
setIsLoading(true)
try {
const res = await getBrandConfigAction()
if (!cancelled && res.success && res.data) {
setConfig(res.data)
}
} catch {
if (!cancelled) toast.error(t("loadFailed"))
} finally {
if (!cancelled) setIsLoading(false)
}
}
void loadConfig()
return () => {
cancelled = true
}
}, [t])
async function handleSave(e: React.FormEvent): Promise<void> {
e.preventDefault()
setIsSaving(true)
try {
const formData = new FormData()
formData.set("schoolName", config.schoolName)
formData.set("logoUrl", config.logoUrl ?? "")
formData.set("testimonialQuote", config.testimonialQuote)
formData.set("testimonialAuthor", config.testimonialAuthor)
const res = await saveBrandConfigAction({ success: false }, formData)
if (res.success) {
toast.success(t("saveSuccess"))
} else {
toast.error(res.message ?? t("saveFailed"))
}
} catch {
toast.error(t("saveFailed"))
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="brand-schoolName">{t("schoolName")}</Label>
<Input
id="brand-schoolName"
value={config.schoolName}
onChange={(e) => setConfig({ ...config, schoolName: e.target.value })}
maxLength={255}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-logoUrl">{t("logoUrl")}</Label>
<Input
id="brand-logoUrl"
type="url"
value={config.logoUrl ?? ""}
onChange={(e) => setConfig({ ...config, logoUrl: e.target.value || null })}
placeholder="https://..."
/>
<p className="text-xs text-muted-foreground">{t("logoUrlDescription")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="brand-quote">{t("testimonialQuote")}</Label>
<Textarea
id="brand-quote"
value={config.testimonialQuote}
onChange={(e) => setConfig({ ...config, testimonialQuote: e.target.value })}
maxLength={500}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-author">{t("testimonialAuthor")}</Label>
<Input
id="brand-author"
value={config.testimonialAuthor}
onChange={(e) => setConfig({ ...config, testimonialAuthor: e.target.value })}
maxLength={100}
required
/>
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("save")}
</Button>
</form>
</CardContent>
</Card>
)
}

View File

@@ -5,8 +5,7 @@ import { StudentGradesCard } from "@/modules/dashboard/components/student-dashbo
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getStudentProfileOverviewData } from "@/modules/settings/data-access-profile-overview"
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
import { Separator } from "@/shared/components/ui/separator"
@@ -18,23 +17,21 @@ interface ProfileStudentOverviewProps {
* 学生概览区块Server Component
*
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
* 不直接 import classes/homework 的 data-access。
*/
export async function ProfileStudentOverview({
userId,
}: ProfileStudentOverviewProps): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.studentOverview")
const t = await getTranslations("settings.profile.studentOverview")
const [classes, schedule, assignmentsAll, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
const { classes, schedule, assignments, grades } =
await getStudentProfileOverviewData(userId)
const data = buildStudentOverviewData({
classes,
schedule,
assignments: assignmentsAll,
assignments,
grades,
})
@@ -90,4 +87,3 @@ export function ProfileStudentOverviewSkeleton(): ReactElement {
</div>
)
}

View File

@@ -3,7 +3,7 @@ import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Calendar, GraduationCap } from "lucide-react"
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { getTeacherProfileOverviewData } from "@/modules/settings/data-access-profile-overview"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -18,16 +18,15 @@ interface ProfileTeacherOverviewProps {
* 教师概览区块Server Component
*
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
* 不直接 import classes 的 data-access。
*/
export async function ProfileTeacherOverview(
_props: ProfileTeacherOverviewProps = {}
): Promise<ReactElement> {
const t = await getTranslations("settings.profilePage.teacherOverview")
const t = await getTranslations("settings.profile.teacherOverview")
const [subjects, classes] = await Promise.all([
getTeacherTeachingSubjects(),
getTeacherClasses(),
])
const { subjects, classes } = await getTeacherProfileOverviewData()
return (
<div className="space-y-6">

View File

@@ -1,42 +1,14 @@
"use client"
import * as React from "react"
import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner"
import {
ShieldCheck,
Smartphone,
Loader2,
LogIn,
LogOut,
UserPlus,
AlertCircle,
LogOutIcon,
KeyRound,
Copy,
Check,
RefreshCw,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { ShieldCheck } from "lucide-react"
import {
disableTwoFactorAction,
getSecurityCenterAction,
regenerateBackupCodesAction,
revokeAllOtherSessionsAction,
setupTwoFactorAction,
verifyTwoFactorAction,
type LoginHistoryItem,
type TwoFactorSetupData,
type TwoFactorStatus,
} from "@/modules/settings/actions-security"
import {
formatRelativeTime,
parseUserAgent,
} from "@/modules/settings/lib/security-utils"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Card,
CardContent,
@@ -44,226 +16,63 @@ import {
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { SecurityTwoFactorSection } from "@/modules/settings/components/security-two-factor-section"
import { SecurityRecentLoginsSection } from "@/modules/settings/components/security-recent-logins-section"
interface SecurityCenterCardProps {
/** 当前会话的 user agent用于标记当前会话 */
currentDeviceLabel?: string
}
const ACTION_ICON_MAP: Record<LoginHistoryItem["action"], React.ReactNode> = {
signin: <LogIn className="h-4 w-4" />,
signout: <LogOut className="h-4 w-4" />,
signup: <UserPlus className="h-4 w-4" />,
}
type SetupStep = "idle" | "qr" | "backup"
/**
* 安全中心卡片
*
* 提供
* - 2FA TOTP 完整流程(启用 / 关闭 / 重新生成备份码)
* - 最近登录历史(最近 10 条,来自 login_logs 表)
* - 远程登出其他会话
* 作为容器组件负责
* - 加载 2FA 状态与最近登录记录
* - 编排 TwoFactor / RecentLogins 两个子区块
*
* 具体交互逻辑封装在子组件中,本组件仅做数据加载与状态分发。
*/
export function SecurityCenterCard({
currentDeviceLabel,
}: SecurityCenterCardProps): React.ReactElement {
const t = useTranslations("settings.security.center")
const locale = useLocale()
const [twoFactor, setTwoFactor] = React.useState<TwoFactorStatus | null>(null)
const [recentLogins, setRecentLogins] = React.useState<LoginHistoryItem[]>([])
const [loading, setLoading] = React.useState(true)
const [revoking, setRevoking] = React.useState(false)
// 启用 2FA Dialog 状态
const [enableDialogOpen, setEnableDialogOpen] = React.useState(false)
const [setupStep, setSetupStep] = React.useState<SetupStep>("idle")
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
const [verifyCode, setVerifyCode] = React.useState("")
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
const [setupLoading, setSetupLoading] = React.useState(false)
const [copied, setCopied] = React.useState(false)
// 关闭 2FA Dialog 状态
const [disableDialogOpen, setDisableDialogOpen] = React.useState(false)
const [disableCode, setDisableCode] = React.useState("")
const [disableLoading, setDisableLoading] = React.useState(false)
// 重新生成备份码 Dialog 状态
const [regenDialogOpen, setRegenDialogOpen] = React.useState(false)
const [regenCode, setRegenCode] = React.useState("")
const [regenLoading, setRegenLoading] = React.useState(false)
const [regenBackupCodes, setRegenBackupCodes] = React.useState<string[]>([])
React.useEffect(() => {
let cancelled = false
async function load(): Promise<void> {
const loadData = React.useCallback(async (): Promise<void> => {
try {
const result = await getSecurityCenterAction()
if (!cancelled && result.success && result.data) {
if (result.success && result.data) {
setTwoFactor(result.data.twoFactor)
setRecentLogins(result.data.recentLogins)
}
} catch {
// 加载失败时静默处理
// 加载失败时静默处理,子组件会展示空状态
} finally {
if (!cancelled) setLoading(false)
setLoading(false)
}
}, [])
React.useEffect(() => {
let cancelled = false
async function load(): Promise<void> {
if (!cancelled) await loadData()
}
void load()
return () => {
cancelled = true
}
}, [])
}, [loadData])
// --- 启用 2FA 流程 ---
const handleEnable2FA = async (): Promise<void> => {
setEnableDialogOpen(true)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
setSetupLoading(true)
try {
const result = await setupTwoFactorAction()
if (result.success && result.data) {
setSetupData(result.data)
setSetupStep("qr")
} else {
toast.error(result.message || t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
}
} catch {
toast.error(t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
} finally {
setSetupLoading(false)
}
const handleTwoFactorChange = (status: TwoFactorStatus): void => {
setTwoFactor(status)
}
const handleVerifySetup = async (): Promise<void> => {
if (!verifyCode.trim()) return
setSetupLoading(true)
try {
const result = await verifyTwoFactorAction(verifyCode.trim())
if (result.success && result.data) {
setBackupCodes(result.data.backupCodes)
setTwoFactor(result.data.status)
setSetupStep("backup")
toast.success(t("twoFactor.enableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.verifyFailure"))
} finally {
setSetupLoading(false)
}
}
const handleCopyBackupCodes = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// 剪贴板不可用时静默
}
}
const handleCloseEnableDialog = (): void => {
setEnableDialogOpen(false)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
}
// --- 关闭 2FA 流程 ---
const handleDisable2FA = async (): Promise<void> => {
if (!disableCode.trim()) return
setDisableLoading(true)
try {
const result = await disableTwoFactorAction(disableCode.trim())
if (result.success && result.data) {
setTwoFactor(result.data)
setDisableDialogOpen(false)
setDisableCode("")
toast.success(t("twoFactor.disableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.disableFailure"))
} finally {
setDisableLoading(false)
}
}
// --- 重新生成备份码 ---
const handleRegenerateBackupCodes = async (): Promise<void> => {
if (!regenCode.trim()) return
setRegenLoading(true)
try {
const result = await regenerateBackupCodesAction(regenCode.trim())
if (result.success && result.data) {
setRegenBackupCodes(result.data.backupCodes)
setTwoFactor(result.data.status)
setRegenCode("")
toast.success(t("twoFactor.regenerateSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.regenerateFailure"))
} finally {
setRegenLoading(false)
}
}
const handleRegenDialogOpen = (): void => {
setRegenDialogOpen(true)
setRegenCode("")
setRegenBackupCodes([])
}
// --- 远程登出 ---
const handleRevokeAllSessions = async (): Promise<void> => {
setRevoking(true)
try {
const result = await revokeAllOtherSessionsAction()
if (result.success && result.data) {
if (result.data.revokedCount > 0) {
toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount }))
} else {
toast.info(t("recentLogins.revokeSuccessEmpty"))
}
const refreshed = await getSecurityCenterAction()
if (refreshed.success && refreshed.data) {
setRecentLogins(refreshed.data.recentLogins)
}
} else {
toast.error(result.message || t("recentLogins.revokeFailure"))
}
} catch {
toast.error(t("recentLogins.revokeFailure"))
} finally {
setRevoking(false)
}
const handleRevoked = async (): Promise<void> => {
await loadData()
}
return (
@@ -278,368 +87,18 @@ export function SecurityCenterCard({
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 2FA 区域 */}
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
<div className="space-y-0.5">
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
<p className="text-sm text-muted-foreground">
{t("twoFactor.description")}
</p>
{twoFactor?.enabled ? (
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{t("twoFactor.enabled")}</Badge>
<span className="text-xs text-muted-foreground">
{t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
</span>
</div>
) : null}
</div>
</div>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : twoFactor?.enabled ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDisableDialogOpen(true)}
>
{t("twoFactor.disable")}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={handleEnable2FA}
>
{t("twoFactor.enable")}
</Button>
)}
</div>
{twoFactor?.enabled ? (
<div className="flex items-center justify-between rounded-lg border border-dashed p-3">
<div className="flex items-start gap-2">
<KeyRound className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<div className="text-xs font-medium">{t("twoFactor.backupCodes")}</div>
<p className="text-xs text-muted-foreground">
{t("twoFactor.backupHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRegenDialogOpen}
className="h-7 gap-1.5 text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("twoFactor.regenerate")}
</Button>
</div>
) : (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.hint")}
</p>
)}
</div>
{/* 最近登录历史 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{t("recentLogins.title")}</h4>
<div className="flex items-center gap-2">
{recentLogins.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("recentLogins.showingLatest", { count: recentLogins.length })}
</span>
) : null}
{!loading && recentLogins.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRevokeAllSessions}
disabled={revoking}
className="h-7 gap-1.5 text-xs"
>
{revoking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOutIcon className="h-3.5 w-3.5" />
)}
{revoking ? t("recentLogins.revoking") : t("recentLogins.revokeAll")}
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : recentLogins.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
{t("recentLogins.empty")}
</div>
) : (
<ul className="divide-y rounded-md border">
{recentLogins.map((item) => {
const { device, browser } = parseUserAgent(item.userAgent)
const isCurrent = currentDeviceLabel
? item.userAgent?.includes(currentDeviceLabel)
: false
return (
<li
key={item.id}
className="flex items-center gap-3 px-3 py-2.5 text-sm"
>
<span
className={
item.status === "success"
? "text-green-600"
: "text-red-600"
}
>
{ACTION_ICON_MAP[item.action]}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">
{t(`recentLogins.actions.${item.action}`)}
</span>
{item.status === "failure" ? (
<Badge variant="destructive" className="text-xs">
{t("recentLogins.failed")}
</Badge>
) : null}
{isCurrent ? (
<Badge variant="outline" className="text-xs">
{t("recentLogins.current")}
</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground truncate">
{device} · {browser}
{item.ipAddress ? ` · ${item.ipAddress}` : ""}
</div>
</div>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeTime(item.createdAt, locale)}
</time>
</li>
)
})}
</ul>
)}
</div>
<SecurityTwoFactorSection
twoFactor={twoFactor}
loading={loading}
onStatusChange={handleTwoFactorChange}
/>
<SecurityRecentLoginsSection
recentLogins={recentLogins}
loading={loading}
currentDeviceLabel={currentDeviceLabel}
onRevoked={handleRevoked}
/>
</CardContent>
{/* 启用 2FA Dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(o) => { if (!o) handleCloseEnableDialog() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.description")}</DialogDescription>
</DialogHeader>
{setupStep === "qr" && setupData ? (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={setupData.qrCodeDataUrl}
alt="2FA QR Code"
className="rounded-md border"
width={240}
height={240}
/>
<p className="text-center text-sm text-muted-foreground">
{t("twoFactor.scanQr")}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("twoFactor.manualEntry")}</Label>
<code className="block rounded-md bg-muted p-2 text-xs break-all">
{setupData.secret}
</code>
</div>
<div className="space-y-2">
<Label htmlFor="verifyCode">{t("twoFactor.enterCode")}</Label>
<Input
id="verifyCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
disabled={setupLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseEnableDialog} disabled={setupLoading}>
{t("twoFactor.cancel")}
</Button>
<Button onClick={handleVerifySetup} disabled={setupLoading || !verifyCode.trim()}>
{setupLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.verify")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "backup" ? (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("twoFactor.backupCodes")}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyBackupCodes}
className="h-7 gap-1.5 text-xs"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("twoFactor.copied") : t("twoFactor.copy")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{backupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseEnableDialog}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "idle" ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : null}
</DialogContent>
</Dialog>
{/* 关闭 2FA Dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.disableTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.disableDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="disableCode">{t("twoFactor.enterCodeDisable")}</Label>
<Input
id="disableCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={8}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
disabled={disableLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDisableDialogOpen(false)} disabled={disableLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDisable2FA}
disabled={disableLoading || !disableCode.trim()}
>
{disableLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.disable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重新生成备份码 Dialog */}
<Dialog open={regenDialogOpen} onOpenChange={setRegenDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.regenerateTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.regenerateDescription")}</DialogDescription>
</DialogHeader>
{regenBackupCodes.length === 0 ? (
<>
<div className="space-y-2">
<Label htmlFor="regenCode">{t("twoFactor.enterCodeRegen")}</Label>
<Input
id="regenCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={regenCode}
onChange={(e) => setRegenCode(e.target.value)}
disabled={regenLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenDialogOpen(false)} disabled={regenLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
onClick={handleRegenerateBackupCodes}
disabled={regenLoading || !regenCode.trim()}
>
{regenLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.regenerate")}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{regenBackupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
<DialogFooter>
<Button onClick={() => setRegenDialogOpen(false)}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner"
import {
LogIn,
LogOut,
UserPlus,
Loader2,
LogOutIcon,
} from "lucide-react"
import {
revokeAllOtherSessionsAction,
type LoginHistoryItem,
} from "@/modules/settings/actions-security"
import {
formatRelativeTime,
parseUserAgent,
} from "@/modules/settings/lib/security-utils"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
interface SecurityRecentLoginsSectionProps {
/** 最近登录记录列表 */
recentLogins: LoginHistoryItem[]
/** 是否正在加载初始数据 */
loading: boolean
/** 当前会话的 user agent用于标记当前会话 */
currentDeviceLabel?: string
/** 远程登出后通知父组件刷新数据 */
onRevoked: () => Promise<void> | void
}
const ACTION_ICON_MAP: Record<LoginHistoryItem["action"], React.ReactNode> = {
signin: <LogIn className="h-4 w-4" />,
signout: <LogOut className="h-4 w-4" />,
signup: <UserPlus className="h-4 w-4" />,
}
/**
* 安全中心 - 最近登录记录区块
*
* 负责:
* - 展示最近登录历史(最近 N 条)
* - 标记当前会话与失败记录
* - 远程登出其他会话
*
* 数据获取由父组件统一管理,本组件仅负责展示与登出操作。
*/
export function SecurityRecentLoginsSection({
recentLogins,
loading,
currentDeviceLabel,
onRevoked,
}: SecurityRecentLoginsSectionProps): React.ReactElement {
const t = useTranslations("settings.security.center")
const locale = useLocale()
const [revoking, setRevoking] = React.useState(false)
const handleRevokeAllSessions = async (): Promise<void> => {
setRevoking(true)
try {
const result = await revokeAllOtherSessionsAction()
if (result.success && result.data) {
if (result.data.revokedCount > 0) {
toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount }))
} else {
toast.info(t("recentLogins.revokeSuccessEmpty"))
}
await onRevoked()
} else {
toast.error(result.message || t("recentLogins.revokeFailure"))
}
} catch {
toast.error(t("recentLogins.revokeFailure"))
} finally {
setRevoking(false)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{t("recentLogins.title")}</h4>
<div className="flex items-center gap-2">
{recentLogins.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("recentLogins.showingLatest", { count: recentLogins.length })}
</span>
) : null}
{!loading && recentLogins.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRevokeAllSessions}
disabled={revoking}
className="h-7 gap-1.5 text-xs"
>
{revoking ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOutIcon className="h-3.5 w-3.5" />
)}
{revoking ? t("recentLogins.revoking") : t("recentLogins.revokeAll")}
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : recentLogins.length === 0 ? (
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
{t("recentLogins.empty")}
</div>
) : (
<ul className="divide-y rounded-md border">
{recentLogins.map((item) => {
const { device, browser } = parseUserAgent(item.userAgent)
const isCurrent = currentDeviceLabel
? item.userAgent?.includes(currentDeviceLabel)
: false
return (
<li
key={item.id}
className="flex items-center gap-3 px-3 py-2.5 text-sm"
>
<span
className={
item.status === "success"
? "text-green-600"
: "text-red-600"
}
>
{ACTION_ICON_MAP[item.action]}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">
{t(`recentLogins.actions.${item.action}`)}
</span>
{item.status === "failure" ? (
<Badge variant="destructive" className="text-xs">
{t("recentLogins.failed")}
</Badge>
) : null}
{isCurrent ? (
<Badge variant="outline" className="text-xs">
{t("recentLogins.current")}
</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground truncate">
{device} · {browser}
{item.ipAddress ? ` · ${item.ipAddress}` : ""}
</div>
</div>
<time className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeTime(item.createdAt, locale)}
</time>
</li>
)
})}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,473 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
Smartphone,
Loader2,
AlertCircle,
KeyRound,
Copy,
Check,
RefreshCw,
} from "lucide-react"
import {
disableTwoFactorAction,
regenerateBackupCodesAction,
setupTwoFactorAction,
verifyTwoFactorAction,
type TwoFactorSetupData,
type TwoFactorStatus,
} from "@/modules/settings/actions-security"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
interface SecurityTwoFactorSectionProps {
/** 当前 2FA 状态null 表示尚未加载 */
twoFactor: TwoFactorStatus | null
/** 是否正在加载初始数据 */
loading: boolean
/** 2FA 状态变更后通知父组件刷新 */
onStatusChange: (status: TwoFactorStatus) => void
}
type SetupStep = "idle" | "qr" | "backup"
/**
* 安全中心 - 两步验证区块
*
* 负责:
* - 2FA 启用流程(二维码 + 验证 + 备份码展示)
* - 2FA 关闭流程
* - 备份码重新生成
*
* 所有 Server Action 调用均封装在本组件内部,
* 父组件仅需提供初始状态与状态变更回调。
*/
export function SecurityTwoFactorSection({
twoFactor,
loading,
onStatusChange,
}: SecurityTwoFactorSectionProps): React.ReactElement {
const t = useTranslations("settings.security.center")
// 启用 2FA Dialog 状态
const [enableDialogOpen, setEnableDialogOpen] = React.useState(false)
const [setupStep, setSetupStep] = React.useState<SetupStep>("idle")
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
const [verifyCode, setVerifyCode] = React.useState("")
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
const [setupLoading, setSetupLoading] = React.useState(false)
const [copied, setCopied] = React.useState(false)
// 关闭 2FA Dialog 状态
const [disableDialogOpen, setDisableDialogOpen] = React.useState(false)
const [disableCode, setDisableCode] = React.useState("")
const [disableLoading, setDisableLoading] = React.useState(false)
// 重新生成备份码 Dialog 状态
const [regenDialogOpen, setRegenDialogOpen] = React.useState(false)
const [regenCode, setRegenCode] = React.useState("")
const [regenLoading, setRegenLoading] = React.useState(false)
const [regenBackupCodes, setRegenBackupCodes] = React.useState<string[]>([])
// --- 启用 2FA 流程 ---
const handleEnable2FA = async (): Promise<void> => {
setEnableDialogOpen(true)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
setSetupLoading(true)
try {
const result = await setupTwoFactorAction()
if (result.success && result.data) {
setSetupData(result.data)
setSetupStep("qr")
} else {
toast.error(result.message || t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
}
} catch {
toast.error(t("twoFactor.setupFailure"))
setEnableDialogOpen(false)
} finally {
setSetupLoading(false)
}
}
const handleVerifySetup = async (): Promise<void> => {
if (!verifyCode.trim()) return
setSetupLoading(true)
try {
const result = await verifyTwoFactorAction(verifyCode.trim())
if (result.success && result.data) {
setBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status)
setSetupStep("backup")
toast.success(t("twoFactor.enableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.verifyFailure"))
} finally {
setSetupLoading(false)
}
}
const handleCopyBackupCodes = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// 剪贴板不可用时静默
}
}
const handleCloseEnableDialog = (): void => {
setEnableDialogOpen(false)
setSetupStep("idle")
setSetupData(null)
setVerifyCode("")
setBackupCodes([])
}
// --- 关闭 2FA 流程 ---
const handleDisable2FA = async (): Promise<void> => {
if (!disableCode.trim()) return
setDisableLoading(true)
try {
const result = await disableTwoFactorAction(disableCode.trim())
if (result.success && result.data) {
onStatusChange(result.data)
setDisableDialogOpen(false)
setDisableCode("")
toast.success(t("twoFactor.disableSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.disableFailure"))
} finally {
setDisableLoading(false)
}
}
// --- 重新生成备份码 ---
const handleRegenerateBackupCodes = async (): Promise<void> => {
if (!regenCode.trim()) return
setRegenLoading(true)
try {
const result = await regenerateBackupCodesAction(regenCode.trim())
if (result.success && result.data) {
setRegenBackupCodes(result.data.backupCodes)
onStatusChange(result.data.status)
setRegenCode("")
toast.success(t("twoFactor.regenerateSuccess"))
} else {
toast.error(result.message || t("twoFactor.invalidCode"))
}
} catch {
toast.error(t("twoFactor.regenerateFailure"))
} finally {
setRegenLoading(false)
}
}
const handleRegenDialogOpen = (): void => {
setRegenDialogOpen(true)
setRegenCode("")
setRegenBackupCodes([])
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
<div className="space-y-0.5">
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
<p className="text-sm text-muted-foreground">
{t("twoFactor.description")}
</p>
{twoFactor?.enabled ? (
<div className="mt-1 flex items-center gap-2">
<Badge variant="secondary">{t("twoFactor.enabled")}</Badge>
<span className="text-xs text-muted-foreground">
{t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
</span>
</div>
) : null}
</div>
</div>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : twoFactor?.enabled ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDisableDialogOpen(true)}
>
{t("twoFactor.disable")}
</Button>
) : (
<Button
type="button"
size="sm"
onClick={handleEnable2FA}
>
{t("twoFactor.enable")}
</Button>
)}
</div>
{twoFactor?.enabled ? (
<div className="flex items-center justify-between rounded-lg border border-dashed p-3">
<div className="flex items-start gap-2">
<KeyRound className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div>
<div className="text-xs font-medium">{t("twoFactor.backupCodes")}</div>
<p className="text-xs text-muted-foreground">
{t("twoFactor.backupHint")}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRegenDialogOpen}
className="h-7 gap-1.5 text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
{t("twoFactor.regenerate")}
</Button>
</div>
) : (
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.hint")}
</p>
)}
{/* 启用 2FA Dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(o) => { if (!o) handleCloseEnableDialog() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.description")}</DialogDescription>
</DialogHeader>
{setupStep === "qr" && setupData ? (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={setupData.qrCodeDataUrl}
alt="2FA QR Code"
className="rounded-md border"
width={240}
height={240}
/>
<p className="text-center text-sm text-muted-foreground">
{t("twoFactor.scanQr")}
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("twoFactor.manualEntry")}</Label>
<code className="block rounded-md bg-muted p-2 text-xs break-all">
{setupData.secret}
</code>
</div>
<div className="space-y-2">
<Label htmlFor="verifyCode">{t("twoFactor.enterCode")}</Label>
<Input
id="verifyCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
disabled={setupLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCloseEnableDialog} disabled={setupLoading}>
{t("twoFactor.cancel")}
</Button>
<Button onClick={handleVerifySetup} disabled={setupLoading || !verifyCode.trim()}>
{setupLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.verify")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "backup" ? (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>{t("twoFactor.backupCodes")}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopyBackupCodes}
className="h-7 gap-1.5 text-xs"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? t("twoFactor.copied") : t("twoFactor.copy")}
</Button>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{backupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseEnableDialog}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
) : null}
{setupStep === "idle" ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : null}
</DialogContent>
</Dialog>
{/* 关闭 2FA Dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.disableTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.disableDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="disableCode">{t("twoFactor.enterCodeDisable")}</Label>
<Input
id="disableCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={8}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
disabled={disableLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDisableDialogOpen(false)} disabled={disableLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDisable2FA}
disabled={disableLoading || !disableCode.trim()}
>
{disableLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.disable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重新生成备份码 Dialog */}
<Dialog open={regenDialogOpen} onOpenChange={setRegenDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.regenerateTitle")}</DialogTitle>
<DialogDescription>{t("twoFactor.regenerateDescription")}</DialogDescription>
</DialogHeader>
{regenBackupCodes.length === 0 ? (
<>
<div className="space-y-2">
<Label htmlFor="regenCode">{t("twoFactor.enterCodeRegen")}</Label>
<Input
id="regenCode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
maxLength={6}
value={regenCode}
onChange={(e) => setRegenCode(e.target.value)}
disabled={regenLoading}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegenDialogOpen(false)} disabled={regenLoading}>
{t("twoFactor.cancel")}
</Button>
<Button
onClick={handleRegenerateBackupCodes}
disabled={regenLoading || !regenCode.trim()}
>
{regenLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t("twoFactor.regenerate")}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4">
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t("twoFactor.backupWarning")}
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
{regenBackupCodes.map((code, i) => (
<code key={i} className="text-sm font-mono">
{code}
</code>
))}
</div>
<DialogFooter>
<Button onClick={() => setRegenDialogOpen(false)}>
{t("twoFactor.done")}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,64 +1,25 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
/**
* 设置页分区 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 common 命名空间。
* 保留同名导出以兼容现有 import。
*/
import { EmptyState } from "@/shared/components/ui/empty-state"
import { useTranslations } from "next-intl"
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface SettingsSectionErrorBoundaryProps {
children: ReactNode
}
interface SettingsSectionErrorBoundaryState {
hasError: boolean
}
/**
* 设置页分区 Error Boundary
*
* 包裹每个 TabsContent 内部组件,避免单个区块崩溃导致整页不可用。
*/
export class SettingsSectionErrorBoundary extends Component<
SettingsSectionErrorBoundaryProps,
SettingsSectionErrorBoundaryState
> {
state: SettingsSectionErrorBoundaryState = { hasError: false }
static getDerivedStateFromError(): SettingsSectionErrorBoundaryState {
return { hasError: true }
}
handleRetry = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
export function SettingsSectionErrorBoundary({
children,
}: SettingsSectionErrorBoundaryProps): ReactNode {
return (
<SettingsSectionErrorFallback onRetry={this.handleRetry} />
)
}
return this.props.children
}
}
function SettingsSectionErrorFallback({
onRetry,
}: {
onRetry: () => void
}): ReactNode {
const t = useTranslations("settings.errors")
return (
<EmptyState
icon={AlertCircle}
title={t("sectionLoadFailed")}
description={t("sectionLoadFailedDesc")}
action={{
label: t("retry"),
onClick: onRetry,
}}
className="border-none shadow-none h-auto"
/>
<SectionErrorBoundary namespace="common">
{children}
</SectionErrorBoundary>
)
}

View File

@@ -0,0 +1,26 @@
import type { Role } from "@/shared/types/permissions"
/**
* Profile 概览类型
*
* 通过配置驱动角色 → 概览区块的映射,新增角色只需在此添加条目。
* 避免在页面层使用 roles.includes("xxx") 硬编码。
*/
export type ProfileOverviewType = "student" | "teacher" | "none"
const PROFILE_OVERVIEW_MAP: Partial<Record<Role, ProfileOverviewType>> = {
student: "student",
teacher: "teacher",
}
/**
* 根据角色列表解析首选概览类型。
* 优先级student > teacher > none
*/
export function resolveProfileOverviewType(roles: Role[]): ProfileOverviewType {
for (const role of roles) {
const overviewType = PROFILE_OVERVIEW_MAP[role]
if (overviewType) return overviewType
}
return "none"
}

View File

@@ -0,0 +1,78 @@
import "server-only"
import {
getSystemSetting,
upsertSystemSetting,
} from "@/modules/settings/data-access-system-settings"
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
export type { BrandConfig }
export { DEFAULT_BRAND_CONFIG }
/** 品牌配置键名 */
const BRAND_KEYS = {
schoolName: "schoolName",
logoUrl: "logoUrl",
testimonialQuote: "testimonialQuote",
testimonialAuthor: "testimonialAuthor",
} as const
/**
* 获取品牌配置audit-P2-6 新增)
*
* 从 system_settings 表 brand 分类读取,未配置项使用默认值。
*/
export async function getBrandConfig(): Promise<BrandConfig> {
const [schoolNameRow, logoUrlRow, quoteRow, authorRow] = await Promise.all([
getSystemSetting("brand", BRAND_KEYS.schoolName),
getSystemSetting("brand", BRAND_KEYS.logoUrl),
getSystemSetting("brand", BRAND_KEYS.testimonialQuote),
getSystemSetting("brand", BRAND_KEYS.testimonialAuthor),
])
return {
schoolName: schoolNameRow?.value || DEFAULT_BRAND_CONFIG.schoolName,
logoUrl: logoUrlRow?.value || null,
testimonialQuote: quoteRow?.value || DEFAULT_BRAND_CONFIG.testimonialQuote,
testimonialAuthor: authorRow?.value || DEFAULT_BRAND_CONFIG.testimonialAuthor,
}
}
/**
* 保存品牌配置audit-P2-6 新增)
*/
export async function saveBrandConfig(
config: BrandConfig,
updatedBy?: string,
): Promise<void> {
await Promise.all([
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.schoolName,
value: config.schoolName,
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.logoUrl,
value: config.logoUrl ?? "",
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.testimonialQuote,
value: config.testimonialQuote,
valueType: "string",
updatedBy,
}),
upsertSystemSetting({
category: "brand",
key: BRAND_KEYS.testimonialAuthor,
value: config.testimonialAuthor,
valueType: "string",
updatedBy,
}),
])
}

View File

@@ -0,0 +1,46 @@
import "server-only"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
import { getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
/**
* Profile 概览数据访问层
*
* 将 classes/homework 模块的 data-access 调用封装在 settings 模块内部,
* 避免 settings 组件层直接 import 其他业务模块的 data-access。
* 模块间通过 data-access 通信是允许的,组件层直接 import 则违反解耦原则。
*/
/**
* 获取学生概览所需的所有数据(并行查询)
*/
export async function getStudentProfileOverviewData(userId: string): Promise<{
classes: Awaited<ReturnType<typeof getStudentClasses>>
schedule: Awaited<ReturnType<typeof getStudentSchedule>>
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>
grades: Awaited<ReturnType<typeof getStudentDashboardGrades>>
}> {
const [classes, schedule, assignments, grades] = await Promise.all([
getStudentClasses(userId),
getStudentSchedule(userId),
getStudentHomeworkAssignments(userId),
getStudentDashboardGrades(userId),
])
return { classes, schedule, assignments, grades }
}
/**
* 获取教师概览所需的所有数据(并行查询)
*/
export async function getTeacherProfileOverviewData(): Promise<{
subjects: Awaited<ReturnType<typeof getTeacherTeachingSubjects>>
classes: Awaited<ReturnType<typeof getTeacherClasses>>
}> {
const [subjects, classes] = await Promise.all([
getTeacherTeachingSubjects(),
getTeacherClasses(),
])
return { subjects, classes }
}

View File

@@ -15,6 +15,8 @@ export type SystemSettingCategory =
| "security_policy"
| "file_upload"
| "notification_config"
| "audit_retention"
| "brand"
/**
* 系统设置值类型

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest"
import { toSettingItem } from "./system-settings-utils"
describe("toSettingItem", () => {
describe("string 类型", () => {
it("将普通字符串转换为设置项", () => {
const item = toSettingItem("school_info", "schoolName", "实验中学", "string")
expect(item).toEqual({
category: "school_info",
key: "schoolName",
value: "实验中学",
valueType: "string",
})
})
it("将 null 转换为空字符串", () => {
const item = toSettingItem("school_info", "schoolCode", null, "string")
expect(item.value).toBe("")
})
it("将 undefined 转换为空字符串", () => {
const item = toSettingItem("school_info", "schoolCode", undefined, "string")
expect(item.value).toBe("")
})
it("将数字转换为字符串", () => {
const item = toSettingItem("school_info", "schoolCode", 12345, "string")
expect(item.value).toBe("12345")
})
})
describe("number 类型", () => {
it("将数字转换为字符串", () => {
const item = toSettingItem("security_policy", "passwordMinLength", 8, "number")
expect(item.value).toBe("8")
})
it("将大数字转换为字符串", () => {
const item = toSettingItem("security_policy", "sessionTimeout", 1440, "number")
expect(item.value).toBe("1440")
})
it("将零转换为字符串", () => {
const item = toSettingItem("security_policy", "sessionTimeout", 0, "number")
expect(item.value).toBe("0")
})
})
describe("boolean 类型", () => {
it("将 true 转换为 'true'", () => {
const item = toSettingItem("security_policy", "requireSpecialChar", true, "boolean")
expect(item.value).toBe("true")
})
it("将 false 转换为 'false'", () => {
const item = toSettingItem("security_policy", "requireUppercase", false, "boolean")
expect(item.value).toBe("false")
})
it("将 truthy 值转换为 'true'", () => {
const item = toSettingItem("security_policy", "forcePasswordChange", 1, "boolean")
expect(item.value).toBe("true")
})
it("将 falsy 值转换为 'false'", () => {
const item = toSettingItem("security_policy", "forcePasswordChange", 0, "boolean")
expect(item.value).toBe("false")
})
})
describe("json 类型", () => {
it("将对象序列化为 JSON 字符串", () => {
const item = toSettingItem("file_upload", "allowedTypes", ["jpg", "png"], "json")
expect(item.value).toBe(JSON.stringify(["jpg", "png"]))
})
it("将嵌套对象序列化", () => {
const data = { types: ["jpg", "png"], maxSize: 10 }
const item = toSettingItem("file_upload", "config", data, "json")
expect(item.value).toBe(JSON.stringify(data))
})
it("将 null 序列化为 'null'", () => {
const item = toSettingItem("file_upload", "config", null, "json")
expect(item.value).toBe("null")
})
it("将数组序列化", () => {
const item = toSettingItem("notification_config", "channels", ["email", "sms"], "json")
expect(item.value).toBe(JSON.stringify(["email", "sms"]))
})
})
describe("返回结构", () => {
it("返回包含所有字段的设置项", () => {
const item = toSettingItem("school_info", "name", "测试", "string")
expect(item).toHaveProperty("category")
expect(item).toHaveProperty("key")
expect(item).toHaveProperty("value")
expect(item).toHaveProperty("valueType")
})
it("保留传入的 category 和 key", () => {
const item = toSettingItem("notification_config", "notifyNewUser", true, "boolean")
expect(item.category).toBe("notification_config")
expect(item.key).toBe("notifyNewUser")
})
it("保留传入的 valueType", () => {
const item = toSettingItem("security_policy", "min", 8, "number")
expect(item.valueType).toBe("number")
})
})
describe("边界情况", () => {
it("处理空字符串", () => {
const item = toSettingItem("school_info", "name", "", "string")
expect(item.value).toBe("")
})
it("处理负数", () => {
const item = toSettingItem("security_policy", "timeout", -1, "number")
expect(item.value).toBe("-1")
})
it("处理浮点数", () => {
const item = toSettingItem("file_upload", "maxSize", 10.5, "number")
expect(item.value).toBe("10.5")
})
it("处理空数组 JSON", () => {
const item = toSettingItem("file_upload", "types", [], "json")
expect(item.value).toBe("[]")
})
it("处理空对象 JSON", () => {
const item = toSettingItem("file_upload", "config", {}, "json")
expect(item.value).toBe("{}")
})
})
})

View File

@@ -0,0 +1,44 @@
import type {
SystemSettingCategory,
SystemSettingValueType,
} from "@/modules/settings/data-access-system-settings"
/**
* 系统设置项(用于 upsertSystemSettings 批量写入)
*/
export interface SettingItem {
category: SystemSettingCategory
key: string
value: string
valueType: SystemSettingValueType
}
/**
* 将表单值转换为可写入数据库的设置项。
*
* 根据 valueType 将原始值序列化为字符串:
* - json: JSON.stringify
* - boolean: "true" / "false"
* - number: String(value)
* - string: String(value ?? "")null/undefined 转为空串)
*
* 该函数为纯函数,便于单元测试。
*/
export function toSettingItem(
category: SystemSettingCategory,
key: string,
value: unknown,
valueType: SystemSettingValueType,
): SettingItem {
let strValue: string
if (valueType === "json") {
strValue = JSON.stringify(value)
} else if (valueType === "boolean") {
strValue = value ? "true" : "false"
} else if (valueType === "number") {
strValue = String(value)
} else {
strValue = String(value ?? "")
}
return { category, key, value: strValue, valueType }
}

View File

@@ -5,7 +5,7 @@ import type {
UpdateNotificationPreferencesInput,
} from "@/modules/notifications/types"
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom" | "ollama"
/**
* AI 服务商可见性

View File

@@ -6,6 +6,7 @@ import type { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { getTranslations } from "next-intl/server";
import { getCurrentStudentUser } from "@/modules/users/data-access";
import { getGradeNameById } from "@/modules/school/data-access";
import {
createTextbook,
createChapter,
@@ -20,6 +21,7 @@ import {
verifyChapterBelongsToTextbook,
verifyKnowledgePointBelongsToTextbook,
getKnowledgePointsByChapterId,
getTextbookById,
createPrerequisite,
deletePrerequisite,
getPrerequisiteEdgesForTextbook,
@@ -369,6 +371,9 @@ export async function getKnowledgePointsByChapterAction(
* - structure 模式:仅返回知识点+依赖+题目数
* - student-mastery 模式:附加当前学生掌握度
* - class-mastery 模式:附加班级平均掌握度(仅教师可用)
*
* P0 安全修复:学生端按年级 scope 过滤,防止跨年级越权查看教材图谱数据。
* class-mastery 模式仅限教师使用getClassStudents 已通过 getAccessibleClassIdsForTeacher 过滤)。
*/
export async function getKnowledgeGraphDataAction(
textbookId: string,
@@ -376,7 +381,27 @@ export async function getKnowledgeGraphDataAction(
): Promise<ActionState<KnowledgeGraphData>> {
try {
const t = await getTranslations("textbooks.action");
await requirePermission(Permissions.TEXTBOOK_READ);
const ctx = await requirePermission(Permissions.TEXTBOOK_READ);
// P0 数据范围校验:学生只能查看本年级教材的图谱
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
const textbook = await getTextbookById(textbookId);
if (!textbook) {
return { success: false, message: t("notFound") };
}
// 学生/家长端:校验教材年级是否在允许范围内
if (textbook.grade) {
const allowedGradeIds = ctx.dataScope.gradeIds ?? [];
if (allowedGradeIds.length > 0) {
const allowedGradeNames = await Promise.all(
allowedGradeIds.map((gid) => getGradeNameById(gid))
);
if (!allowedGradeNames.includes(textbook.grade)) {
return { success: false, message: t("notFound") };
}
}
}
}
const knowledgePointsData = await getKnowledgePointsWithRelations(textbookId);
const masteryMap: Record<string, MasteryInfo> = {};
@@ -391,7 +416,11 @@ export async function getKnowledgeGraphDataAction(
}
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
} else if (viewMode === "class-mastery") {
// 获取教师所带班级的所有学生 ID计算班级平均掌握度
// class-mastery 模式仅限教师使用dataScope.type === "class_taught"
// getClassStudents 内部已通过 getAccessibleClassIdsForTeacher 过滤,仅返回当前教师可访问班级的学生
if (ctx.dataScope.type !== "class_taught" && ctx.dataScope.type !== "all") {
return { success: false, message: t("noClassMasteryPermission") };
}
const students = await getClassStudents({ status: "active" });
const studentIds = students.map((s) => s.id);
if (studentIds.length > 0) {

View File

@@ -98,8 +98,16 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
)}
<div
className="flex-1 min-w-0 flex items-center gap-2"
className="flex-1 min-w-0 flex items-center gap-2 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm"
role="button"
tabIndex={0}
onClick={() => onSelect(chapter)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelect(chapter)
}
}}
>
{hasChildren ? (
<Folder className={cn("h-4 w-4 shrink-0 transition-colors", isOpen || isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />

View File

@@ -4,8 +4,7 @@ import { memo } from "react"
import { Handle, Position, type NodeProps } from "@xyflow/react"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import type { GraphNodeData, MasteryLevel } from "../types"
import type { GraphLayoutNodeData } from "../graph-layout"
import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types"
import { NODE_WIDTH } from "../graph-layout"
/** 根据掌握度计算色彩等级 */
@@ -30,11 +29,23 @@ const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
unassessed: "bg-muted",
}
function GraphKpNodeComponent({ data, selected }: NodeProps) {
/**
* 类型守卫:从 React Flow node.dataRecord<string, unknown>)安全提取图谱节点数据。
* 替代 as unknown as 双重断言,提供运行时安全。
*/
function extractNodeData(data: Record<string, unknown>): {
kp: KpWithRelations
graphData?: GraphNodeData
} {
const kp = data.kp as KpWithRelations
const graphData = data.graphData as GraphNodeData | undefined
return { kp, graphData }
}
function GraphKpNodeComponent(props: NodeProps) {
const t = useTranslations("textbooks")
const nodeData = data as unknown as GraphLayoutNodeData
const { kp } = nodeData
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
const { data, selected } = props
const { kp, graphData } = extractNodeData(data)
const mastery = graphData?.mastery ?? null
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"

View File

@@ -42,7 +42,7 @@ export function GraphNodeDetailPanel({
<div className="flex flex-col h-full border-l bg-background">
<div className="flex items-center justify-between p-3 border-b shrink-0">
<h3 className="text-sm font-semibold truncate">{t("graph.detail.title")}</h3>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose}>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClose} aria-label={t("graph.detail.close")}>
<X className="h-4 w-4" />
</Button>
</div>
@@ -141,6 +141,7 @@ export function GraphNodeDetailPanel({
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onRemovePrerequisite(p.id)}
aria-label={t("graph.detail.removePrerequisite")}
>
<Trash2 className="h-3 w-3" />
</Button>

View File

@@ -37,6 +37,7 @@ import {
import { Button } from "@/shared/components/ui/button"
import type { GraphViewMode, GraphNodeData } from "../types"
import { computeGraphLayout } from "../graph-layout"
import type { GraphLayoutNodeData } from "../graph-layout"
import { useGraphData } from "../hooks/use-graph-data"
import {
createPrerequisiteAction,
@@ -202,12 +203,12 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
const handleAddPrerequisite = useCallback(async () => {
if (!selectedKpId || !newPrereqId || !textbookId) return
setIsSavingPrereq(true)
try {
const formData = new FormData()
formData.set("knowledgePointId", selectedKpId)
formData.set("prerequisiteKpId", newPrereqId)
formData.set("textbookId", textbookId)
const result = await createPrerequisiteAction(formData)
setIsSavingPrereq(false)
if (result.success) {
toast.success(t("graph.detail.prerequisiteAdded"))
setAddPrereqOpen(false)
@@ -216,11 +217,17 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
} else {
toast.error(result.message)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"))
} finally {
setIsSavingPrereq(false)
}
}, [selectedKpId, newPrereqId, textbookId, t, reload])
// 删除前置依赖
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
if (!selectedKpId || !textbookId) return
try {
const formData = new FormData()
formData.set("knowledgePointId", selectedKpId)
formData.set("prerequisiteKpId", prereqId)
@@ -232,6 +239,9 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
} else {
toast.error(result.message)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed"))
}
}, [selectedKpId, textbookId, t, reload])
// 可选的前置知识点(排除自身和已是前置的)
@@ -306,9 +316,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
<MiniMap
className="!bg-background !border !rounded-lg"
nodeColor={(node) => {
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
return graphData?.chapterColor ?? "#6b7280"
// 安全的类型收窄:node.data 是 Record<string, unknown>
// GraphLayoutNodeData 有索引签名 [key: string]: unknown是 Record 的子类型
const data = node.data as GraphLayoutNodeData
return data.graphData?.chapterColor ?? "#6b7280"
}}
/>
</ReactFlow>

View File

@@ -81,7 +81,8 @@ export function KnowledgePointDialogs({
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription>{t("createDesc")}</DialogDescription>
</DialogHeader>
<form action={onCreateKnowledgePoint as (formData: FormData) => void}>
{/* 包装为 void 返回,避免 as 断言Promise 仍会被 React 处理 */}
<form action={(formData: FormData) => { void onCreateKnowledgePoint(formData) }}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">{t("name")}</Label>

View File

@@ -3,13 +3,15 @@
/**
* 教材模块内联 Error Boundary。
*
* 用于包裹独立数据区块(章节树、内容区、知识点区、图谱区),
* 隔离故障域,避免单点错误导致整个阅读器白屏
* 薄包装:委托给共享 SectionErrorBoundary通过自定义 fallback 渲染
* 调用方传入的 fallbackTitle/fallbackDescription/retryLabel
* 保留同名导出和 props 以兼容现有 import。
*/
import { Component, type ReactNode } from "react"
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface TextbookSectionErrorBoundaryProps {
children: ReactNode
@@ -21,52 +23,36 @@ interface TextbookSectionErrorBoundaryProps {
retryLabel?: string
}
interface TextbookSectionErrorBoundaryState {
hasError: boolean
}
export class TextbookSectionErrorBoundary extends Component<
TextbookSectionErrorBoundaryProps,
TextbookSectionErrorBoundaryState
> {
constructor(props: TextbookSectionErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
// 默认值为空字符串,强制调用方传入 i18n 文案
const title = this.props.fallbackTitle ?? ""
const description = this.props.fallbackDescription ?? ""
const retryLabel = this.props.retryLabel ?? ""
return (
// 任意值 min-h-[200px]:错误降级 UI 最小高度,保证视觉占位
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
{title && (
<p className="text-sm font-medium text-foreground">{title}</p>
export function TextbookSectionErrorBoundary({
children,
fallbackTitle,
fallbackDescription,
retryLabel,
}: TextbookSectionErrorBoundaryProps): ReactNode {
const fallback = (_error: Error, reset: () => void): ReactNode => (
<div
role="alert"
aria-live="assertive"
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center"
>
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
{fallbackTitle && (
<p className="text-sm font-medium text-foreground">{fallbackTitle}</p>
)}
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
{fallbackDescription && (
<p className="text-xs text-muted-foreground">{fallbackDescription}</p>
)}
{retryLabel && (
<Button size="sm" variant="outline" onClick={this.handleReset}>
<Button size="sm" variant="outline" onClick={reset}>
{retryLabel}
</Button>
)}
</div>
)
}
return this.props.children
}
return (
<SectionErrorBoundary fallback={fallback}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -44,7 +44,7 @@ interface TextbookCardProps {
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
const t = useTranslations("textbooks")
const router = useRouter()
const base = hrefBase || "/teacher/textbooks"
const base = hrefBase ?? "/teacher/textbooks" // 默认教师端,学生端通过 hrefBase prop 覆盖
const colorClass = getSubjectColor(textbook.subject)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)

View File

@@ -20,48 +20,67 @@ import {
} from "@/shared/components/ui/context-menu"
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
interface TextbookContentPanelProps {
/**
* 章节数据组:当前选中章节及其预处理后的内容。
*/
export interface ChapterDataGroup {
selected: Chapter | null
processedContent: string
}
/**
* 编辑状态组:章节内容编辑相关的状态与操作。
*/
export interface EditingStateGroup {
isEditing: boolean
editContent: string
setEditContent: (content: string) => void
canEdit: boolean
highlightedKpId: string | null
onHighlight: (id: string) => void
onSwitchToKnowledgeTab: () => void
isSaving: boolean
startEditing: () => void
cancelEditing: () => void
saveContent: () => void
}
/**
* 文本选区组:右键菜单与文本选区相关的状态与操作。
*/
export interface TextSelectionGroup {
contentRef: React.RefObject<HTMLDivElement | null>
onPointerDown: (e: React.PointerEvent) => void
onContextMenuChange: (open: boolean) => void
selectedText: string
setCreateDialogOpen: (open: boolean) => void
startEditing: () => void
cancelEditing: () => void
saveContent: () => void
isSaving: boolean
processedContent: string
}
/**
* 知识点高亮组:高亮跳转相关的状态与操作。
*/
export interface KpHighlightGroup {
highlightedKpId: string | null
onHighlight: (id: string) => void
onSwitchToKnowledgeTab: () => void
}
interface TextbookContentPanelProps {
chapter: ChapterDataGroup
editing: EditingStateGroup
selection: TextSelectionGroup
highlight: KpHighlightGroup
canEdit: boolean
}
export function TextbookContentPanel({
selected,
isEditing,
editContent,
setEditContent,
chapter,
editing,
selection,
highlight,
canEdit,
highlightedKpId,
onHighlight,
onSwitchToKnowledgeTab,
contentRef,
onPointerDown,
onContextMenuChange,
selectedText,
setCreateDialogOpen,
startEditing,
cancelEditing,
saveContent,
isSaving,
processedContent,
}: TextbookContentPanelProps) {
}: TextbookContentPanelProps): React.ReactNode {
const t = useTranslations("textbooks")
const { selected, processedContent } = chapter
const { isEditing, editContent, setEditContent, isSaving, startEditing, cancelEditing, saveContent } = editing
const { contentRef, onPointerDown, onContextMenuChange, selectedText, setCreateDialogOpen } = selection
const { highlightedKpId, onHighlight, onSwitchToKnowledgeTab } = highlight
if (!selected) {
return (

View File

@@ -14,18 +14,9 @@ import {
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { createTextbookAction } from "../actions"
import { SUBJECTS, GRADES } from "../constants"
import { toast } from "sonner"
import { TextbookFormFields } from "./textbook-form-fields"
function SubmitButton() {
const { pending } = useFormStatus()
@@ -71,65 +62,7 @@ export function TextbookFormDialog() {
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
</DialogHeader>
<form action={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
{t("field.title")}
</Label>
<Input
id="title"
name="title"
placeholder={t("field.titlePlaceholder")}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
{t("field.subject")}
</Label>
<Select name="subject" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
{t("field.grade")}
</Label>
<Select name="grade" required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.gradePlaceholder")} />
</SelectTrigger>
<SelectContent>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
{t("field.publisher")}
</Label>
<Input
id="publisher"
name="publisher"
placeholder={t("field.publisherPlaceholder")}
className="col-span-3"
/>
</div>
</div>
<TextbookFormFields />
<DialogFooter>
<SubmitButton />
</DialogFooter>

View File

@@ -0,0 +1,107 @@
"use client"
import { useTranslations } from "next-intl"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { SUBJECTS, GRADES } from "../constants"
/**
* 教材表单字段共享组件。
*
* 用于 TextbookFormDialog创建模式和 TextbookSettingsDialog编辑模式
* 消除 title/subject/grade/publisher 四个字段的 JSX 重复。
*
* - 创建模式:不传 defaultValues字段使用 placeholder
* - 编辑模式:传 defaultValues字段使用 defaultValue
*/
export interface TextbookFormFieldsProps {
/** 编辑模式下的默认值;创建模式不传 */
defaultValues?: {
title?: string | null
subject?: string | null
grade?: string | null
publisher?: string | null
}
}
export function TextbookFormFields({ defaultValues }: TextbookFormFieldsProps): React.ReactNode {
const t = useTranslations("textbooks")
const isEdit = defaultValues !== undefined
// null → undefined 转换Input/Select 的 defaultValue 不接受 null
const dv = defaultValues ?? {}
const title = dv.title ?? undefined
const subject = dv.subject ?? undefined
const grade = dv.grade ?? undefined
const publisher = dv.publisher ?? undefined
return (
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
{t("field.title")}
</Label>
<Input
id="title"
name="title"
defaultValue={title}
placeholder={isEdit ? undefined : t("field.titlePlaceholder")}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
{t("field.subject")}
</Label>
<Select name="subject" defaultValue={subject} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
{t("field.grade")}
</Label>
<Select name="grade" defaultValue={grade} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.gradePlaceholder")} />
</SelectTrigger>
<SelectContent>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
{t("field.publisher")}
</Label>
<Input
id="publisher"
name="publisher"
defaultValue={publisher}
placeholder={isEdit ? undefined : t("field.publisherPlaceholder")}
className="col-span-3"
/>
</div>
</div>
)
}

View File

@@ -53,12 +53,6 @@ export interface TextbookReaderProps {
* 必传,否则知识点面板将始终为空。
*/
textbookId: string
/**
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
* 保留 prop 仅为向后兼容,传入值会被忽略。
* @deprecated 改用权限系统自动判断
*/
canEdit?: boolean
/**
* 题目创建器渲染函数P0-1 解耦)。
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
@@ -176,9 +170,12 @@ export function TextbookReader({
const onCreateKnowledgePoint = async (formData: FormData) => {
setIsCreating(true)
try {
await handleCreateKnowledgePoint(formData)
} finally {
setIsCreating(false)
}
}
const handleSaveContent = async () => {
if (!selectedId || !textbookId) return
@@ -224,19 +221,13 @@ export function TextbookReader({
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
}, [effectiveContent, currentChapterKPs])
// P2 状态驱动:仅保留 scrollIntoView滚动无法声明式实现
// 视觉高亮已由 TextbookContentPanel 中 isHighlighted 状态驱动,无需命令式 classList 操作
useEffect(() => {
if (!highlightedKpId) return
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-offset-2")
const timer = setTimeout(() => {
el.classList.remove("ring-2", "ring-primary", "ring-offset-2")
}, 2000)
return () => {
clearTimeout(timer)
}
}, [highlightedKpId])
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs桌面端内联、移动端抽屉复用同一份
@@ -422,24 +413,29 @@ export function TextbookReader({
retryLabel={t("error.retry")}
>
<TextbookContentPanel
selected={selected}
isEditing={isEditing}
editContent={editContent}
setEditContent={setEditContent}
chapter={{ selected, processedContent }}
editing={{
isEditing,
editContent,
setEditContent,
isSaving,
startEditing,
cancelEditing: () => setIsEditing(false),
saveContent: handleSaveContent,
}}
selection={{
contentRef,
onPointerDown: handleContentPointerDown,
onContextMenuChange: handleContextMenuChange,
selectedText,
setCreateDialogOpen,
}}
highlight={{
highlightedKpId,
onHighlight: setHighlightedKpId,
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
}}
canEdit={canEdit}
highlightedKpId={highlightedKpId}
onHighlight={setHighlightedKpId}
onSwitchToKnowledgeTab={() => setActiveTab("knowledge")}
contentRef={contentRef}
onPointerDown={handleContentPointerDown}
onContextMenuChange={handleContextMenuChange}
selectedText={selectedText}
setCreateDialogOpen={setCreateDialogOpen}
startEditing={startEditing}
cancelEditing={() => setIsEditing(false)}
saveContent={handleSaveContent}
isSaving={isSaving}
processedContent={processedContent}
/>
</TextbookSectionErrorBoundary>
</div>

View File

@@ -24,26 +24,19 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { updateTextbookAction, deleteTextbookAction } from "../actions"
import { SUBJECTS, GRADES } from "../constants"
import { toast } from "sonner"
import type { Textbook } from "../types"
import { TextbookFormFields } from "./textbook-form-fields"
interface TextbookSettingsDialogProps {
textbook: Textbook
trigger?: React.ReactNode
/** 删除后跳转的 URL默认 "/teacher/textbooks" */
redirectAfterDelete?: string
}
export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDialogProps) {
export function TextbookSettingsDialog({ textbook, trigger, redirectAfterDelete = "/teacher/textbooks" }: TextbookSettingsDialogProps) {
const t = useTranslations("textbooks")
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
@@ -77,7 +70,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
if (result.success) {
toast.success(result.message)
router.push("/teacher/textbooks")
router.push(redirectAfterDelete)
} else {
toast.error(result.message)
}
@@ -107,65 +100,14 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
</DialogHeader>
<form action={handleUpdate}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
{t("field.title")}
</Label>
<Input
id="title"
name="title"
defaultValue={textbook.title}
className="col-span-3"
required
<TextbookFormFields
defaultValues={{
title: textbook.title,
subject: textbook.subject,
grade: textbook.grade,
publisher: textbook.publisher,
}}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="subject" className="text-right">
{t("field.subject")}
</Label>
<Select name="subject" defaultValue={textbook.subject} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.subjectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{SUBJECTS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{t(`subject.${s.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="grade" className="text-right">
{t("field.grade")}
</Label>
<Select name="grade" defaultValue={textbook.grade || undefined} required>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={t("field.gradePlaceholder")} />
</SelectTrigger>
<SelectContent>
{GRADES.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(`grade.${g.labelKey}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="publisher" className="text-right">
{t("field.publisher")}
</Label>
<Input
id="publisher"
name="publisher"
defaultValue={textbook.publisher || ""}
className="col-span-3"
/>
</div>
</div>
<DialogFooter className="flex justify-between sm:justify-between">
<Button

View File

@@ -37,6 +37,10 @@ export const SUBJECTS: readonly SubjectOption[] = [
export const GRADES: readonly GradeOption[] = [
{ value: "Grade 1", labelKey: "grade1" },
{ value: "Grade 2", labelKey: "grade2" },
{ value: "Grade 3", labelKey: "grade3" },
{ value: "Grade 4", labelKey: "grade4" },
{ value: "Grade 5", labelKey: "grade5" },
{ value: "Grade 6", labelKey: "grade6" },
{ value: "Grade 7", labelKey: "grade7" },
{ value: "Grade 8", labelKey: "grade8" },
{ value: "Grade 9", labelKey: "grade9" },

View File

@@ -6,7 +6,7 @@ import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
import { escapeLikePattern } from "@/shared/lib/action-utils"
import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils"
import type {
Chapter,
KnowledgePoint,
@@ -24,11 +24,12 @@ import type {
} from "./schema"
import {
buildChapterTree,
findChapterById,
normalizeOptional,
sortChapters,
} from "./utils"
export { buildChapterTree, normalizeOptional, sortChapters }
export { buildChapterTree, findChapterById, normalizeOptional, sortChapters }
/**
* 数据范围过滤参数。
@@ -185,7 +186,7 @@ export async function updateTextbook(data: UpdateTextbookInput): Promise<Textboo
.where(eq(textbooks.id, data.id))
const updated = await getTextbookById(data.id)
if (!updated) throw new Error("Textbook not found")
if (!updated) throw new NotFoundError("教材")
return updated
}
@@ -241,7 +242,7 @@ export async function updateChapterContent(data: UpdateChapterContentInput): Pro
.where(eq(chapters.id, data.chapterId))
.limit(1)
if (!row) throw new Error("Chapter not found")
if (!row) throw new NotFoundError("章节")
return {
id: row.id,
@@ -291,7 +292,26 @@ export async function deleteChapter(id: string): Promise<void> {
if (kids) stack.push(...kids)
}
// P0 数据完整性修复:先查询被删章节下的所有知识点 ID用于清理孤儿前置依赖记录
const kpsInChapters = await db
.select({ id: knowledgePoints.id })
.from(knowledgePoints)
.where(inArray(knowledgePoints.chapterId, idsToDelete))
const kpIds = kpsInChapters.map((k) => k.id)
await db.transaction(async (tx) => {
// P0 修复:清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录
// 包括作为 knowledgePointId 和作为 prerequisiteKpId 的记录
if (kpIds.length > 0) {
await tx
.delete(knowledgePointPrerequisites)
.where(
or(
inArray(knowledgePointPrerequisites.knowledgePointId, kpIds),
inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds),
),
)
}
await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
await tx.delete(chapters).where(inArray(chapters.id, idsToDelete))
})
@@ -380,7 +400,7 @@ export async function deleteKnowledgePoint(id: string): Promise<void> {
export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise<void> {
const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1)
if (!target) throw new Error("Chapter not found")
if (!target) throw new NotFoundError("章节")
const siblings = await db
.select()
@@ -453,23 +473,6 @@ export async function verifyChapterBelongsToTextbook(
return row.textbookId === textbookId
}
/**
* 校验知识点是否属于指定章节。
*/
export async function verifyKnowledgePointBelongsToChapter(
kpId: string,
chapterId: string
): Promise<boolean> {
const [row] = await db
.select({ chapterId: knowledgePoints.chapterId })
.from(knowledgePoints)
.where(eq(knowledgePoints.id, kpId))
.limit(1)
if (!row) return false
return row.chapterId === chapterId
}
/**
* 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。
*
@@ -658,5 +661,5 @@ export async function getPrerequisiteEdgesForTextbook(
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.where(eq(chapters.textbookId, textbookId))
return rows.map((r) => [r.knowledgePointId, r.prerequisiteKpId] as [string, string])
return rows.map((r): [string, string] => [r.knowledgePointId, r.prerequisiteKpId])
}

View File

@@ -7,11 +7,13 @@
import dagre from "@dagrejs/dagre"
import type { EdgeLabel, GraphLabel, NodeLabel } from "@dagrejs/dagre"
import type { Edge, Node } from "@xyflow/react"
import type { KpWithRelations } from "./types"
import type { GraphNodeData, KpWithRelations } from "./types"
export interface GraphLayoutNodeData {
kp: KpWithRelations
label: string
/** 图谱节点附加数据(掌握度、视图模式等),由 knowledge-graph.tsx 注入 */
graphData?: GraphNodeData
// 索引签名:满足 @xyflow/react Node<GraphLayoutNodeData> 的 Record<string, unknown> 约束
[key: string]: unknown
}
@@ -46,6 +48,9 @@ export function computeGraphLayout(
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
g.setDefaultEdgeLabel(() => ({}))
// P2 性能优化:用 Set 替代 O(n²) 的 some() 查找,降为 O(n)
const kpIdSet = new Set(knowledgePoints.map((kp) => kp.id))
// 添加节点
for (const kp of knowledgePoints) {
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
@@ -53,7 +58,7 @@ export function computeGraphLayout(
// 添加 parentId 边(树归属,实线)
for (const kp of knowledgePoints) {
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
if (kp.parentId && kpIdSet.has(kp.parentId)) {
g.setEdge(kp.parentId, kp.id)
}
}
@@ -61,7 +66,7 @@ export function computeGraphLayout(
// 添加 prerequisite 边(依赖,虚线箭头)
for (const kp of knowledgePoints) {
for (const prereqId of kp.prerequisiteIds) {
if (knowledgePoints.some((k) => k.id === prereqId)) {
if (kpIdSet.has(prereqId)) {
g.setEdge(prereqId, kp.id)
}
}
@@ -86,7 +91,7 @@ export function computeGraphLayout(
// parentId 边
for (const kp of knowledgePoints) {
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
if (kp.parentId && kpIdSet.has(kp.parentId)) {
edges.push({
id: `parent-${kp.parentId}-${kp.id}`,
source: kp.parentId,
@@ -100,7 +105,7 @@ export function computeGraphLayout(
// prerequisite 边
for (const kp of knowledgePoints) {
for (const prereqId of kp.prerequisiteIds) {
if (knowledgePoints.some((k) => k.id === prereqId)) {
if (kpIdSet.has(prereqId)) {
edges.push({
id: `prereq-${prereqId}-${kp.id}`,
source: prereqId,

View File

@@ -0,0 +1,51 @@
"use client"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { createKnowledgePointAction } from "../actions"
interface UseKpCreateArgs {
selectedChapterId: string | null
selectedChapterTextbookId: string | undefined
onKpCreated?: () => void
}
/**
* 知识点创建 Hook。
* 从 use-kp-crud 拆分,负责创建知识点的异步操作与 toast 反馈。
*/
export function useKpCreate({
selectedChapterId,
selectedChapterTextbookId,
onKpCreated,
}: UseKpCreateArgs) {
const t = useTranslations("textbooks")
const handleCreateKnowledgePoint = async (formData: FormData): Promise<boolean> => {
if (!selectedChapterId || !selectedChapterTextbookId) return false
try {
const result = await createKnowledgePointAction(
selectedChapterId,
selectedChapterTextbookId,
null,
formData,
)
if (result.success) {
toast.success(t("action.kpCreateSuccess"))
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
}
toast.error(result.message || t("action.kpCreateFailed"))
return false
} catch {
toast.error(t("action.errorOccurred"))
return false
}
}
return { handleCreateKnowledgePoint }
}

View File

@@ -1,15 +1,9 @@
"use client"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { KnowledgePoint } from "../types"
import {
createKnowledgePointAction,
deleteKnowledgePointAction,
updateKnowledgePointAction,
} from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state"
import { useKpCreate } from "./use-kp-create"
import { useKpDelete } from "./use-kp-delete"
import { useKpUpdate } from "./use-kp-update"
type DialogState = ReturnType<typeof useKpDialogState>
@@ -24,9 +18,10 @@ interface UseKpCrudArgs {
}
/**
* 知识点 CRUD 操作 Hook。
* 知识点 CRUD 操作 Hook(门面)
*
* 依赖 useKpDialogState 提供的对话框状态,执行创建/更新/删除操作。
* 组合 useKpCreate / useKpDelete / useKpUpdate 三个子 Hook
* 对外保持原有 API 不变。拆分后每个子 Hook 均在 80 行限制内。
*/
export function useKpCrud({
textbookId,
@@ -37,79 +32,23 @@ export function useKpCrud({
onKpCreated,
dialog,
}: UseKpCrudArgs) {
const t = useTranslations("textbooks")
const handleCreateKnowledgePoint = async (formData: FormData): Promise<boolean> => {
if (!selectedChapterId || !selectedChapterTextbookId) return false
try {
const result = await createKnowledgePointAction(
const { handleCreateKnowledgePoint } = useKpCreate({
selectedChapterId,
selectedChapterTextbookId,
null,
formData,
)
onKpCreated,
})
if (result.success) {
toast.success(t("action.kpCreateSuccess"))
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
}
toast.error(result.message || t("action.kpCreateFailed"))
return false
} catch {
toast.error(t("action.errorOccurred"))
return false
}
}
const { requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint } = useKpDelete({
textbookId,
highlightedKpId,
setHighlightedKpId,
dialog,
})
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent): void => {
e.stopPropagation()
dialog.setPendingDeleteKpId(kpId)
dialog.setDeleteConfirmOpen(true)
}
const confirmDeleteKnowledgePoint = async (): Promise<void> => {
if (!dialog.pendingDeleteKpId || !textbookId) return
dialog.setDeleteConfirmOpen(false)
try {
const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === dialog.pendingDeleteKpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.deleteFailed"))
} finally {
dialog.setPendingDeleteKpId(null)
}
}
const handleUpdateKnowledgePoint = async (formData: FormData): Promise<void> => {
if (!dialog.editingKp || !textbookId) return
dialog.setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
dialog.setEditKpDialogOpen(false)
dialog.setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.updateFailedGeneric"))
} finally {
dialog.setIsUpdatingKp(false)
}
}
const { handleUpdateKnowledgePoint } = useKpUpdate({
textbookId,
dialog,
})
return {
handleCreateKnowledgePoint,
@@ -118,5 +57,3 @@ export function useKpCrud({
handleUpdateKnowledgePoint,
}
}
export type { KnowledgePoint }

View File

@@ -0,0 +1,58 @@
"use client"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { deleteKnowledgePointAction } from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state"
type DialogState = ReturnType<typeof useKpDialogState>
interface UseKpDeleteArgs {
textbookId: string | undefined
highlightedKpId: string | null
setHighlightedKpId: (id: string | null) => void
dialog: DialogState
}
/**
* 知识点删除 Hook。
* 从 use-kp-crud 拆分,负责删除知识点的请求确认与异步执行。
*/
export function useKpDelete({
textbookId,
highlightedKpId,
setHighlightedKpId,
dialog,
}: UseKpDeleteArgs) {
const t = useTranslations("textbooks")
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent): void => {
e.stopPropagation()
dialog.setPendingDeleteKpId(kpId)
dialog.setDeleteConfirmOpen(true)
}
const confirmDeleteKnowledgePoint = async (): Promise<void> => {
if (!dialog.pendingDeleteKpId || !textbookId) return
dialog.setDeleteConfirmOpen(false)
try {
const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === dialog.pendingDeleteKpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.deleteFailed"))
} finally {
dialog.setPendingDeleteKpId(null)
}
}
return { requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint }
}

View File

@@ -0,0 +1,44 @@
"use client"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { updateKnowledgePointAction } from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state"
type DialogState = ReturnType<typeof useKpDialogState>
interface UseKpUpdateArgs {
textbookId: string | undefined
dialog: DialogState
}
/**
* 知识点更新 Hook。
* 从 use-kp-crud 拆分,负责更新知识点信息的异步操作与 toast 反馈。
*/
export function useKpUpdate({ textbookId, dialog }: UseKpUpdateArgs) {
const t = useTranslations("textbooks")
const handleUpdateKnowledgePoint = async (formData: FormData): Promise<void> => {
if (!dialog.editingKp || !textbookId) return
dialog.setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
dialog.setEditKpDialogOpen(false)
dialog.setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.updateFailedGeneric"))
} finally {
dialog.setIsUpdatingKp(false)
}
}
return { handleUpdateKnowledgePoint }
}

View File

@@ -54,15 +54,6 @@ export const UpdateKnowledgePointSchema = z.object({
export type UpdateKnowledgePointInput = z.infer<typeof UpdateKnowledgePointSchema>
export const ReorderChaptersSchema = z.object({
chapterId: z.string().min(1),
newIndex: z.coerce.number().int().min(0),
parentId: z.string().nullable(),
textbookId: z.string().min(1),
})
export type ReorderChaptersInput = z.infer<typeof ReorderChaptersSchema>
export const CreatePrerequisiteSchema = z.object({
knowledgePointId: z.string().min(1),
prerequisiteKpId: z.string().min(1),

View File

@@ -106,6 +106,30 @@ export function findChapterParent(
return null
}
/**
* 在章节树中递归查找指定 ID 的章节。
*
* P1-6 修复:从 lesson-preparation 4 个页面文件中提取的共享工具,
* 消除重复代码DRY 原则)。
*
* @param chapters 章节树(含 children 递归结构)
* @param id 目标章节 ID
* @returns 匹配的章节;未找到返回 undefined
*/
export function findChapterById(
chapters: Chapter[],
id: string
): Chapter | undefined {
for (const ch of chapters) {
if (ch.id === id) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapterById(ch.children, id)
if (found) return found
}
}
return undefined
}
/**
* 过滤出指定章节的知识点。
*/