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