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 {
|
import {
|
||||||
createQuestionWithRelations,
|
createQuestionWithRelations,
|
||||||
deleteQuestionByIdRecursive,
|
deleteQuestionByIdRecursive,
|
||||||
|
deleteQuestionsBatch,
|
||||||
|
exportQuestions,
|
||||||
|
getChapterOptions,
|
||||||
getKnowledgePointOptions,
|
getKnowledgePointOptions,
|
||||||
|
getKnowledgePointOptionsByChapter,
|
||||||
getQuestions,
|
getQuestions,
|
||||||
|
getTextbookOptions,
|
||||||
|
importQuestions,
|
||||||
updateQuestionById,
|
updateQuestionById,
|
||||||
type GetQuestionsParams,
|
type GetQuestionsParams,
|
||||||
|
type QuestionExportItem,
|
||||||
|
type QuestionImportItem,
|
||||||
} from "./data-access"
|
} 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 { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||||
|
import { trackQuestionCreated, trackQuestionUpdated, trackQuestionDeleted } from "./utils/track-event"
|
||||||
|
|
||||||
/** Result type of getQuestions (data + meta) */
|
/** Result type of getQuestions (data + meta) */
|
||||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
|
||||||
@@ -56,7 +66,10 @@ export async function createQuestionAction(
|
|||||||
|
|
||||||
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
const questionId = await createQuestionWithRelations(input, ctx.userId)
|
||||||
|
|
||||||
|
trackQuestionCreated(questionId, input.type, input.difficulty)
|
||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
revalidatePath("/admin/questions")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -102,7 +115,10 @@ export async function updateQuestionAction(
|
|||||||
|
|
||||||
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
|
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
|
||||||
|
|
||||||
|
trackQuestionUpdated(id, updateData.type, updateData.difficulty)
|
||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
revalidatePath("/admin/questions")
|
||||||
|
|
||||||
return { success: true, message: "Question updated successfully", data: id }
|
return { success: true, message: "Question updated successfully", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -125,7 +141,10 @@ export async function deleteQuestionAction(
|
|||||||
|
|
||||||
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
|
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
|
||||||
|
|
||||||
|
trackQuestionDeleted(questionId)
|
||||||
|
|
||||||
revalidatePath("/teacher/questions")
|
revalidatePath("/teacher/questions")
|
||||||
|
revalidatePath("/admin/questions")
|
||||||
|
|
||||||
return { success: true, message: "Question deleted successfully", data: questionId }
|
return { success: true, message: "Question deleted successfully", data: questionId }
|
||||||
} catch (e) {
|
} 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(
|
export async function getQuestionsAction(
|
||||||
params: GetQuestionsParams,
|
params: GetQuestionsParams,
|
||||||
): Promise<ActionState<QuestionsListResult>> {
|
): Promise<ActionState<QuestionsListResult>> {
|
||||||
@@ -156,3 +222,158 @@ export async function getKnowledgePointOptionsAction(): Promise<
|
|||||||
return handleActionError(e)
|
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"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
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"
|
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)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!hasPermission(Permissions.QUESTION_CREATE)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={() => setOpen(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Question
|
{t("addQuestion")}
|
||||||
</Button>
|
</Button>
|
||||||
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
|
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useForm, type SubmitHandler } from "react-hook-form"
|
import { useForm, type SubmitHandler } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,19 +16,20 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
import {
|
import { Form } from "@/shared/components/ui/form"
|
||||||
Form,
|
|
||||||
FormLabel,
|
|
||||||
} from "@/shared/components/ui/form"
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { SelectField } from "@/shared/components/form-fields/select-field"
|
import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||||
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
||||||
import { BaseQuestionSchema } from "../schema"
|
import { BaseQuestionSchema } from "../schema"
|
||||||
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
|
import { createQuestionAction, updateQuestionAction } from "../actions"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Question } from "../types"
|
import type { Question } from "../types"
|
||||||
import { useActionQuery } from "@/shared/hooks/use-action-query"
|
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({
|
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
@@ -39,7 +39,7 @@ const QuestionFormSchema = BaseQuestionSchema.extend({
|
|||||||
z.object({
|
z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
isCorrect: z.boolean().default(false),
|
isCorrect: z.boolean(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -56,39 +56,21 @@ interface CreateQuestionDialogProps {
|
|||||||
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
|
defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite"
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialTextFromContent(content: unknown) {
|
/**
|
||||||
if (typeof content === "string") return content
|
* 将 QuestionOption[] 转换为 QuestionOptionInput[](表单格式)。
|
||||||
if (content && typeof content === "object") {
|
*/
|
||||||
const text = (content as { text?: unknown }).text
|
function optionsToFormInput(options: QuestionOption[] | undefined): QuestionOptionInput[] {
|
||||||
if (typeof text === "string") return text
|
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 options.map((opt) => ({
|
||||||
return JSON.stringify(content)
|
label: opt.text,
|
||||||
}
|
value: opt.id,
|
||||||
|
isCorrect: opt.isCorrect,
|
||||||
function getInitialOptionsFromContent(content: unknown) {
|
}))
|
||||||
if (!content || typeof content !== "object") return undefined
|
|
||||||
const rawOptions = (content as { options?: unknown }).options
|
|
||||||
if (!Array.isArray(rawOptions)) return undefined
|
|
||||||
|
|
||||||
const mapped = rawOptions
|
|
||||||
.map((opt) => {
|
|
||||||
if (!opt || typeof opt !== "object") return null
|
|
||||||
const id =
|
|
||||||
(opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value
|
|
||||||
const text =
|
|
||||||
(opt as { text?: unknown; label?: unknown }).text ??
|
|
||||||
(opt as { label?: unknown }).label
|
|
||||||
const isCorrect = (opt as { isCorrect?: unknown }).isCorrect
|
|
||||||
return {
|
|
||||||
value: typeof id === "string" ? id : "",
|
|
||||||
label: typeof text === "string" ? text : "",
|
|
||||||
isCorrect: typeof isCorrect === "boolean" ? isCorrect : false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((v): v is NonNullable<typeof v> => Boolean(v && v.value && v.label))
|
|
||||||
|
|
||||||
return mapped.length > 0 ? mapped : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateQuestionDialog({
|
export function CreateQuestionDialog({
|
||||||
@@ -97,45 +79,32 @@ export function CreateQuestionDialog({
|
|||||||
initialData,
|
initialData,
|
||||||
defaultKnowledgePointIds = [],
|
defaultKnowledgePointIds = [],
|
||||||
defaultContent = "",
|
defaultContent = "",
|
||||||
defaultType = "single_choice"
|
defaultType = "single_choice",
|
||||||
}: CreateQuestionDialogProps) {
|
}: CreateQuestionDialogProps): React.ReactNode {
|
||||||
|
const t = useTranslations("questions")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
const isEdit = !!initialData
|
const isEdit = !!initialData
|
||||||
const [knowledgePointQuery, setKnowledgePointQuery] = useState("")
|
|
||||||
const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState<string[]>([])
|
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>({
|
const form = useForm<QuestionFormValues>({
|
||||||
resolver: zodResolver(QuestionFormSchema),
|
resolver: zodResolver(QuestionFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: initialData?.type || defaultType,
|
type: initialData?.type || defaultType,
|
||||||
difficulty: initialData?.difficulty || 1,
|
difficulty: initialData?.difficulty || 1,
|
||||||
content: getInitialTextFromContent(initialData?.content) || defaultContent,
|
content: parseQuestionContent(initialData?.content).text || defaultContent,
|
||||||
options:
|
options: optionsToFormInput(getQuestionOptions(initialData?.content)),
|
||||||
getInitialOptionsFromContent(initialData?.content) ?? [
|
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
|
const parsed = parseQuestionContent(initialData.content)
|
||||||
form.reset({
|
form.reset({
|
||||||
type: initialData.type,
|
type: initialData.type,
|
||||||
difficulty: initialData.difficulty,
|
difficulty: initialData.difficulty,
|
||||||
content: getInitialTextFromContent(initialData.content),
|
content: parsed.text,
|
||||||
options:
|
options: optionsToFormInput(parsed.options),
|
||||||
getInitialOptionsFromContent(initialData.content) ?? [
|
|
||||||
{ label: "Option A", value: "A", isCorrect: true },
|
|
||||||
{ label: "Option B", value: "B", isCorrect: false },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -153,44 +122,16 @@ export function CreateQuestionDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const nextIds = initialData.knowledgePoints.map((kp) => kp.id)
|
setSelectedKnowledgePointIds(initialData.knowledgePoints.map((kp) => kp.id))
|
||||||
setSelectedKnowledgePointIds((prev) => {
|
|
||||||
if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
return nextIds
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedKnowledgePointIds((prev) => {
|
setSelectedKnowledgePointIds(defaultKnowledgePointIds)
|
||||||
if (
|
|
||||||
prev.length === defaultKnowledgePointIds.length &&
|
|
||||||
prev.every((id, idx) => id === defaultKnowledgePointIds[idx])
|
|
||||||
) {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
return defaultKnowledgePointIds
|
|
||||||
})
|
|
||||||
}, [open, initialData, defaultKnowledgePointIds])
|
}, [open, initialData, defaultKnowledgePointIds])
|
||||||
|
|
||||||
const questionType = form.watch("type")
|
const questionType = form.watch("type")
|
||||||
const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => {
|
const formOptions = form.watch("options") ?? []
|
||||||
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 buildContent = (data: QuestionFormValues) => {
|
const buildContent = (data: QuestionFormValues): unknown => {
|
||||||
const text = data.content.trim()
|
const text = data.content.trim()
|
||||||
if (data.type === "single_choice" || data.type === "multiple_choice") {
|
if (data.type === "single_choice" || data.type === "multiple_choice") {
|
||||||
const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0)
|
const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0)
|
||||||
@@ -223,7 +164,7 @@ export function CreateQuestionDialog({
|
|||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
try {
|
try {
|
||||||
if (isEdit && !initialData?.id) {
|
if (isEdit && !initialData?.id) {
|
||||||
toast.error("Missing question id")
|
toast.error(t("error.missingId"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -239,18 +180,18 @@ export function CreateQuestionDialog({
|
|||||||
? await updateQuestionAction(undefined, fd)
|
? await updateQuestionAction(undefined, fd)
|
||||||
: await createQuestionAction(undefined, fd)
|
: await createQuestionAction(undefined, fd)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(isEdit ? "Updated question" : "Created question")
|
toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
form.reset()
|
form.reset()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Operation failed")
|
toast.error(res.message || t("error.operationFailed"))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to submit question", e)
|
console.error("Failed to submit question", e)
|
||||||
toast.error("Unexpected error")
|
toast.error(t("error.unexpected"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
@@ -260,9 +201,9 @@ export function CreateQuestionDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
|
<DialogTitle>{isEdit ? t("dialog.editTitle") : t("dialog.createTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -272,21 +213,21 @@ export function CreateQuestionDialog({
|
|||||||
<SelectField
|
<SelectField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="type"
|
name="type"
|
||||||
label="Question Type"
|
label={t("dialog.questionType")}
|
||||||
placeholder="Select type"
|
placeholder={t("dialog.selectType")}
|
||||||
options={[
|
options={[
|
||||||
{ value: "single_choice", label: "Single Choice" },
|
{ value: "single_choice", label: t("type.single_choice") },
|
||||||
{ value: "multiple_choice", label: "Multiple Choice" },
|
{ value: "multiple_choice", label: t("type.multiple_choice") },
|
||||||
{ value: "judgment", label: "True/False" },
|
{ value: "judgment", label: t("type.judgment") },
|
||||||
{ value: "text", label: "Short Answer" },
|
{ value: "text", label: t("type.text") },
|
||||||
{ value: "composite", label: "Composite" },
|
{ value: "composite", label: t("type.composite") },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="difficulty"
|
name="difficulty"
|
||||||
label="Difficulty (1-5)"
|
label={t("dialog.difficulty")}
|
||||||
placeholder="Select difficulty"
|
placeholder={t("dialog.selectDifficulty")}
|
||||||
toSelectValue={(v) => String(v)}
|
toSelectValue={(v) => String(v)}
|
||||||
fromSelectValue={(val) => {
|
fromSelectValue={(val) => {
|
||||||
const n = parseInt(val, 10)
|
const n = parseInt(val, 10)
|
||||||
@@ -294,7 +235,7 @@ export function CreateQuestionDialog({
|
|||||||
}}
|
}}
|
||||||
options={[1, 2, 3, 4, 5].map((level) => ({
|
options={[1, 2, 3, 4, 5].map((level) => ({
|
||||||
value: String(level),
|
value: String(level),
|
||||||
label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`,
|
label: `${level} - ${t(`difficulty.${level}`)}`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,147 +243,33 @@ export function CreateQuestionDialog({
|
|||||||
<TextareaField
|
<TextareaField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
label="Question Content"
|
label={t("dialog.questionContent")}
|
||||||
placeholder="Enter the question text here..."
|
placeholder={t("dialog.contentPlaceholder")}
|
||||||
description="Supports basic text. Rich text editor coming soon."
|
description={t("dialog.contentDescription")}
|
||||||
textareaClassName="min-h-[100px]"
|
textareaClassName="min-h-[100px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<KnowledgePointSelector
|
||||||
<div className="flex items-center justify-between">
|
selectedIds={selectedKnowledgePointIds}
|
||||||
<FormLabel>Knowledge Points</FormLabel>
|
onChange={setSelectedKnowledgePointIds}
|
||||||
<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>
|
|
||||||
|
|
||||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||||
<div className="space-y-4">
|
<OptionsEditor
|
||||||
<div className="flex items-center justify-between">
|
options={formOptions}
|
||||||
<FormLabel>Options</FormLabel>
|
onChange={(next) => form.setValue("options", next)}
|
||||||
<Button
|
singleChoice={questionType === "single_choice"}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
{t("dialog.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isPending}>
|
<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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</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"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
@@ -31,146 +32,161 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
|
||||||
import { Question } from "../types"
|
import type { Question } from "../types"
|
||||||
import { deleteQuestionAction } from "../actions"
|
import { deleteQuestionAction } from "../actions"
|
||||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
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"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface QuestionActionsProps {
|
interface QuestionActionsProps {
|
||||||
question: Question
|
question: Question
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
export function QuestionActions({ question }: QuestionActionsProps): React.ReactNode {
|
||||||
|
const t = useTranslations("questions")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { hasPermission } = usePermission()
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = 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 {
|
try {
|
||||||
navigator.clipboard.writeText(question.id)
|
navigator.clipboard.writeText(question.id)
|
||||||
toast.success("Question ID copied to clipboard")
|
toast.success(t("actions.copyIdSuccess"))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to copy question ID to clipboard", 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)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.set("questionId", question.id)
|
fd.set("questionId", question.id)
|
||||||
const res = await deleteQuestionAction(undefined, fd)
|
const res = await deleteQuestionAction(undefined, fd)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success("Question deleted successfully")
|
toast.success(t("actions.deleteSuccess"))
|
||||||
setShowDeleteDialog(false)
|
setShowDeleteDialog(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "Failed to delete question")
|
toast.error(res.message || t("actions.deleteFailed"))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete question", e)
|
console.error("Failed to delete question", e)
|
||||||
toast.error("Failed to delete question")
|
toast.error(t("actions.deleteFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const typeLabel = t(question.type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
|
<Button variant="ghost" className="h-8 w-8 p-0" aria-label={t("actions.menuLabel")}>
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">{t("actions.menuLabel")}</span>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("actions.actions")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onClick={copyId}>
|
<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>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||||
<Eye className="mr-2 h-4 w-4" /> View Details
|
<Eye className="mr-2 h-4 w-4" /> {t("actions.viewDetails")}
|
||||||
</DropdownMenuItem>
|
|
||||||
<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
|
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<CreateQuestionDialog
|
{canEdit && (
|
||||||
open={showEditDialog}
|
<CreateQuestionDialog
|
||||||
onOpenChange={setShowEditDialog}
|
open={showEditDialog}
|
||||||
initialData={question}
|
onOpenChange={setShowEditDialog}
|
||||||
/>
|
initialData={question}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
{canDelete && (
|
||||||
<AlertDialogContent>
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<AlertDialogHeader>
|
<AlertDialogContent>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogHeader>
|
||||||
<AlertDialogDescription>
|
<AlertDialogTitle>{t("actions.deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
This action cannot be undone. This will permanently delete the question
|
<AlertDialogDescription>
|
||||||
and remove it from our servers.
|
{t("actions.deleteConfirmDesc")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("actions.deleteConfirmCancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleDelete()
|
handleDelete()
|
||||||
}}
|
}}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? t("actions.deleting") : t("actions.deleteConfirmAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Question Details</DialogTitle>
|
<DialogTitle>{t("actions.detailsTitle")}</DialogTitle>
|
||||||
<DialogDescription>ID: {question.id}</DialogDescription>
|
<DialogDescription>{t("actions.detailsId", { id: question.id })}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Type:</span>
|
<span className="font-medium">{t("actions.detailsType")}</span>
|
||||||
<span className="col-span-3 capitalize">{question.type.replaceAll("_", " ")}</span>
|
<span className="col-span-3">{typeLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<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>
|
<span className="col-span-3">{question.difficulty}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 items-start gap-4">
|
<div className="grid grid-cols-4 items-start gap-4">
|
||||||
<span className="font-medium pt-1">Content:</span>
|
<span className="font-medium pt-1">{t("actions.detailsContent")}</span>
|
||||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
<div className="col-span-3 rounded-md bg-muted p-3">
|
||||||
{typeof question.content === "string"
|
<QuestionContentRenderer question={question} showAnswer />
|
||||||
? question.content
|
|
||||||
: JSON.stringify(question.content, null, 2)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{question.author && (
|
{question.author && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
<span className="font-medium">Author:</span>
|
<span className="font-medium">{t("actions.detailsAuthor")}</span>
|
||||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
<span className="col-span-3">{question.author.name || t("actions.detailsUnknown")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<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">
|
<div className="col-span-3 flex flex-wrap gap-1">
|
||||||
{question.knowledgePoints.map(kp => (
|
{question.knowledgePoints.map(kp => (
|
||||||
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
<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"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { ColumnDef } from "@tanstack/react-table"
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { Question } from "../types"
|
import type { Question } from "../types"
|
||||||
import { QUESTION_TYPE_VARIANT, QUESTION_TYPE_LABEL } from "../types"
|
import { QUESTION_TYPE_VARIANT } from "../types"
|
||||||
|
import { getQuestionPreview } from "../utils/parse-content"
|
||||||
import { QuestionActions } from "./question-actions"
|
import { QuestionActions } from "./question-actions"
|
||||||
|
|
||||||
export const columns: ColumnDef<Question>[] = [
|
/**
|
||||||
{
|
* 题目表格列定义。
|
||||||
id: "select",
|
*
|
||||||
header: ({ table }) => (
|
* 使用函数形式以获取 i18n 翻译函数。
|
||||||
<Checkbox
|
* 调用方通过 `useQuestionColumns()` 获取列定义。
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
*/
|
||||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
export function useQuestionColumns(): ColumnDef<Question>[] {
|
||||||
aria-label="Select all"
|
const t = useTranslations("questions")
|
||||||
/>
|
|
||||||
),
|
return useMemo(() => [
|
||||||
cell: ({ row }) => (
|
{
|
||||||
<Checkbox
|
id: "select",
|
||||||
checked={row.getIsSelected()}
|
header: ({ table }) => (
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
<Checkbox
|
||||||
aria-label="Select row"
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
/>
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
),
|
aria-label={t("actions.menuLabel")}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label={t("actions.menuLabel")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
accessorKey: "type",
|
||||||
accessorKey: "content",
|
header: t("table.type"),
|
||||||
header: "Content",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const type = row.original.type
|
||||||
const content = row.original.content
|
return (
|
||||||
let preview = ""
|
<StatusBadge
|
||||||
if (typeof content === "string") {
|
status={type}
|
||||||
preview = content
|
variantMap={QUESTION_TYPE_VARIANT}
|
||||||
} else if (content && typeof content === "object") {
|
labelMap={{ [type]: t(type) }}
|
||||||
const text = (content as { text?: unknown }).text
|
className="whitespace-nowrap"
|
||||||
if (typeof text === "string") {
|
capitalize={false}
|
||||||
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: "content",
|
||||||
accessorKey: "difficulty",
|
header: t("table.content"),
|
||||||
header: "Difficulty",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const preview = getQuestionPreview(row.original.content, 80)
|
||||||
const diff = row.original.difficulty
|
return (
|
||||||
const label =
|
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||||
diff === 1
|
{preview}
|
||||||
? "Easy"
|
</div>
|
||||||
: 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: "difficulty",
|
||||||
accessorKey: "knowledgePoints",
|
header: t("table.difficulty"),
|
||||||
header: "Knowledge Points",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const diff = row.original.difficulty
|
||||||
const kps = row.original.knowledgePoints
|
const label = t(`difficulty.${diff}`)
|
||||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
return (
|
<Badge variant="outline" className="tabular-nums">
|
||||||
<div className="flex flex-wrap gap-1">
|
{label}
|
||||||
{kps.slice(0, 2).map((kp) => (
|
|
||||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
|
||||||
{kp.name}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
<span className="text-xs text-muted-foreground tabular-nums">({diff})</span>
|
||||||
{kps.length > 2 && (
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
)
|
||||||
+{kps.length - 2}
|
},
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
accessorKey: "knowledgePoints",
|
||||||
accessorKey: "createdAt",
|
header: t("table.knowledgePoints"),
|
||||||
header: "Created",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const kps = row.original.knowledgePoints
|
||||||
const createdAt = row.original.createdAt
|
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
return (
|
||||||
{createdAt instanceof Date
|
<div className="flex flex-wrap gap-1">
|
||||||
? formatDate(createdAt)
|
{kps.slice(0, 2).map((kp) => (
|
||||||
: typeof createdAt === "string"
|
<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)
|
? formatDate(createdAt)
|
||||||
: "—"}
|
: typeof createdAt === "string"
|
||||||
</span>
|
? formatDate(createdAt)
|
||||||
)
|
: "—"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "actions",
|
||||||
id: "actions",
|
cell: ({ row }) => <QuestionActions question={row.original} />,
|
||||||
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"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -23,16 +24,21 @@ import {
|
|||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { BatchOperations } from "./batch-operations"
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[]
|
columns: ColumnDef<TData, TValue>[]
|
||||||
data: TData[]
|
data: TData[]
|
||||||
|
/** 获取行 ID 的函数(用于批量操作) */
|
||||||
|
getRowId?: (row: TData) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionDataTable<TData, TValue>({
|
export function QuestionDataTable<TData, TValue>({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
}: DataTableProps<TData, TValue>) {
|
getRowId,
|
||||||
|
}: DataTableProps<TData, TValue>): React.ReactNode {
|
||||||
|
const t = useTranslations("questions")
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||||
|
|
||||||
@@ -45,14 +51,27 @@ export function QuestionDataTable<TData, TValue>({
|
|||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getRowId: getRowId ? (row) => getRowId(row) : undefined,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
rowSelection,
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<BatchOperations selectedIds={selectedIds} onClearSelection={clearSelection} />
|
||||||
|
)}
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -96,7 +115,7 @@ export function QuestionDataTable<TData, TValue>({
|
|||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="h-24 text-center"
|
className="h-24 text-center"
|
||||||
>
|
>
|
||||||
No results.
|
{t("table.noResults")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -105,8 +124,10 @@ export function QuestionDataTable<TData, TValue>({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
<div className="flex-1 text-sm text-muted-foreground">
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
{t("table.selected", {
|
||||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
count: table.getFilteredSelectedRowModel().rows.length,
|
||||||
|
total: table.getFilteredRowModel().rows.length,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -116,7 +137,7 @@ export function QuestionDataTable<TData, TValue>({
|
|||||||
disabled={!table.getCanPreviousPage()}
|
disabled={!table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
{t("table.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -124,7 +145,7 @@ export function QuestionDataTable<TData, TValue>({
|
|||||||
onClick={() => table.nextPage()}
|
onClick={() => table.nextPage()}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
Next
|
{t("table.next")}
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -11,28 +11,27 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
import { getKnowledgePointOptionsAction } from "../actions"
|
import { QuestionCascadeFilter } from "./question-cascade-filter"
|
||||||
import type { KnowledgePointOption } from "../types"
|
|
||||||
|
/**
|
||||||
|
* 题库筛选栏。
|
||||||
|
*
|
||||||
|
* 组合了:
|
||||||
|
* - 搜索框(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 [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||||
const [difficulty, setDifficulty] = useQueryState("difficulty", 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(
|
const hasFilters = Boolean(
|
||||||
search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all",
|
search || type !== "all" || difficulty !== "all",
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,60 +43,43 @@ export function QuestionFilters() {
|
|||||||
setSearch(null)
|
setSearch(null)
|
||||||
setType(null)
|
setType(null)
|
||||||
setDifficulty(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
|
<FilterSearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(v) => setSearch(v || null)}
|
onChange={(v) => setSearch(v || null)}
|
||||||
placeholder="Search questions..."
|
placeholder={t("search.placeholder")}
|
||||||
className="flex-1 md:max-w-sm"
|
className="flex-1 md:max-w-sm"
|
||||||
inputClassName="border-muted-foreground/20 pl-8"
|
inputClassName="border-muted-foreground/20 pl-8"
|
||||||
/>
|
/>
|
||||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[150px]" aria-label={t("filters.type")}>
|
||||||
<SelectValue placeholder="Type" />
|
<SelectValue placeholder={t("filters.type")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">{t("filters.typeAll")}</SelectItem>
|
||||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
<SelectItem value="single_choice">{t("type.single_choice")}</SelectItem>
|
||||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
<SelectItem value="multiple_choice">{t("type.multiple_choice")}</SelectItem>
|
||||||
<SelectItem value="judgment">True/False</SelectItem>
|
<SelectItem value="judgment">{t("type.judgment")}</SelectItem>
|
||||||
<SelectItem value="text">Short Answer</SelectItem>
|
<SelectItem value="text">{t("type.text")}</SelectItem>
|
||||||
<SelectItem value="composite">Composite</SelectItem>
|
<SelectItem value="composite">{t("type.composite")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[150px]" aria-label={t("filters.difficulty")}>
|
||||||
<SelectValue placeholder="Difficulty" />
|
<SelectValue placeholder={t("filters.difficulty")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
<SelectItem value="all">{t("filters.difficultyAll")}</SelectItem>
|
||||||
<SelectItem value="1">Easy (1)</SelectItem>
|
<SelectItem value="1">{t("filters.difficulty1")}</SelectItem>
|
||||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
<SelectItem value="2">{t("filters.difficulty2")}</SelectItem>
|
||||||
<SelectItem value="3">Medium (3)</SelectItem>
|
<SelectItem value="3">{t("filters.difficulty3")}</SelectItem>
|
||||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
<SelectItem value="4">{t("filters.difficulty4")}</SelectItem>
|
||||||
<SelectItem value="5">Hard (5)</SelectItem>
|
<SelectItem value="5">{t("filters.difficulty5")}</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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<QuestionCascadeFilter />
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared
|
|||||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
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 { 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]
|
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
|
|
||||||
@@ -24,6 +30,8 @@ export type GetQuestionsParams = {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
knowledgePointId?: string;
|
knowledgePointId?: string;
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
type?: QuestionType;
|
type?: QuestionType;
|
||||||
difficulty?: number;
|
difficulty?: number;
|
||||||
};
|
};
|
||||||
@@ -34,6 +42,8 @@ export const getQuestions = cache(async ({
|
|||||||
pageSize = 50,
|
pageSize = 50,
|
||||||
ids,
|
ids,
|
||||||
knowledgePointId,
|
knowledgePointId,
|
||||||
|
textbookId,
|
||||||
|
chapterId,
|
||||||
type,
|
type,
|
||||||
difficulty,
|
difficulty,
|
||||||
}: GetQuestionsParams = {}) => {
|
}: GetQuestionsParams = {}) => {
|
||||||
@@ -62,13 +72,53 @@ export const getQuestions = cache(async ({
|
|||||||
conditions.push(eq(questions.difficulty, difficulty));
|
conditions.push(eq(questions.difficulty, difficulty));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 级联筛选:通过 textbooks data-access 获取知识点 ID 集合,
|
||||||
|
// 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。
|
||||||
if (knowledgePointId) {
|
if (knowledgePointId) {
|
||||||
const subQuery = db
|
conditions.push(
|
||||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
inArray(
|
||||||
.from(questionsToKnowledgePoints)
|
questions.id,
|
||||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
|
db
|
||||||
|
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||||
conditions.push(inArray(questions.id, subQuery));
|
.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) {
|
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[]> {
|
export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]> {
|
||||||
// Delegate to textbooks module data-access to avoid direct queries on
|
// Delegate to textbooks module data-access to avoid direct queries on
|
||||||
// textbooks/chapters/knowledgePoints tables (owned by textbooks module).
|
// textbooks/chapters/knowledgePoints tables (owned by textbooks module).
|
||||||
return await getKnowledgePointOptionsFromTextbooks()
|
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
|
// Cross-module query interfaces — read-only access for other modules
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -355,3 +498,136 @@ export const getQuestionsContentForErrorCollection = cache(
|
|||||||
return result
|
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 { 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"
|
import { QuestionTypeEnum } from "./schema"
|
||||||
|
|
||||||
|
export type { QuestionContent, QuestionOption } from "./utils/parse-content"
|
||||||
|
|
||||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
export type QuestionType = z.infer<typeof QuestionTypeEnum>
|
||||||
|
|
||||||
/** 题型 → Badge variant 映射 */
|
/** 题型 → Badge variant 映射 */
|
||||||
@@ -13,13 +15,22 @@ export const QUESTION_TYPE_VARIANT: StatusVariantMap<QuestionType> = {
|
|||||||
composite: "secondary",
|
composite: "secondary",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 题型 → 展示文本映射 */
|
/** 题型 → i18n 键映射(供 useTranslations 使用) */
|
||||||
export const QUESTION_TYPE_LABEL: StatusLabelMap<QuestionType> = {
|
export const QUESTION_TYPE_I18N_KEY: Record<QuestionType, string> = {
|
||||||
single_choice: "Single Choice",
|
single_choice: "type.single_choice",
|
||||||
multiple_choice: "Multiple Choice",
|
multiple_choice: "type.multiple_choice",
|
||||||
judgment: "True/False",
|
judgment: "type.judgment",
|
||||||
text: "Short Answer",
|
text: "type.text",
|
||||||
composite: "Composite",
|
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 {
|
export interface Question {
|
||||||
@@ -51,3 +62,20 @@ export type KnowledgePointOption = {
|
|||||||
subject: string | null
|
subject: string | null
|
||||||
grade: 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"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react"
|
import type { ReactNode } from "react"
|
||||||
import { toast } from "sonner"
|
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { parseAsString, useQueryState } from "nuqs"
|
import { parseAsString, useQueryState } from "nuqs"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
|
||||||
import type { GradeOverviewStats } from "../data-access"
|
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 { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -25,49 +26,8 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/components/ui/dropdown-menu"
|
} 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"
|
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({
|
export function GradesClient({
|
||||||
grades,
|
grades,
|
||||||
schools,
|
schools,
|
||||||
@@ -81,19 +41,33 @@ export function GradesClient({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("school")
|
const t = useTranslations("school")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
const {
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
createOpen,
|
||||||
const [editItem, setEditItem] = useState<GradeListItem | null>(null)
|
editItem,
|
||||||
const [deleteItem, setDeleteItem] = useState<GradeListItem | null>(null)
|
deleteItem,
|
||||||
|
setCreateOpen,
|
||||||
|
setEditItem,
|
||||||
|
setDeleteItem,
|
||||||
|
isWorking,
|
||||||
|
} = useGradeData()
|
||||||
|
|
||||||
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
const [q, setQ] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all"))
|
const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all"))
|
||||||
const [head, setHead] = useQueryState("head", parseAsString.withDefault("all"))
|
const [head, setHead] = useQueryState("head", parseAsString.withDefault("all"))
|
||||||
const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default"))
|
const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default"))
|
||||||
|
|
||||||
const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools])
|
// 表单对话框会话 key:每次打开对话框时递增,强制 GradeFormDialog 重新挂载以重置表单状态
|
||||||
const [createState, setCreateState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
const [formSession, setFormSession] = useState(0)
|
||||||
const [editState, setEditState] = useState<FormState>(() => toFormState(null, defaultSchoolId))
|
|
||||||
|
const openCreate = (): void => {
|
||||||
|
setFormSession((s) => s + 1)
|
||||||
|
setCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (item: GradeListItem): void => {
|
||||||
|
setFormSession((s) => s + 1)
|
||||||
|
setEditItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
// 年级概览统计映射,用于卡片视图
|
// 年级概览统计映射,用于卡片视图
|
||||||
const statsMap = useMemo(() => {
|
const statsMap = useMemo(() => {
|
||||||
@@ -102,68 +76,6 @@ export function GradesClient({
|
|||||||
return m
|
return m
|
||||||
}, [gradeStats])
|
}, [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 filteredGrades = useMemo(() => {
|
||||||
const needle = q.trim().toLowerCase()
|
const needle = q.trim().toLowerCase()
|
||||||
const bySchool = school === "all" ? "" : school
|
const bySchool = school === "all" ? "" : school
|
||||||
@@ -207,321 +119,68 @@ export function GradesClient({
|
|||||||
|
|
||||||
const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default"
|
const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default"
|
||||||
|
|
||||||
const openEdit = (item: GradeListItem) => {
|
const handleResetFilters = (): void => {
|
||||||
setEditItem(item)
|
setQ(null)
|
||||||
setEditState(toFormState(item, defaultSchoolId))
|
setSchool(null)
|
||||||
|
setHead(null)
|
||||||
|
setSort(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const handleFormOpenChange = (open: boolean): void => {
|
||||||
setCreateState(toFormState(null, defaultSchoolId))
|
if (!open) {
|
||||||
setCreateOpen(true)
|
setCreateOpen(false)
|
||||||
|
setEditItem(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createValidation = useMemo(
|
const handleDeleteOpenChange = (open: boolean): void => {
|
||||||
() => validateForm(createState, { grades }),
|
if (!open) {
|
||||||
[createState, grades, validateForm]
|
setDeleteItem(null)
|
||||||
)
|
}
|
||||||
const editValidation = useMemo(
|
}
|
||||||
() => validateForm(editState, { grades, excludeGradeId: editItem?.id }),
|
|
||||||
[editItem?.id, editState, grades, validateForm]
|
|
||||||
)
|
|
||||||
|
|
||||||
const isEditDirty = useMemo(() => {
|
const handleSuccess = (): void => {
|
||||||
if (!editItem) return false
|
router.refresh()
|
||||||
const next = {
|
}
|
||||||
schoolId: editState.schoolId.trim(),
|
|
||||||
name: normalizeName(editState.name),
|
const formatStaffDetail = (u: StaffOption | null): ReactNode => {
|
||||||
order: parseOrder(editState.order),
|
if (!u) return <Badge variant="outline">{t("grades.notSet")}</Badge>
|
||||||
gradeHeadId: editState.gradeHeadId || "",
|
|
||||||
teachingHeadId: editState.teachingHeadId || "",
|
|
||||||
}
|
|
||||||
const prev = {
|
|
||||||
schoolId: editItem.school.id,
|
|
||||||
name: normalizeName(editItem.name),
|
|
||||||
order: editItem.order,
|
|
||||||
gradeHeadId: editItem.gradeHead?.id ?? "",
|
|
||||||
teachingHeadId: editItem.teachingHead?.id ?? "",
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
next.schoolId !== prev.schoolId ||
|
<div className="min-w-0">
|
||||||
next.name !== prev.name ||
|
<div className="truncate">{u.name}</div>
|
||||||
(typeof next.order === "number" ? next.order : null) !== prev.order ||
|
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
|
||||||
next.gradeHeadId !== prev.gradeHeadId ||
|
</div>
|
||||||
next.teachingHeadId !== prev.teachingHeadId
|
|
||||||
)
|
)
|
||||||
}, [editItem, editState])
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
const validation = validateForm(createState, { grades })
|
|
||||||
if (!validation.ok) {
|
|
||||||
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsWorking(true)
|
|
||||||
try {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.set("schoolId", createState.schoolId)
|
|
||||||
fd.set("name", normalizeName(createState.name))
|
|
||||||
fd.set("order", createState.order)
|
|
||||||
fd.set("gradeHeadId", createState.gradeHeadId)
|
|
||||||
fd.set("teachingHeadId", createState.teachingHeadId)
|
|
||||||
|
|
||||||
const res = await createGradeAction(undefined, fd)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */}
|
{/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */}
|
||||||
{filteredGrades.length > 0 && (
|
{filteredGrades.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<GradeOverviewCards
|
||||||
{filteredGrades.slice(0, 8).map((g) => {
|
grades={filteredGrades}
|
||||||
const stats = statsMap.get(g.id)
|
statsMap={statsMap}
|
||||||
return (
|
isWorking={isWorking}
|
||||||
<Card key={g.id} className="shadow-none">
|
onEdit={openEdit}
|
||||||
<CardContent className="space-y-3 p-4">
|
onDelete={setDeleteItem}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<GradeListToolbar
|
||||||
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
|
q={q}
|
||||||
<div className="flex-1 md:max-w-sm">
|
setQ={setQ}
|
||||||
<Input placeholder={t("grades.filters.search")} value={q} onChange={(e) => setQ(e.target.value || null)} />
|
school={school}
|
||||||
</div>
|
setSchool={setSchool}
|
||||||
|
head={head}
|
||||||
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
|
setHead={setHead}
|
||||||
<SelectTrigger className="w-full md:w-[220px]">
|
sort={sort}
|
||||||
<SelectValue placeholder={t("grades.filters.school")} />
|
setSort={setSort}
|
||||||
</SelectTrigger>
|
hasFilters={hasFilters}
|
||||||
<SelectContent>
|
onReset={handleResetFilters}
|
||||||
<SelectItem value="all">{t("grades.filters.allSchools")}</SelectItem>
|
schools={schools}
|
||||||
{schools.map((s) => (
|
onCreate={openCreate}
|
||||||
<SelectItem key={s.id} value={s.id}>
|
isWorking={isWorking}
|
||||||
{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>
|
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
@@ -554,368 +213,82 @@ export function GradesClient({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("grades.column.school")}</TableHead>
|
<TableHead>{t("grades.column.school")}</TableHead>
|
||||||
<TableHead>{t("grades.column.grade")}</TableHead>
|
<TableHead>{t("grades.column.grade")}</TableHead>
|
||||||
<TableHead>{t("grades.column.order")}</TableHead>
|
<TableHead>{t("grades.column.order")}</TableHead>
|
||||||
<TableHead>{t("grades.column.gradeHead")}</TableHead>
|
<TableHead>{t("grades.column.gradeHead")}</TableHead>
|
||||||
<TableHead>{t("grades.column.teachingHead")}</TableHead>
|
<TableHead>{t("grades.column.teachingHead")}</TableHead>
|
||||||
<TableHead>{t("grades.column.updated")}</TableHead>
|
<TableHead>{t("grades.column.updated")}</TableHead>
|
||||||
<TableHead className="w-[60px]" />
|
<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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<GradeFormDialog
|
||||||
<DialogContent className="sm:max-w-[560px]">
|
key={formSession}
|
||||||
<DialogHeader>
|
open={createOpen || Boolean(editItem)}
|
||||||
<DialogTitle>{t("grades.form.createTitle")}</DialogTitle>
|
onOpenChange={handleFormOpenChange}
|
||||||
</DialogHeader>
|
editItem={editItem}
|
||||||
<form
|
schools={schools}
|
||||||
className="space-y-4"
|
staff={staff}
|
||||||
onSubmit={(e) => {
|
grades={grades}
|
||||||
e.preventDefault()
|
onSuccess={handleSuccess}
|
||||||
void handleCreate()
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
|
||||||
<Label className="text-right">{t("grades.form.school")}</Label>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<Select
|
|
||||||
value={createState.schoolId}
|
|
||||||
onValueChange={(v) => setCreateState((p) => ({ ...p, schoolId: v }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("grades.form.school")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{schools.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>
|
|
||||||
{s.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{createValidation.errors.schoolId ? (
|
|
||||||
<div className="col-span-3 col-start-2 text-sm font-medium text-destructive">
|
|
||||||
{createValidation.errors.schoolId}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<GradeDeleteDialog
|
||||||
<Label htmlFor="create-grade-name" className="text-right">
|
deleteItem={deleteItem}
|
||||||
{t("grades.form.name")}
|
onOpenChange={handleDeleteOpenChange}
|
||||||
</Label>
|
onSuccess={handleSuccess}
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,57 @@
|
|||||||
"use client"
|
"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 { AlertCircle } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
interface SchoolErrorBoundaryProps {
|
interface SchoolErrorBoundaryProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
fallback?: ReactNode
|
fallback?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SchoolErrorBoundaryState {
|
export function SchoolErrorBoundary({
|
||||||
hasError: boolean
|
children,
|
||||||
}
|
fallback,
|
||||||
|
}: SchoolErrorBoundaryProps): ReactNode {
|
||||||
function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element {
|
|
||||||
const t = useTranslations("school")
|
const t = useTranslations("school")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const handleRetry = (): void => {
|
const customFallback = (_error: Error, reset: () => void): ReactNode => {
|
||||||
onReset()
|
const handleRetry = (): void => {
|
||||||
router.refresh()
|
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 (
|
return (
|
||||||
<div
|
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
|
||||||
role="alert"
|
{children}
|
||||||
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
|
</SectionErrorBoundary>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
"use server"
|
||||||
|
|
||||||
import { unlink } from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { storageProvider } from "@/shared/lib/storage-provider"
|
||||||
import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access"
|
import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access"
|
||||||
import {
|
import {
|
||||||
deleteFileAttachment,
|
deleteFileAttachment,
|
||||||
@@ -16,6 +15,9 @@ import {
|
|||||||
/**
|
/**
|
||||||
* 清理旧头像文件(磁盘 + DB 记录)
|
* 清理旧头像文件(磁盘 + DB 记录)
|
||||||
* 静默失败,不影响主流程
|
* 静默失败,不影响主流程
|
||||||
|
*
|
||||||
|
* P1-4:磁盘删除统一走 storageProvider 抽象,
|
||||||
|
* 不再直接 import fs/promises。
|
||||||
*/
|
*/
|
||||||
async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
||||||
if (!oldImageUrl) return
|
if (!oldImageUrl) return
|
||||||
@@ -23,17 +25,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
|||||||
const fileRecord = await getFileByUrl(oldImageUrl)
|
const fileRecord = await getFileByUrl(oldImageUrl)
|
||||||
if (!fileRecord) return
|
if (!fileRecord) return
|
||||||
|
|
||||||
// 删除磁盘文件
|
// 删除磁盘文件(通过 storageProvider 抽象)
|
||||||
const absolutePath = path.join(
|
await storageProvider.delete(fileRecord.storagePath)
|
||||||
process.cwd(),
|
|
||||||
"public",
|
|
||||||
fileRecord.storagePath,
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
await unlink(absolutePath)
|
|
||||||
} catch {
|
|
||||||
// 文件可能已不存在,忽略错误
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除 DB 记录
|
// 删除 DB 记录
|
||||||
await deleteFileAttachment(fileRecord.id)
|
await deleteFileAttachment(fileRecord.id)
|
||||||
@@ -45,7 +38,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* 更新用户头像 URL
|
* 更新用户头像 URL
|
||||||
*
|
*
|
||||||
* 实际文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段。
|
* 实际文件上传通过 /api/upload 路由完成(targetType="user_avatar"),
|
||||||
|
* 此 action 仅更新 users.image 字段。
|
||||||
* 更新成功后会清理旧头像文件(磁盘 + DB 记录)。
|
* 更新成功后会清理旧头像文件(磁盘 + DB 记录)。
|
||||||
*/
|
*/
|
||||||
export async function updateUserAvatarAction(
|
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 { validatePassword } from "@/shared/lib/password-policy"
|
||||||
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||||
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
|
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
|
||||||
|
import { checkBreachedPassword } from "@/shared/lib/breached-password"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getPasswordSecurityByUserId,
|
getPasswordSecurityByUserId,
|
||||||
@@ -38,7 +39,7 @@ export async function changePasswordAction(
|
|||||||
const userId = ctx.userId
|
const userId = ctx.userId
|
||||||
|
|
||||||
const limitKey = rateLimitKey("pwd-change", 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) {
|
if (!limit.success) {
|
||||||
return { success: false, message: "Too many attempts. Please try again later." }
|
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" }
|
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
|
// Parallelize user and passwordSecurity queries
|
||||||
const [userRecord, existingSecurity] = await Promise.all([
|
const [userRecord, existingSecurity] = await Promise.all([
|
||||||
getUserPasswordHash(userId),
|
getUserPasswordHash(userId),
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import type { ActionState } from "@/shared/types/action-state"
|
|||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { db } from "@/shared/db"
|
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 { logLoginEvent } from "@/shared/lib/login-logger"
|
||||||
|
import { trackAuthEvent } from "@/shared/lib/track-event"
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -239,6 +240,11 @@ export async function verifyTwoFactorAction(
|
|||||||
|
|
||||||
const status = await getTwoFactorStatus(ctx.userId)
|
const status = await getTwoFactorStatus(ctx.userId)
|
||||||
|
|
||||||
|
// audit-P1-9:2FA 启用埋点(用于 2FA 启用率统计)
|
||||||
|
await trackAuthEvent("auth.2fa_enabled", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { backupCodes, status },
|
data: { backupCodes, status },
|
||||||
@@ -298,6 +304,12 @@ export async function disableTwoFactorAction(
|
|||||||
revalidatePath("/settings")
|
revalidatePath("/settings")
|
||||||
|
|
||||||
const status = await getTwoFactorStatus(ctx.userId)
|
const status = await getTwoFactorStatus(ctx.userId)
|
||||||
|
|
||||||
|
// audit-P1-9:2FA 禁用埋点(用于 2FA 禁用率告警,可能表明账户安全降级)
|
||||||
|
await trackAuthEvent("auth.2fa_disabled", {
|
||||||
|
userId: ctx.userId,
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, data: status }
|
return { success: true, data: status }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -369,27 +381,20 @@ export async function revokeAllOtherSessionsAction(): Promise<
|
|||||||
return { success: false, message: "User not found" }
|
return { success: false, message: "User not found" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除 sessions 表中该用户的所有记录
|
// audit-P1-9:JWT 策略下 sessions 表无数据,此 Action 为 no-op。
|
||||||
const result = await db
|
// 真正的"远程登出其他设备"需要 JWT 黑名单或短期 token + refresh token,
|
||||||
.delete(sessions)
|
// 当前架构不支持。保留此 Action 是为了:
|
||||||
.where(eq(sessions.userId, ctx.userId))
|
// 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({
|
await logLoginEvent({
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
userEmail: profile.email,
|
userEmail: profile.email,
|
||||||
action: "signout",
|
action: "signout",
|
||||||
status: "success",
|
status: "success",
|
||||||
errorMessage: revokedCount > 0
|
errorMessage:
|
||||||
? `Remote logout: revoked ${revokedCount} session(s)`
|
"Remote logout requested (JWT-based, no active DB sessions to revoke)",
|
||||||
: "Remote logout: no active DB sessions (JWT-based)",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
revalidatePath("/settings")
|
revalidatePath("/settings")
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { Permissions } from "@/shared/types/permissions"
|
|||||||
import {
|
import {
|
||||||
getAllSystemSettings,
|
getAllSystemSettings,
|
||||||
upsertSystemSettings,
|
upsertSystemSettings,
|
||||||
type SystemSettingCategory,
|
|
||||||
type SystemSettingValueType,
|
|
||||||
} from "./data-access-system-settings"
|
} from "./data-access-system-settings"
|
||||||
|
import { toSettingItem } from "./lib/system-settings-utils"
|
||||||
|
|
||||||
// --- Schemas ---
|
// --- Schemas ---
|
||||||
|
|
||||||
@@ -53,27 +52,6 @@ const AdminSettingsFormSchema = z.object({
|
|||||||
|
|
||||||
type AdminSettingsFormValues = z.infer<typeof AdminSettingsFormSchema>
|
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 ---
|
// --- Actions ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type { AiProviderSummary, AiProviderVisibility } from "./types"
|
|||||||
|
|
||||||
export type { AiProviderSummary } 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 VisibilitySchema = z.enum(["public", "private"])
|
||||||
|
|
||||||
const AiProviderFormSchema = z.object({
|
const AiProviderFormSchema = z.object({
|
||||||
@@ -42,6 +42,8 @@ const AiProviderFormSchema = z.object({
|
|||||||
const AiProviderTestSchema = AiProviderFormSchema.extend({
|
const AiProviderTestSchema = AiProviderFormSchema.extend({
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
|
// Ollama 本地部署无需 API Key
|
||||||
|
if (data.provider === "ollama") return
|
||||||
if (!data.apiKey?.trim() && !data.id?.trim()) {
|
if (!data.apiKey?.trim() && !data.id?.trim()) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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 }
|
* 校验当前用户身份,返回 { id, isAdmin }
|
||||||
*
|
*
|
||||||
@@ -71,6 +82,22 @@ const normalizeBaseUrl = (value: string | undefined): string | null => {
|
|||||||
.replace(/\/chat\/completions$/i, "")
|
.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 列表
|
* 获取当前用户可见的 AI Provider 列表
|
||||||
*
|
*
|
||||||
@@ -101,8 +128,8 @@ export async function upsertAiProviderAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = parsed.data
|
const payload = parsed.data
|
||||||
const baseUrl = normalizeBaseUrl(payload.baseUrl)
|
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
|
||||||
if (payload.provider !== "openai" && !baseUrl) {
|
if (!isLocalProvider(payload.provider) && !baseUrl) {
|
||||||
return { success: false, message: "Base URL is required for this provider" }
|
return { success: false, message: "Base URL is required for this provider" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +151,15 @@ export async function upsertAiProviderAction(
|
|||||||
const id = payload.id
|
const id = payload.id
|
||||||
if (!existing) return { success: false, message: "AI provider not found" }
|
if (!existing) return { success: false, message: "AI provider not found" }
|
||||||
|
|
||||||
|
// Ollama 无需 API Key:未提供时使用占位符(仅新建时);更新时保留原值
|
||||||
const nextKey = payload.apiKey?.trim()
|
const nextKey = payload.apiKey?.trim()
|
||||||
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
|
const effectiveKey = nextKey
|
||||||
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
|
? 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 =
|
const isNextDefault =
|
||||||
payload.isDefault === false && existing.isDefault && defaultCount <= 1
|
payload.isDefault === false && existing.isDefault && defaultCount <= 1
|
||||||
@@ -153,13 +186,16 @@ export async function upsertAiProviderAction(
|
|||||||
return { success: true, message: "AI provider updated", data: id }
|
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" }
|
return { success: false, message: "API key is required" }
|
||||||
}
|
}
|
||||||
|
const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY
|
||||||
|
|
||||||
const id = createId()
|
const id = createId()
|
||||||
const encrypted = encryptAiApiKey(payload.apiKey.trim())
|
const encrypted = encryptAiApiKey(effectiveApiKey)
|
||||||
const last4 = payload.apiKey.trim().slice(-4)
|
const last4 = effectiveApiKey.slice(-4)
|
||||||
const shouldMakeDefault = payload.isDefault ?? !hasDefault
|
const shouldMakeDefault = payload.isDefault ?? !hasDefault
|
||||||
|
|
||||||
await createAiProvider(
|
await createAiProvider(
|
||||||
@@ -198,14 +234,16 @@ export async function testAiProviderAction(
|
|||||||
return { success: false, message: "Invalid form data" }
|
return { success: false, message: "Invalid form data" }
|
||||||
}
|
}
|
||||||
const payload = parsed.data
|
const payload = parsed.data
|
||||||
const baseUrl = normalizeBaseUrl(payload.baseUrl)
|
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
|
||||||
if (payload.provider !== "openai" && !baseUrl) {
|
if (!isLocalProvider(payload.provider) && !baseUrl) {
|
||||||
return { success: false, message: "Base URL is required for this provider" }
|
return { success: false, message: "Base URL is required for this provider" }
|
||||||
}
|
}
|
||||||
const model = payload.model.trim()
|
const model = payload.model.trim()
|
||||||
const apiKey = payload.apiKey?.trim()
|
const apiKey = payload.apiKey?.trim()
|
||||||
if (apiKey) {
|
// Ollama 无 API Key 时使用占位符进行测试
|
||||||
await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model })
|
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) {
|
} else if (payload.id) {
|
||||||
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
|
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 * as React from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { School, Shield, Database, Bell, Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminSystemSettingsAction,
|
getAdminSystemSettingsAction,
|
||||||
saveAdminSystemSettingsAction,
|
saveAdminSystemSettingsAction,
|
||||||
} from "@/modules/settings/actions-system-settings"
|
} from "@/modules/settings/actions-system-settings"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { SchoolInfoCard, type SchoolInfoValues } from "@/modules/settings/components/admin-school-info-card"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { SecurityPolicyCard, type SecurityPolicyValues } from "@/modules/settings/components/admin-security-policy-card"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { FileUploadCard, type FileUploadValues } from "@/modules/settings/components/admin-file-upload-card"
|
||||||
import { Switch } from "@/shared/components/ui/switch"
|
import { NotificationConfigCard, type NotificationConfigValues } from "@/modules/settings/components/admin-notification-config-card"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { BrandConfigCard } from "@/modules/settings/components/brand-config-card"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
|
||||||
|
|
||||||
interface AdminSettingsFormValues {
|
interface AdminSettingsFormValues {
|
||||||
schoolInfo: {
|
schoolInfo: SchoolInfoValues
|
||||||
schoolName: string
|
securityPolicy: SecurityPolicyValues
|
||||||
schoolCode: string
|
fileUpload: FileUploadValues
|
||||||
schoolPhone: string
|
notificationConfig: NotificationConfigValues
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VALUES: AdminSettingsFormValues = {
|
const DEFAULT_VALUES: AdminSettingsFormValues = {
|
||||||
@@ -135,26 +114,26 @@ export function AdminSettingsView(): React.ReactElement {
|
|||||||
setValues(loadedValues)
|
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 } }))
|
setValues((prev) => ({ ...prev, schoolInfo: { ...prev.schoolInfo, [key]: value } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSecurityPolicy = (
|
const updateSecurityPolicy = (
|
||||||
key: keyof AdminSettingsFormValues["securityPolicy"],
|
key: keyof SecurityPolicyValues,
|
||||||
value: number | boolean
|
value: number | boolean
|
||||||
): void => {
|
): void => {
|
||||||
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
|
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateFileUpload = (
|
const updateFileUpload = (
|
||||||
key: keyof AdminSettingsFormValues["fileUpload"],
|
key: keyof FileUploadValues,
|
||||||
value: number | string
|
value: number | string
|
||||||
): void => {
|
): void => {
|
||||||
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
|
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNotificationConfig = (
|
const updateNotificationConfig = (
|
||||||
key: keyof AdminSettingsFormValues["notificationConfig"],
|
key: keyof NotificationConfigValues,
|
||||||
value: boolean
|
value: boolean
|
||||||
): void => {
|
): void => {
|
||||||
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
|
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
|
||||||
@@ -176,254 +155,22 @@ export function AdminSettingsView(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="space-y-6">
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
{/* 学校信息 */}
|
<SchoolInfoCard
|
||||||
<Card className="shadow-none">
|
values={values.schoolInfo}
|
||||||
<CardHeader>
|
onChange={updateSchoolInfo}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<School className="h-5 w-5 text-primary" />
|
<SecurityPolicyCard
|
||||||
<div>
|
values={values.securityPolicy}
|
||||||
<CardTitle className="text-base">{t("schoolInfo.title")}</CardTitle>
|
onChange={updateSecurityPolicy}
|
||||||
<CardDescription>{t("schoolInfo.description")}</CardDescription>
|
/>
|
||||||
</div>
|
<FileUploadCard
|
||||||
</div>
|
values={values.fileUpload}
|
||||||
</CardHeader>
|
onChange={updateFileUpload}
|
||||||
<CardContent className="space-y-4">
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<NotificationConfigCard
|
||||||
<div className="space-y-2">
|
values={values.notificationConfig}
|
||||||
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
|
onChange={updateNotificationConfig}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -439,6 +186,9 @@ export function AdminSettingsView(): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* audit-P2-6: 品牌配置(独立表单 + 独立保存,不嵌入主表单以防嵌套 form) */}
|
||||||
|
<BrandConfigCard />
|
||||||
</div>
|
</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"
|
"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 { useTranslations } from "next-intl"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { toast } from "sonner"
|
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -40,10 +28,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
|
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 VisibilitySchema = z.enum(["public", "private"])
|
||||||
|
|
||||||
const AiProviderFormSchema = z.object({
|
const AiProviderFormSchema = z.object({
|
||||||
@@ -58,8 +47,6 @@ const AiProviderFormSchema = z.object({
|
|||||||
|
|
||||||
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
|
type AiProviderFormValues = z.infer<typeof AiProviderFormSchema>
|
||||||
|
|
||||||
const NEW_PROVIDER_VALUE = "__new__"
|
|
||||||
|
|
||||||
type AiProviderSettingsCardProps = {
|
type AiProviderSettingsCardProps = {
|
||||||
onProvidersChanged?: (rows: AiProviderSummary[]) => void
|
onProvidersChanged?: (rows: AiProviderSummary[]) => void
|
||||||
initialMode?: "new" | "first"
|
initialMode?: "new" | "first"
|
||||||
@@ -72,7 +59,7 @@ export function AiProviderSettingsCard({
|
|||||||
initialMode = "first",
|
initialMode = "first",
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
}: AiProviderSettingsCardProps) {
|
}: AiProviderSettingsCardProps): ReactElement {
|
||||||
const t = useTranslations("settings.ai.providers")
|
const t = useTranslations("settings.ai.providers")
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [providers, setProviders] = useState<AiProviderSummary[]>([])
|
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) => {
|
const buildSignature = useCallback((values: AiProviderFormValues) => {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
provider: values.provider,
|
provider: values.provider,
|
||||||
@@ -160,7 +142,7 @@ export function AiProviderSettingsCard({
|
|||||||
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
|
}, [form, selectedId, onProvidersChanged, initialMode, resetToNew, t])
|
||||||
|
|
||||||
const handleSelectChange = (value: string) => {
|
const handleSelectChange = (value: string) => {
|
||||||
if (value === NEW_PROVIDER_VALUE) {
|
if (value === "__new__") {
|
||||||
resetToNew()
|
resetToNew()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,7 +176,8 @@ export function AiProviderSettingsCard({
|
|||||||
const handleTest = () => {
|
const handleTest = () => {
|
||||||
const values = form.getValues()
|
const values = form.getValues()
|
||||||
const apiKey = values.apiKey?.trim()
|
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"))
|
toast.error(t("needKey"))
|
||||||
return
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -347,35 +302,12 @@ export function AiProviderSettingsCard({
|
|||||||
<CardDescription>{t("description")}</CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<AiProviderSelector
|
||||||
<div className="space-y-2">
|
providers={providers}
|
||||||
<Label>{t("existing")}</Label>
|
selectedId={selectedId}
|
||||||
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
currentUserId={currentUserId}
|
||||||
<SelectTrigger>
|
onSelectChange={handleSelectChange}
|
||||||
<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>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
@@ -396,6 +328,7 @@ export function AiProviderSettingsCard({
|
|||||||
{ value: "zhipu", label: "Zhipu" },
|
{ value: "zhipu", label: "Zhipu" },
|
||||||
{ value: "openai", label: "OpenAI" },
|
{ value: "openai", label: "OpenAI" },
|
||||||
{ value: "gemini", label: "Gemini" },
|
{ value: "gemini", label: "Gemini" },
|
||||||
|
{ value: "ollama", label: "Ollama (Local)" },
|
||||||
{ value: "custom", label: "Custom" },
|
{ value: "custom", label: "Custom" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -403,22 +336,22 @@ export function AiProviderSettingsCard({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="baseUrl"
|
name="baseUrl"
|
||||||
label={t("baseUrl")}
|
label={t("baseUrl")}
|
||||||
placeholder={t("baseUrlPlaceholder")}
|
placeholder={form.watch("provider") === "ollama" ? "http://localhost:11434/v1" : t("baseUrlPlaceholder")}
|
||||||
description={t("baseUrlDesc")}
|
description={form.watch("provider") === "ollama" ? t("baseUrlDescOllama") : t("baseUrlDesc")}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="model"
|
name="model"
|
||||||
label={t("model")}
|
label={t("model")}
|
||||||
placeholder={t("modelPlaceholder")}
|
placeholder={form.watch("provider") === "ollama" ? "llama3.2" : t("modelPlaceholder")}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
label={t("apiKey")}
|
label={t("apiKey")}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t("apiKeyPlaceholder")}
|
placeholder={form.watch("provider") === "ollama" ? t("apiKeyPlaceholderOllama") : t("apiKeyPlaceholder")}
|
||||||
description={t("apiKeyDesc")}
|
description={form.watch("provider") === "ollama" ? t("apiKeyDescOllama") : t("apiKeyDesc")}
|
||||||
itemClassName="sm:col-span-2"
|
itemClassName="sm:col-span-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,28 +400,11 @@ export function AiProviderSettingsCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<CardFooter className="flex justify-between border-t px-0 pt-4">
|
<CardFooter className="flex justify-between border-t px-0 pt-4">
|
||||||
<AlertDialog>
|
<AiProviderDeleteDialog
|
||||||
<AlertDialogTrigger asChild>
|
disabled={isPending || !form.getValues("id")?.trim()}
|
||||||
<Button
|
isPending={isPending}
|
||||||
type="button"
|
onConfirm={handleDelete}
|
||||||
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>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
||||||
{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({
|
export function AvatarUpload({
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -72,7 +73,7 @@ export function AvatarUpload({
|
|||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
// 上传文件到 /api/upload
|
// 上传文件到 /api/upload,targetType="user_avatar" 已注册到 FileTargetType 枚举
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
formData.append("targetType", "user_avatar")
|
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 { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||||
import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student-dashboard/student-today-schedule-card"
|
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 { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
|
||||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
import { getStudentProfileOverviewData } from "@/modules/settings/data-access-profile-overview"
|
||||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
|
||||||
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
|
import { buildStudentOverviewData } from "@/modules/settings/lib/student-overview-data"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
@@ -18,23 +17,21 @@ interface ProfileStudentOverviewProps {
|
|||||||
* 学生概览区块(Server Component)
|
* 学生概览区块(Server Component)
|
||||||
*
|
*
|
||||||
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
* 独立获取学生数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
||||||
|
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
|
||||||
|
* 不直接 import classes/homework 的 data-access。
|
||||||
*/
|
*/
|
||||||
export async function ProfileStudentOverview({
|
export async function ProfileStudentOverview({
|
||||||
userId,
|
userId,
|
||||||
}: ProfileStudentOverviewProps): Promise<ReactElement> {
|
}: 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([
|
const { classes, schedule, assignments, grades } =
|
||||||
getStudentClasses(userId),
|
await getStudentProfileOverviewData(userId)
|
||||||
getStudentSchedule(userId),
|
|
||||||
getStudentHomeworkAssignments(userId),
|
|
||||||
getStudentDashboardGrades(userId),
|
|
||||||
])
|
|
||||||
|
|
||||||
const data = buildStudentOverviewData({
|
const data = buildStudentOverviewData({
|
||||||
classes,
|
classes,
|
||||||
schedule,
|
schedule,
|
||||||
assignments: assignmentsAll,
|
assignments,
|
||||||
grades,
|
grades,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,4 +87,3 @@ export function ProfileStudentOverviewSkeleton(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from "next/link"
|
|||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Calendar, GraduationCap } from "lucide-react"
|
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 { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -18,16 +18,15 @@ interface ProfileTeacherOverviewProps {
|
|||||||
* 教师概览区块(Server Component)
|
* 教师概览区块(Server Component)
|
||||||
*
|
*
|
||||||
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
* 独立获取教师数据并渲染,可被 Suspense + ErrorBoundary 包裹实现流式渲染与局部容错。
|
||||||
|
* 数据获取通过 settings 模块自身的 data-access-profile-overview 层调用,
|
||||||
|
* 不直接 import classes 的 data-access。
|
||||||
*/
|
*/
|
||||||
export async function ProfileTeacherOverview(
|
export async function ProfileTeacherOverview(
|
||||||
_props: ProfileTeacherOverviewProps = {}
|
_props: ProfileTeacherOverviewProps = {}
|
||||||
): Promise<ReactElement> {
|
): Promise<ReactElement> {
|
||||||
const t = await getTranslations("settings.profilePage.teacherOverview")
|
const t = await getTranslations("settings.profile.teacherOverview")
|
||||||
|
|
||||||
const [subjects, classes] = await Promise.all([
|
const { subjects, classes } = await getTeacherProfileOverviewData()
|
||||||
getTeacherTeachingSubjects(),
|
|
||||||
getTeacherClasses(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,42 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { ShieldCheck } from "lucide-react"
|
||||||
import {
|
|
||||||
ShieldCheck,
|
|
||||||
Smartphone,
|
|
||||||
Loader2,
|
|
||||||
LogIn,
|
|
||||||
LogOut,
|
|
||||||
UserPlus,
|
|
||||||
AlertCircle,
|
|
||||||
LogOutIcon,
|
|
||||||
KeyRound,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
disableTwoFactorAction,
|
|
||||||
getSecurityCenterAction,
|
getSecurityCenterAction,
|
||||||
regenerateBackupCodesAction,
|
|
||||||
revokeAllOtherSessionsAction,
|
|
||||||
setupTwoFactorAction,
|
|
||||||
verifyTwoFactorAction,
|
|
||||||
type LoginHistoryItem,
|
type LoginHistoryItem,
|
||||||
type TwoFactorSetupData,
|
|
||||||
type TwoFactorStatus,
|
type TwoFactorStatus,
|
||||||
} from "@/modules/settings/actions-security"
|
} 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -44,226 +16,63 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card"
|
} from "@/shared/components/ui/card"
|
||||||
import {
|
import { SecurityTwoFactorSection } from "@/modules/settings/components/security-two-factor-section"
|
||||||
Dialog,
|
import { SecurityRecentLoginsSection } from "@/modules/settings/components/security-recent-logins-section"
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
|
|
||||||
interface SecurityCenterCardProps {
|
interface SecurityCenterCardProps {
|
||||||
/** 当前会话的 user agent,用于标记当前会话 */
|
/** 当前会话的 user agent,用于标记当前会话 */
|
||||||
currentDeviceLabel?: string
|
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 完整流程(启用 / 关闭 / 重新生成备份码)
|
* - 加载 2FA 状态与最近登录记录
|
||||||
* - 最近登录历史(最近 10 条,来自 login_logs 表)
|
* - 编排 TwoFactor / RecentLogins 两个子区块
|
||||||
* - 远程登出其他会话
|
*
|
||||||
|
* 具体交互逻辑封装在子组件中,本组件仅做数据加载与状态分发。
|
||||||
*/
|
*/
|
||||||
export function SecurityCenterCard({
|
export function SecurityCenterCard({
|
||||||
currentDeviceLabel,
|
currentDeviceLabel,
|
||||||
}: SecurityCenterCardProps): React.ReactElement {
|
}: SecurityCenterCardProps): React.ReactElement {
|
||||||
const t = useTranslations("settings.security.center")
|
const t = useTranslations("settings.security.center")
|
||||||
const locale = useLocale()
|
|
||||||
|
|
||||||
const [twoFactor, setTwoFactor] = React.useState<TwoFactorStatus | null>(null)
|
const [twoFactor, setTwoFactor] = React.useState<TwoFactorStatus | null>(null)
|
||||||
const [recentLogins, setRecentLogins] = React.useState<LoginHistoryItem[]>([])
|
const [recentLogins, setRecentLogins] = React.useState<LoginHistoryItem[]>([])
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
const [revoking, setRevoking] = React.useState(false)
|
|
||||||
|
|
||||||
// 启用 2FA Dialog 状态
|
const loadData = React.useCallback(async (): Promise<void> => {
|
||||||
const [enableDialogOpen, setEnableDialogOpen] = React.useState(false)
|
try {
|
||||||
const [setupStep, setSetupStep] = React.useState<SetupStep>("idle")
|
const result = await getSecurityCenterAction()
|
||||||
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
|
if (result.success && result.data) {
|
||||||
const [verifyCode, setVerifyCode] = React.useState("")
|
setTwoFactor(result.data.twoFactor)
|
||||||
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
|
setRecentLogins(result.data.recentLogins)
|
||||||
const [setupLoading, setSetupLoading] = React.useState(false)
|
}
|
||||||
const [copied, setCopied] = React.useState(false)
|
} catch {
|
||||||
|
// 加载失败时静默处理,子组件会展示空状态
|
||||||
// 关闭 2FA Dialog 状态
|
} finally {
|
||||||
const [disableDialogOpen, setDisableDialogOpen] = React.useState(false)
|
setLoading(false)
|
||||||
const [disableCode, setDisableCode] = React.useState("")
|
}
|
||||||
const [disableLoading, setDisableLoading] = React.useState(false)
|
}, [])
|
||||||
|
|
||||||
// 重新生成备份码 Dialog 状态
|
|
||||||
const [regenDialogOpen, setRegenDialogOpen] = React.useState(false)
|
|
||||||
const [regenCode, setRegenCode] = React.useState("")
|
|
||||||
const [regenLoading, setRegenLoading] = React.useState(false)
|
|
||||||
const [regenBackupCodes, setRegenBackupCodes] = React.useState<string[]>([])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
try {
|
if (!cancelled) await loadData()
|
||||||
const result = await getSecurityCenterAction()
|
|
||||||
if (!cancelled && result.success && result.data) {
|
|
||||||
setTwoFactor(result.data.twoFactor)
|
|
||||||
setRecentLogins(result.data.recentLogins)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 加载失败时静默处理
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [loadData])
|
||||||
|
|
||||||
// --- 启用 2FA 流程 ---
|
const handleTwoFactorChange = (status: TwoFactorStatus): void => {
|
||||||
|
setTwoFactor(status)
|
||||||
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> => {
|
const handleRevoked = async (): Promise<void> => {
|
||||||
if (!verifyCode.trim()) return
|
await loadData()
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -278,368 +87,18 @@ export function SecurityCenterCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* 2FA 区域 */}
|
<SecurityTwoFactorSection
|
||||||
<div className="space-y-3">
|
twoFactor={twoFactor}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
loading={loading}
|
||||||
<div className="flex items-start gap-3">
|
onStatusChange={handleTwoFactorChange}
|
||||||
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
|
/>
|
||||||
<div className="space-y-0.5">
|
<SecurityRecentLoginsSection
|
||||||
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
|
recentLogins={recentLogins}
|
||||||
<p className="text-sm text-muted-foreground">
|
loading={loading}
|
||||||
{t("twoFactor.description")}
|
currentDeviceLabel={currentDeviceLabel}
|
||||||
</p>
|
onRevoked={handleRevoked}
|
||||||
{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>
|
|
||||||
</CardContent>
|
</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>
|
</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"
|
"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 type { ReactNode } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
interface SettingsSectionErrorBoundaryProps {
|
interface SettingsSectionErrorBoundaryProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsSectionErrorBoundaryState {
|
export function SettingsSectionErrorBoundary({
|
||||||
hasError: boolean
|
children,
|
||||||
}
|
}: SettingsSectionErrorBoundaryProps): ReactNode {
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置页分区 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")
|
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<SectionErrorBoundary namespace="common">
|
||||||
icon={AlertCircle}
|
{children}
|
||||||
title={t("sectionLoadFailed")}
|
</SectionErrorBoundary>
|
||||||
description={t("sectionLoadFailedDesc")}
|
|
||||||
action={{
|
|
||||||
label: t("retry"),
|
|
||||||
onClick: onRetry,
|
|
||||||
}}
|
|
||||||
className="border-none shadow-none h-auto"
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
| "security_policy"
|
||||||
| "file_upload"
|
| "file_upload"
|
||||||
| "notification_config"
|
| "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,
|
UpdateNotificationPreferencesInput,
|
||||||
} from "@/modules/notifications/types"
|
} from "@/modules/notifications/types"
|
||||||
|
|
||||||
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
|
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom" | "ollama"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 服务商可见性
|
* AI 服务商可见性
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ActionState } from "@/shared/types/action-state";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access";
|
import { getCurrentStudentUser } from "@/modules/users/data-access";
|
||||||
|
import { getGradeNameById } from "@/modules/school/data-access";
|
||||||
import {
|
import {
|
||||||
createTextbook,
|
createTextbook,
|
||||||
createChapter,
|
createChapter,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
verifyChapterBelongsToTextbook,
|
verifyChapterBelongsToTextbook,
|
||||||
verifyKnowledgePointBelongsToTextbook,
|
verifyKnowledgePointBelongsToTextbook,
|
||||||
getKnowledgePointsByChapterId,
|
getKnowledgePointsByChapterId,
|
||||||
|
getTextbookById,
|
||||||
createPrerequisite,
|
createPrerequisite,
|
||||||
deletePrerequisite,
|
deletePrerequisite,
|
||||||
getPrerequisiteEdgesForTextbook,
|
getPrerequisiteEdgesForTextbook,
|
||||||
@@ -369,6 +371,9 @@ export async function getKnowledgePointsByChapterAction(
|
|||||||
* - structure 模式:仅返回知识点+依赖+题目数
|
* - structure 模式:仅返回知识点+依赖+题目数
|
||||||
* - student-mastery 模式:附加当前学生掌握度
|
* - student-mastery 模式:附加当前学生掌握度
|
||||||
* - class-mastery 模式:附加班级平均掌握度(仅教师可用)
|
* - class-mastery 模式:附加班级平均掌握度(仅教师可用)
|
||||||
|
*
|
||||||
|
* P0 安全修复:学生端按年级 scope 过滤,防止跨年级越权查看教材图谱数据。
|
||||||
|
* class-mastery 模式仅限教师使用(getClassStudents 已通过 getAccessibleClassIdsForTeacher 过滤)。
|
||||||
*/
|
*/
|
||||||
export async function getKnowledgeGraphDataAction(
|
export async function getKnowledgeGraphDataAction(
|
||||||
textbookId: string,
|
textbookId: string,
|
||||||
@@ -376,7 +381,27 @@ export async function getKnowledgeGraphDataAction(
|
|||||||
): Promise<ActionState<KnowledgeGraphData>> {
|
): Promise<ActionState<KnowledgeGraphData>> {
|
||||||
try {
|
try {
|
||||||
const t = await getTranslations("textbooks.action");
|
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 knowledgePointsData = await getKnowledgePointsWithRelations(textbookId);
|
||||||
const masteryMap: Record<string, MasteryInfo> = {};
|
const masteryMap: Record<string, MasteryInfo> = {};
|
||||||
@@ -391,7 +416,11 @@ export async function getKnowledgeGraphDataAction(
|
|||||||
}
|
}
|
||||||
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
|
// 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态
|
||||||
} else if (viewMode === "class-mastery") {
|
} 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 students = await getClassStudents({ status: "active" });
|
||||||
const studentIds = students.map((s) => s.id);
|
const studentIds = students.map((s) => s.id);
|
||||||
if (studentIds.length > 0) {
|
if (studentIds.length > 0) {
|
||||||
|
|||||||
@@ -98,8 +98,16 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<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)}
|
onClick={() => onSelect(chapter)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect(chapter)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<Folder className={cn("h-4 w-4 shrink-0 transition-colors", isOpen || isSelected ? "text-blue-500/80" : "text-muted-foreground/50")} />
|
<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 { Handle, Position, type NodeProps } from "@xyflow/react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import type { GraphNodeData, MasteryLevel } from "../types"
|
import type { GraphNodeData, MasteryLevel, KpWithRelations } from "../types"
|
||||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
|
||||||
import { NODE_WIDTH } from "../graph-layout"
|
import { NODE_WIDTH } from "../graph-layout"
|
||||||
|
|
||||||
/** 根据掌握度计算色彩等级 */
|
/** 根据掌握度计算色彩等级 */
|
||||||
@@ -30,11 +29,23 @@ const MASTERY_BAR_COLORS: Record<MasteryLevel, string> = {
|
|||||||
unassessed: "bg-muted",
|
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 t = useTranslations("textbooks")
|
||||||
const nodeData = data as unknown as GraphLayoutNodeData
|
const { data, selected } = props
|
||||||
const { kp } = nodeData
|
const { kp, graphData } = extractNodeData(data)
|
||||||
const graphData = (data as unknown as { graphData?: GraphNodeData }).graphData
|
|
||||||
const mastery = graphData?.mastery ?? null
|
const mastery = graphData?.mastery ?? null
|
||||||
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
const masteryLevel = getMasteryLevel(mastery?.masteryLevel ?? null)
|
||||||
const showMastery = graphData?.viewMode === "student-mastery" || graphData?.viewMode === "class-mastery"
|
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 flex-col h-full border-l bg-background">
|
||||||
<div className="flex items-center justify-between p-3 border-b shrink-0">
|
<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>
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +141,7 @@ export function GraphNodeDetailPanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => onRemovePrerequisite(p.id)}
|
onClick={() => onRemovePrerequisite(p.id)}
|
||||||
|
aria-label={t("graph.detail.removePrerequisite")}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import type { GraphViewMode, GraphNodeData } from "../types"
|
import type { GraphViewMode, GraphNodeData } from "../types"
|
||||||
import { computeGraphLayout } from "../graph-layout"
|
import { computeGraphLayout } from "../graph-layout"
|
||||||
|
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||||
import { useGraphData } from "../hooks/use-graph-data"
|
import { useGraphData } from "../hooks/use-graph-data"
|
||||||
import {
|
import {
|
||||||
createPrerequisiteAction,
|
createPrerequisiteAction,
|
||||||
@@ -202,35 +203,44 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
const handleAddPrerequisite = useCallback(async () => {
|
const handleAddPrerequisite = useCallback(async () => {
|
||||||
if (!selectedKpId || !newPrereqId || !textbookId) return
|
if (!selectedKpId || !newPrereqId || !textbookId) return
|
||||||
setIsSavingPrereq(true)
|
setIsSavingPrereq(true)
|
||||||
const formData = new FormData()
|
try {
|
||||||
formData.set("knowledgePointId", selectedKpId)
|
const formData = new FormData()
|
||||||
formData.set("prerequisiteKpId", newPrereqId)
|
formData.set("knowledgePointId", selectedKpId)
|
||||||
formData.set("textbookId", textbookId)
|
formData.set("prerequisiteKpId", newPrereqId)
|
||||||
const result = await createPrerequisiteAction(formData)
|
formData.set("textbookId", textbookId)
|
||||||
setIsSavingPrereq(false)
|
const result = await createPrerequisiteAction(formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(t("graph.detail.prerequisiteAdded"))
|
toast.success(t("graph.detail.prerequisiteAdded"))
|
||||||
setAddPrereqOpen(false)
|
setAddPrereqOpen(false)
|
||||||
setNewPrereqId("")
|
setNewPrereqId("")
|
||||||
reload()
|
reload()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message)
|
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])
|
}, [selectedKpId, newPrereqId, textbookId, t, reload])
|
||||||
|
|
||||||
// 删除前置依赖
|
// 删除前置依赖
|
||||||
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
||||||
if (!selectedKpId || !textbookId) return
|
if (!selectedKpId || !textbookId) return
|
||||||
const formData = new FormData()
|
try {
|
||||||
formData.set("knowledgePointId", selectedKpId)
|
const formData = new FormData()
|
||||||
formData.set("prerequisiteKpId", prereqId)
|
formData.set("knowledgePointId", selectedKpId)
|
||||||
formData.set("textbookId", textbookId)
|
formData.set("prerequisiteKpId", prereqId)
|
||||||
const result = await deletePrerequisiteAction(formData)
|
formData.set("textbookId", textbookId)
|
||||||
if (result.success) {
|
const result = await deletePrerequisiteAction(formData)
|
||||||
toast.success(t("graph.detail.prerequisiteRemoved"))
|
if (result.success) {
|
||||||
reload()
|
toast.success(t("graph.detail.prerequisiteRemoved"))
|
||||||
} else {
|
reload()
|
||||||
toast.error(result.message)
|
} else {
|
||||||
|
toast.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed"))
|
||||||
}
|
}
|
||||||
}, [selectedKpId, textbookId, t, reload])
|
}, [selectedKpId, textbookId, t, reload])
|
||||||
|
|
||||||
@@ -306,9 +316,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
|
|||||||
<MiniMap
|
<MiniMap
|
||||||
className="!bg-background !border !rounded-lg"
|
className="!bg-background !border !rounded-lg"
|
||||||
nodeColor={(node) => {
|
nodeColor={(node) => {
|
||||||
// node.data 是 Record<string, unknown>;从 unknown 安全转换读取 graphData
|
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
||||||
const graphData = (node.data as unknown as { graphData?: { chapterColor: string } })?.graphData
|
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
||||||
return graphData?.chapterColor ?? "#6b7280"
|
const data = node.data as GraphLayoutNodeData
|
||||||
|
return data.graphData?.chapterColor ?? "#6b7280"
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export function KnowledgePointDialogs({
|
|||||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||||
<DialogDescription>{t("createDesc")}</DialogDescription>
|
<DialogDescription>{t("createDesc")}</DialogDescription>
|
||||||
</DialogHeader>
|
</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-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">{t("name")}</Label>
|
<Label htmlFor="name">{t("name")}</Label>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
/**
|
/**
|
||||||
* 教材模块内联 Error Boundary。
|
* 教材模块内联 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 { AlertCircle } from "lucide-react"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
interface TextbookSectionErrorBoundaryProps {
|
interface TextbookSectionErrorBoundaryProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -21,52 +23,36 @@ interface TextbookSectionErrorBoundaryProps {
|
|||||||
retryLabel?: string
|
retryLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextbookSectionErrorBoundaryState {
|
export function TextbookSectionErrorBoundary({
|
||||||
hasError: boolean
|
children,
|
||||||
}
|
fallbackTitle,
|
||||||
|
fallbackDescription,
|
||||||
export class TextbookSectionErrorBoundary extends Component<
|
retryLabel,
|
||||||
TextbookSectionErrorBoundaryProps,
|
}: TextbookSectionErrorBoundaryProps): ReactNode {
|
||||||
TextbookSectionErrorBoundaryState
|
const fallback = (_error: Error, reset: () => void): ReactNode => (
|
||||||
> {
|
<div
|
||||||
constructor(props: TextbookSectionErrorBoundaryProps) {
|
role="alert"
|
||||||
super(props)
|
aria-live="assertive"
|
||||||
this.state = { hasError: false }
|
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" />
|
||||||
static getDerivedStateFromError(): TextbookSectionErrorBoundaryState {
|
{fallbackTitle && (
|
||||||
return { hasError: true }
|
<p className="text-sm font-medium text-foreground">{fallbackTitle}</p>
|
||||||
}
|
)}
|
||||||
|
{fallbackDescription && (
|
||||||
handleReset = (): void => {
|
<p className="text-xs text-muted-foreground">{fallbackDescription}</p>
|
||||||
this.setState({ hasError: false })
|
)}
|
||||||
}
|
{retryLabel && (
|
||||||
|
<Button size="sm" variant="outline" onClick={reset}>
|
||||||
render(): ReactNode {
|
{retryLabel}
|
||||||
if (this.state.hasError) {
|
</Button>
|
||||||
// 默认值为空字符串,强制调用方传入 i18n 文案
|
)}
|
||||||
const title = this.props.fallbackTitle ?? ""
|
</div>
|
||||||
const description = this.props.fallbackDescription ?? ""
|
)
|
||||||
const retryLabel = this.props.retryLabel ?? ""
|
|
||||||
return (
|
return (
|
||||||
// 任意值 min-h-[200px]:错误降级 UI 最小高度,保证视觉占位
|
<SectionErrorBoundary fallback={fallback}>
|
||||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
|
{children}
|
||||||
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
</SectionErrorBoundary>
|
||||||
{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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface TextbookCardProps {
|
|||||||
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const base = hrefBase || "/teacher/textbooks"
|
const base = hrefBase ?? "/teacher/textbooks" // 默认教师端,学生端通过 hrefBase prop 覆盖
|
||||||
const colorClass = getSubjectColor(textbook.subject)
|
const colorClass = getSubjectColor(textbook.subject)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|||||||
@@ -20,48 +20,67 @@ import {
|
|||||||
} from "@/shared/components/ui/context-menu"
|
} from "@/shared/components/ui/context-menu"
|
||||||
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
import { RichTextEditor } from "@/shared/components/ui/rich-text-editor"
|
||||||
|
|
||||||
interface TextbookContentPanelProps {
|
/**
|
||||||
|
* 章节数据组:当前选中章节及其预处理后的内容。
|
||||||
|
*/
|
||||||
|
export interface ChapterDataGroup {
|
||||||
selected: Chapter | null
|
selected: Chapter | null
|
||||||
|
processedContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑状态组:章节内容编辑相关的状态与操作。
|
||||||
|
*/
|
||||||
|
export interface EditingStateGroup {
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
editContent: string
|
editContent: string
|
||||||
setEditContent: (content: string) => void
|
setEditContent: (content: string) => void
|
||||||
canEdit: boolean
|
isSaving: boolean
|
||||||
highlightedKpId: string | null
|
startEditing: () => void
|
||||||
onHighlight: (id: string) => void
|
cancelEditing: () => void
|
||||||
onSwitchToKnowledgeTab: () => void
|
saveContent: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本选区组:右键菜单与文本选区相关的状态与操作。
|
||||||
|
*/
|
||||||
|
export interface TextSelectionGroup {
|
||||||
contentRef: React.RefObject<HTMLDivElement | null>
|
contentRef: React.RefObject<HTMLDivElement | null>
|
||||||
onPointerDown: (e: React.PointerEvent) => void
|
onPointerDown: (e: React.PointerEvent) => void
|
||||||
onContextMenuChange: (open: boolean) => void
|
onContextMenuChange: (open: boolean) => void
|
||||||
selectedText: string
|
selectedText: string
|
||||||
setCreateDialogOpen: (open: boolean) => void
|
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({
|
export function TextbookContentPanel({
|
||||||
selected,
|
chapter,
|
||||||
isEditing,
|
editing,
|
||||||
editContent,
|
selection,
|
||||||
setEditContent,
|
highlight,
|
||||||
canEdit,
|
canEdit,
|
||||||
highlightedKpId,
|
}: TextbookContentPanelProps): React.ReactNode {
|
||||||
onHighlight,
|
|
||||||
onSwitchToKnowledgeTab,
|
|
||||||
contentRef,
|
|
||||||
onPointerDown,
|
|
||||||
onContextMenuChange,
|
|
||||||
selectedText,
|
|
||||||
setCreateDialogOpen,
|
|
||||||
startEditing,
|
|
||||||
cancelEditing,
|
|
||||||
saveContent,
|
|
||||||
isSaving,
|
|
||||||
processedContent,
|
|
||||||
}: TextbookContentPanelProps) {
|
|
||||||
const t = useTranslations("textbooks")
|
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) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,18 +14,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/shared/components/ui/dialog"
|
} 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 { createTextbookAction } from "../actions"
|
||||||
import { SUBJECTS, GRADES } from "../constants"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { TextbookFormFields } from "./textbook-form-fields"
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
@@ -71,65 +62,7 @@ export function TextbookFormDialog() {
|
|||||||
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
|
<DialogDescription>{t("dialog.create.description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleSubmit}>
|
<form action={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<TextbookFormFields />
|
||||||
<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>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
</DialogFooter>
|
</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
|
textbookId: string
|
||||||
/**
|
|
||||||
* 是否可编辑。已废弃——改由内部 usePermission() 自动判断。
|
|
||||||
* 保留 prop 仅为向后兼容,传入值会被忽略。
|
|
||||||
* @deprecated 改用权限系统自动判断
|
|
||||||
*/
|
|
||||||
canEdit?: boolean
|
|
||||||
/**
|
/**
|
||||||
* 题目创建器渲染函数(P0-1 解耦)。
|
* 题目创建器渲染函数(P0-1 解耦)。
|
||||||
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
* 由页面层注入 questions 模块的 CreateQuestionDialog 实现。
|
||||||
@@ -176,8 +170,11 @@ export function TextbookReader({
|
|||||||
|
|
||||||
const onCreateKnowledgePoint = async (formData: FormData) => {
|
const onCreateKnowledgePoint = async (formData: FormData) => {
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
await handleCreateKnowledgePoint(formData)
|
try {
|
||||||
setIsCreating(false)
|
await handleCreateKnowledgePoint(formData)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveContent = async () => {
|
const handleSaveContent = async () => {
|
||||||
@@ -224,19 +221,13 @@ export function TextbookReader({
|
|||||||
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
|
return highlightKnowledgePoints(effectiveContent, currentChapterKPs)
|
||||||
}, [effectiveContent, currentChapterKPs])
|
}, [effectiveContent, currentChapterKPs])
|
||||||
|
|
||||||
|
// P2 状态驱动:仅保留 scrollIntoView(滚动无法声明式实现),
|
||||||
|
// 视觉高亮已由 TextbookContentPanel 中 isHighlighted 状态驱动,无需命令式 classList 操作
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightedKpId) return
|
if (!highlightedKpId) return
|
||||||
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
const el = document.querySelector(`[data-kp-id="${highlightedKpId}"]`)
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
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])
|
}, [highlightedKpId])
|
||||||
|
|
||||||
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
||||||
@@ -422,24 +413,29 @@ export function TextbookReader({
|
|||||||
retryLabel={t("error.retry")}
|
retryLabel={t("error.retry")}
|
||||||
>
|
>
|
||||||
<TextbookContentPanel
|
<TextbookContentPanel
|
||||||
selected={selected}
|
chapter={{ selected, processedContent }}
|
||||||
isEditing={isEditing}
|
editing={{
|
||||||
editContent={editContent}
|
isEditing,
|
||||||
setEditContent={setEditContent}
|
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}
|
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>
|
</TextbookSectionErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,26 +24,19 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} 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 { updateTextbookAction, deleteTextbookAction } from "../actions"
|
||||||
import { SUBJECTS, GRADES } from "../constants"
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import type { Textbook } from "../types"
|
import type { Textbook } from "../types"
|
||||||
|
import { TextbookFormFields } from "./textbook-form-fields"
|
||||||
|
|
||||||
interface TextbookSettingsDialogProps {
|
interface TextbookSettingsDialogProps {
|
||||||
textbook: Textbook
|
textbook: Textbook
|
||||||
trigger?: React.ReactNode
|
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 t = useTranslations("textbooks")
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -77,7 +70,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.push("/teacher/textbooks")
|
router.push(redirectAfterDelete)
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message)
|
toast.error(result.message)
|
||||||
}
|
}
|
||||||
@@ -107,65 +100,14 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form action={handleUpdate}>
|
<form action={handleUpdate}>
|
||||||
<div className="grid gap-4 py-4">
|
<TextbookFormFields
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
defaultValues={{
|
||||||
<Label htmlFor="title" className="text-right">
|
title: textbook.title,
|
||||||
{t("field.title")}
|
subject: textbook.subject,
|
||||||
</Label>
|
grade: textbook.grade,
|
||||||
<Input
|
publisher: textbook.publisher,
|
||||||
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>
|
|
||||||
|
|
||||||
<DialogFooter className="flex justify-between sm:justify-between">
|
<DialogFooter className="flex justify-between sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const SUBJECTS: readonly SubjectOption[] = [
|
|||||||
export const GRADES: readonly GradeOption[] = [
|
export const GRADES: readonly GradeOption[] = [
|
||||||
{ value: "Grade 1", labelKey: "grade1" },
|
{ value: "Grade 1", labelKey: "grade1" },
|
||||||
{ value: "Grade 2", labelKey: "grade2" },
|
{ 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 7", labelKey: "grade7" },
|
||||||
{ value: "Grade 8", labelKey: "grade8" },
|
{ value: "Grade 8", labelKey: "grade8" },
|
||||||
{ value: "Grade 9", labelKey: "grade9" },
|
{ value: "Grade 9", labelKey: "grade9" },
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createId } from "@paralleldrive/cuid2"
|
|||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
|
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 {
|
import type {
|
||||||
Chapter,
|
Chapter,
|
||||||
KnowledgePoint,
|
KnowledgePoint,
|
||||||
@@ -24,11 +24,12 @@ import type {
|
|||||||
} from "./schema"
|
} from "./schema"
|
||||||
import {
|
import {
|
||||||
buildChapterTree,
|
buildChapterTree,
|
||||||
|
findChapterById,
|
||||||
normalizeOptional,
|
normalizeOptional,
|
||||||
sortChapters,
|
sortChapters,
|
||||||
} from "./utils"
|
} 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))
|
.where(eq(textbooks.id, data.id))
|
||||||
|
|
||||||
const updated = await getTextbookById(data.id)
|
const updated = await getTextbookById(data.id)
|
||||||
if (!updated) throw new Error("Textbook not found")
|
if (!updated) throw new NotFoundError("教材")
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +242,7 @@ export async function updateChapterContent(data: UpdateChapterContentInput): Pro
|
|||||||
.where(eq(chapters.id, data.chapterId))
|
.where(eq(chapters.id, data.chapterId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!row) throw new Error("Chapter not found")
|
if (!row) throw new NotFoundError("章节")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -291,7 +292,26 @@ export async function deleteChapter(id: string): Promise<void> {
|
|||||||
if (kids) stack.push(...kids)
|
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) => {
|
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(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete))
|
||||||
await tx.delete(chapters).where(inArray(chapters.id, 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> {
|
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)
|
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
|
const siblings = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -453,23 +473,6 @@ export async function verifyChapterBelongsToTextbook(
|
|||||||
return row.textbookId === textbookId
|
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 关联)。
|
* 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。
|
||||||
*
|
*
|
||||||
@@ -658,5 +661,5 @@ export async function getPrerequisiteEdgesForTextbook(
|
|||||||
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
.innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||||
.where(eq(chapters.textbookId, textbookId))
|
.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 dagre from "@dagrejs/dagre"
|
||||||
import type { EdgeLabel, GraphLabel, NodeLabel } from "@dagrejs/dagre"
|
import type { EdgeLabel, GraphLabel, NodeLabel } from "@dagrejs/dagre"
|
||||||
import type { Edge, Node } from "@xyflow/react"
|
import type { Edge, Node } from "@xyflow/react"
|
||||||
import type { KpWithRelations } from "./types"
|
import type { GraphNodeData, KpWithRelations } from "./types"
|
||||||
|
|
||||||
export interface GraphLayoutNodeData {
|
export interface GraphLayoutNodeData {
|
||||||
kp: KpWithRelations
|
kp: KpWithRelations
|
||||||
label: string
|
label: string
|
||||||
|
/** 图谱节点附加数据(掌握度、视图模式等),由 knowledge-graph.tsx 注入 */
|
||||||
|
graphData?: GraphNodeData
|
||||||
// 索引签名:满足 @xyflow/react Node<GraphLayoutNodeData> 的 Record<string, unknown> 约束
|
// 索引签名:满足 @xyflow/react Node<GraphLayoutNodeData> 的 Record<string, unknown> 约束
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,9 @@ export function computeGraphLayout(
|
|||||||
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
|
g.setGraph({ rankdir: "TB", nodesep: NODE_SEP, ranksep: RANK_SEP })
|
||||||
g.setDefaultEdgeLabel(() => ({}))
|
g.setDefaultEdgeLabel(() => ({}))
|
||||||
|
|
||||||
|
// P2 性能优化:用 Set 替代 O(n²) 的 some() 查找,降为 O(n)
|
||||||
|
const kpIdSet = new Set(knowledgePoints.map((kp) => kp.id))
|
||||||
|
|
||||||
// 添加节点
|
// 添加节点
|
||||||
for (const kp of knowledgePoints) {
|
for (const kp of knowledgePoints) {
|
||||||
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
g.setNode(kp.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||||
@@ -53,7 +58,7 @@ export function computeGraphLayout(
|
|||||||
|
|
||||||
// 添加 parentId 边(树归属,实线)
|
// 添加 parentId 边(树归属,实线)
|
||||||
for (const kp of knowledgePoints) {
|
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)
|
g.setEdge(kp.parentId, kp.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,7 @@ export function computeGraphLayout(
|
|||||||
// 添加 prerequisite 边(依赖,虚线箭头)
|
// 添加 prerequisite 边(依赖,虚线箭头)
|
||||||
for (const kp of knowledgePoints) {
|
for (const kp of knowledgePoints) {
|
||||||
for (const prereqId of kp.prerequisiteIds) {
|
for (const prereqId of kp.prerequisiteIds) {
|
||||||
if (knowledgePoints.some((k) => k.id === prereqId)) {
|
if (kpIdSet.has(prereqId)) {
|
||||||
g.setEdge(prereqId, kp.id)
|
g.setEdge(prereqId, kp.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +91,7 @@ export function computeGraphLayout(
|
|||||||
|
|
||||||
// parentId 边
|
// parentId 边
|
||||||
for (const kp of knowledgePoints) {
|
for (const kp of knowledgePoints) {
|
||||||
if (kp.parentId && knowledgePoints.some((k) => k.id === kp.parentId)) {
|
if (kp.parentId && kpIdSet.has(kp.parentId)) {
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `parent-${kp.parentId}-${kp.id}`,
|
id: `parent-${kp.parentId}-${kp.id}`,
|
||||||
source: kp.parentId,
|
source: kp.parentId,
|
||||||
@@ -100,7 +105,7 @@ export function computeGraphLayout(
|
|||||||
// prerequisite 边
|
// prerequisite 边
|
||||||
for (const kp of knowledgePoints) {
|
for (const kp of knowledgePoints) {
|
||||||
for (const prereqId of kp.prerequisiteIds) {
|
for (const prereqId of kp.prerequisiteIds) {
|
||||||
if (knowledgePoints.some((k) => k.id === prereqId)) {
|
if (kpIdSet.has(prereqId)) {
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `prereq-${prereqId}-${kp.id}`,
|
id: `prereq-${prereqId}-${kp.id}`,
|
||||||
source: prereqId,
|
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"
|
"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 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>
|
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({
|
export function useKpCrud({
|
||||||
textbookId,
|
textbookId,
|
||||||
@@ -37,79 +32,23 @@ export function useKpCrud({
|
|||||||
onKpCreated,
|
onKpCreated,
|
||||||
dialog,
|
dialog,
|
||||||
}: UseKpCrudArgs) {
|
}: UseKpCrudArgs) {
|
||||||
const t = useTranslations("textbooks")
|
const { handleCreateKnowledgePoint } = useKpCreate({
|
||||||
|
selectedChapterId,
|
||||||
|
selectedChapterTextbookId,
|
||||||
|
onKpCreated,
|
||||||
|
})
|
||||||
|
|
||||||
const handleCreateKnowledgePoint = async (formData: FormData): Promise<boolean> => {
|
const { requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint } = useKpDelete({
|
||||||
if (!selectedChapterId || !selectedChapterTextbookId) return false
|
textbookId,
|
||||||
|
highlightedKpId,
|
||||||
|
setHighlightedKpId,
|
||||||
|
dialog,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
const { handleUpdateKnowledgePoint } = useKpUpdate({
|
||||||
const result = await createKnowledgePointAction(
|
textbookId,
|
||||||
selectedChapterId,
|
dialog,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleCreateKnowledgePoint,
|
handleCreateKnowledgePoint,
|
||||||
@@ -118,5 +57,3 @@ export function useKpCrud({
|
|||||||
handleUpdateKnowledgePoint,
|
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 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({
|
export const CreatePrerequisiteSchema = z.object({
|
||||||
knowledgePointId: z.string().min(1),
|
knowledgePointId: z.string().min(1),
|
||||||
prerequisiteKpId: z.string().min(1),
|
prerequisiteKpId: z.string().min(1),
|
||||||
|
|||||||
@@ -106,6 +106,30 @@ export function findChapterParent(
|
|||||||
return null
|
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