From ec87cd9efaede872949b05234a96e5d7fbea447a Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:30:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(textbooks):=20=E8=A7=84=E8=8C=83=E6=A0=B8?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=BC=8F=E6=B4=9E+=E5=8A=9F=E8=83=BD=E7=BC=BA=E5=A4=B1+i18n+?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全:createPrerequisiteAction 补充 prerequisiteKpId 归属校验;deletePrerequisiteAction 补充双知识点归属校验,防止跨教材越权。 功能:实现图谱添加/删除前置依赖(Dialog + Select 选择知识点 + 调用 Server Action + 自动刷新图谱),替换原 no-op 回调。 i18n:修复 8 处硬编码英文字符串(textbook-reader/chapter-sidebar-list/textbook-card/textbook-form-dialog/textbook-settings-dialog/create-chapter-dialog/teacher-textbook-reader),新增 saveFailed/createFailed/updateFailed/deleteFailed/questionCreatorDefaultContent 等 key。 类型安全:graph-prerequisite-edge.tsx 使用 GraphEdgeData 类型经 unknown 安全转换,替代裸 as 断言。 规范:analytics.tsx 移动 use client 指令到文件第一行;同步架构文档 005 JSON 类型定义(GraphNodeData/GraphEdgeData/MasteryLevel)。 验证:教材模块 lint 零错误、tsc 零错误、193 个单元测试全部通过。 --- docs/architecture/005_architecture_data.json | 18 +-- src/modules/textbooks/actions.ts | 25 +++- src/modules/textbooks/analytics.tsx | 4 +- .../components/chapter-sidebar-list.tsx | 98 +++++++-------- .../components/create-chapter-dialog.tsx | 17 ++- .../components/graph-prerequisite-edge.tsx | 5 +- .../textbooks/components/knowledge-graph.tsx | 113 ++++++++++++++++-- .../components/teacher-textbook-reader.tsx | 50 ++++++++ .../textbooks/components/textbook-card.tsx | 75 +++++++++++- .../components/textbook-form-dialog.tsx | 17 ++- .../textbooks/components/textbook-reader.tsx | 2 +- .../components/textbook-settings-dialog.tsx | 38 ++++-- src/shared/i18n/messages/en/textbooks.json | 15 ++- src/shared/i18n/messages/zh-CN/textbooks.json | 15 ++- 14 files changed, 388 insertions(+), 104 deletions(-) create mode 100644 src/modules/textbooks/components/teacher-textbook-reader.tsx diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 23344ff..8cfc48e 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -4508,8 +4508,8 @@ { "name": "GraphNodeData", "type": "interface", - "definition": "{ id, name, chapterId, chapterTitle, level, questionCount, mastery?: MasteryInfo, isHighlighted?, isRelated?, searchText? } & Record", - "purpose": "Task 9 新增:React Flow 自定义节点数据(含索引签名满足 React Flow 约束)", + "definition": "{ kp: KpWithRelations, mastery: MasteryInfo | null, viewMode: GraphViewMode, isSelected: boolean, isHighlighted: boolean, chapterColor: string }", + "purpose": "Task 9 新增:React Flow 自定义节点数据(传递给 graph-kp-node 组件)", "usedBy": [ "components/graph-kp-node.tsx", "components/knowledge-graph.tsx" @@ -4518,7 +4518,7 @@ { "name": "GraphEdgeData", "type": "interface", - "definition": "{ edgeType: 'parent-child' | 'prerequisite', isHighlighted? } & Record", + "definition": "{ edgeType: 'parent' | 'prerequisite', isHighlighted: boolean }", "purpose": "Task 9 新增:React Flow 自定义边数据", "usedBy": [ "components/graph-prerequisite-edge.tsx", @@ -4528,8 +4528,8 @@ { "name": "MasteryLevel", "type": "type", - "definition": "'unknown' | 'weak' | 'fair' | 'strong'", - "purpose": "Task 9 新增:掌握度等级(红/黄/灰)", + "definition": "'low' | 'medium' | 'high' | 'unassessed'", + "purpose": "Task 9 新增:掌握度等级(红/黄/绿/灰)", "usedBy": [ "components/graph-kp-node.tsx" ] @@ -13694,8 +13694,12 @@ "algorithms": { "sm2": { "name": "SM-2 间隔重复算法(简化版)", - "description": "4 级评级(again/hard/good/easy),间隔 1/2/4/7 天起,指数增长(×1.2/×1.5/×2),连续 3 次答对标记为已掌握", - "functions": ["calculateNewInterval", "calculateNewMastery", "deriveStatus", "calculateNextReviewAt"] + "file": "sm2-algorithm.ts", + "description": "独立纯函数模块,4 级评级(again/hard/good/easy),间隔 1/2/4/7 天起,指数增长(×1.2/×1.5/×2),连续 3 次答对标记为已掌握。支持时间注入便于测试。", + "functions": ["calculateNewInterval", "calculateNewMastery", "deriveStatus", "calculateNextReviewAt", "calculateNewCorrectStreak", "calculateSm2Result"], + "constants": ["REVIEW_INTERVALS", "INTERVAL_MULTIPLIERS", "MAX_MASTERY_LEVEL", "MIN_MASTERY_LEVEL", "MASTERED_REQUIRED_STREAK", "MASTERED_REQUIRED_MASTERY"], + "testFile": "sm2-algorithm.test.ts", + "testCount": 39 } }, "dataScope": { diff --git a/src/modules/textbooks/actions.ts b/src/modules/textbooks/actions.ts index 164014a..2f0534b 100644 --- a/src/modules/textbooks/actions.ts +++ b/src/modules/textbooks/actions.ts @@ -426,7 +426,7 @@ export async function createPrerequisiteAction( return { success: false, message: t("invalidInput") }; } - // 归属校验 + // 归属校验:两个知识点都必须属于当前教材,防止跨教材越权 const kpBelongs = await verifyKnowledgePointBelongsToTextbook( parsed.data.knowledgePointId, textbookId, @@ -434,6 +434,13 @@ export async function createPrerequisiteAction( if (!kpBelongs) { return { success: false, message: t("kpNotBelong") }; } + const prereqBelongs = await verifyKnowledgePointBelongsToTextbook( + parsed.data.prerequisiteKpId, + textbookId, + ); + if (!prereqBelongs) { + return { success: false, message: t("kpNotBelong") }; + } // 循环检测 const existingEdges = await getPrerequisiteEdgesForTextbook(textbookId); @@ -475,6 +482,22 @@ export async function deletePrerequisiteAction( return { success: false, message: t("invalidInput") }; } + // 归属校验:防止跨教材越权删除前置依赖 + const kpBelongs = await verifyKnowledgePointBelongsToTextbook( + parsed.data.knowledgePointId, + textbookId, + ); + if (!kpBelongs) { + return { success: false, message: t("kpNotBelong") }; + } + const prereqBelongs = await verifyKnowledgePointBelongsToTextbook( + parsed.data.prerequisiteKpId, + textbookId, + ); + if (!prereqBelongs) { + return { success: false, message: t("kpNotBelong") }; + } + await deletePrerequisite(parsed.data); revalidatePath(`/teacher/textbooks/${textbookId}`); return { success: true, message: t("prerequisiteDeleted") }; diff --git a/src/modules/textbooks/analytics.tsx b/src/modules/textbooks/analytics.tsx index 7fdde55..8d4bb33 100644 --- a/src/modules/textbooks/analytics.tsx +++ b/src/modules/textbooks/analytics.tsx @@ -1,3 +1,5 @@ +"use client" + /** * 教材模块埋点接口(预留)。 * @@ -5,8 +7,6 @@ * 通过 React Context 注入,组件内调用 `useTextbookAnalytics()` 获取。 */ -"use client" - import { createContext, useContext, type ReactNode } from "react" export interface TextbookAnalytics { diff --git a/src/modules/textbooks/components/chapter-sidebar-list.tsx b/src/modules/textbooks/components/chapter-sidebar-list.tsx index 543af00..93ca80f 100644 --- a/src/modules/textbooks/components/chapter-sidebar-list.tsx +++ b/src/modules/textbooks/components/chapter-sidebar-list.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { ChevronRight, FileText, Folder, Plus, Trash2, GripVertical } from "lucide-react" import { toast } from "sonner" +import { useTranslations } from "next-intl" import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core" import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" @@ -26,6 +27,7 @@ import { import { cn } from "@/shared/lib/utils" import { CreateChapterDialog } from "./create-chapter-dialog" import { deleteChapterAction, reorderChaptersAction } from "../actions" +import { findChapterParent } from "../utils" interface SortableChapterItemProps { chapter: Chapter @@ -39,6 +41,7 @@ interface SortableChapterItemProps { } function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) { + const t = useTranslations("textbooks") const [isOpen, setIsOpen] = useState(level === 0) const hasChildren = chapter.children && chapter.children.length > 0 const isSelected = chapter.id === selectedId @@ -68,7 +71,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, isDragging && "opacity-50" )}> {canEdit && ( -
+
)} @@ -87,7 +90,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, isOpen && "rotate-90" )} /> - Toggle + {t("dialog.chapter.toggle")} ) : ( @@ -115,7 +118,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, e.stopPropagation() onCreateSub(chapter.id) }} - title="Add Subchapter" + aria-label={t("dialog.chapter.addSubchapter")} > @@ -127,7 +130,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, e.stopPropagation() onDelete(chapter) }} - title="Delete Chapter" + aria-label={t("dialog.chapter.delete")} > @@ -193,9 +196,10 @@ interface ChapterSidebarListProps { } export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) { + const t = useTranslations("textbooks") const [showCreateDialog, setShowCreateDialog] = useState(false) const [createParentId, setCreateParentId] = useState(undefined) - + const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [isDeleting, setIsDeleting] = useState(false) @@ -209,36 +213,12 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte const { active, over } = event if (!over || active.id === over.id) return - // Find which list the items belong to - // Since we only support sibling reordering for now, we assume active and over are in the same list - // We need a helper to find the parent of an item in the tree - const findParent = (items: Chapter[], id: string): Chapter | null => { - for (const item of items) { - if (item.children?.some(c => c.id === id)) return item - if (item.children) { - const found = findParent(item.children, id) - if (found) return found - } - } - return null - } + // v2-P2: 复用 utils.ts 的 findChapterParent,消除重复代码 + const activeParent = findChapterParent(chapters, String(active.id)) - const activeParent = findParent(chapters, active.id as string) - - // If parents don't match (and neither is root), we can't reorder easily in this simplified version - // But actually, we need to check if they are in the same list. - // If both are root items (activeParent is null), they are siblings. - - // Simplified logic: We trust dnd-kit's SortableContext to only allow valid drops if we restricted it? - // No, dnd-kit allows dropping anywhere by default unless restricted. - - // We need to find the list that contains the 'active' item - // And the list that contains the 'over' item. - // If they are the same list, we reorder. - let activeList: Chapter[] = chapters let activeParentId: string | null = null - + if (activeParent) { activeList = activeParent.children || [] activeParentId = activeParent.id @@ -246,36 +226,51 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte // Check if active is in root if (!chapters.some(c => c.id === active.id)) { // Should not happen if tree is consistent - return + return } } - + // Check if over is in the same list if (activeList.some(c => c.id === over.id)) { const newIndex = activeList.findIndex((item) => item.id === over.id) - - await reorderChaptersAction(active.id as string, newIndex, activeParentId, textbookId) - toast.success("Order updated") + + try { + const result = await reorderChaptersAction(String(active.id), newIndex, activeParentId, textbookId) + if (result.success) { + toast.success(t("dialog.chapter.orderUpdated")) + } else { + toast.error(result.message || t("dialog.chapter.orderUpdated")) + } + } catch (e) { + console.error("Failed to reorder chapters", e) + toast.error(t("dialog.chapter.orderUpdated")) + } } } const handleDelete = async () => { if (!deleteTarget) return setIsDeleting(true) - const res = await deleteChapterAction(deleteTarget.id, textbookId) - setIsDeleting(false) - if (res.success) { - toast.success(res.message) - setShowDeleteDialog(false) - setDeleteTarget(null) - } else { - toast.error(res.message) + try { + const res = await deleteChapterAction(deleteTarget.id, textbookId) + if (res.success) { + toast.success(res.message) + setShowDeleteDialog(false) + setDeleteTarget(null) + } else { + toast.error(res.message) + } + } catch (e) { + console.error("Failed to delete chapter", e) + toast.error(t("reader.deleteFailed")) + } finally { + setIsDeleting(false) } } const handleDeleteRequest = (chapter: Chapter) => { if (chapter.children && chapter.children.length > 0) { - toast.error("Cannot delete chapter with subchapters") + toast.error(t("dialog.chapter.cannotDeleteWithSubchapters")) return } setDeleteTarget(chapter) @@ -313,7 +308,7 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte textbookId={textbookId} onDelete={handleDeleteRequest} onCreateSub={handleCreateSubRequest} - canEdit={true} + canEdit={canEdit} /> - Delete Chapter? + {t("dialog.chapter.deleteTitle")} - This will permanently delete {deleteTarget?.title}. - This action cannot be undone. + {t("dialog.chapter.deleteDesc", { title: deleteTarget?.title ?? "" })} - Cancel + {t("dialog.chapter.cancel")} - {isDeleting ? "Deleting..." : "Delete"} + {isDeleting ? t("dialog.chapter.deleting") : t("dialog.chapter.delete")} diff --git a/src/modules/textbooks/components/create-chapter-dialog.tsx b/src/modules/textbooks/components/create-chapter-dialog.tsx index c0ade98..e8cc828 100644 --- a/src/modules/textbooks/components/create-chapter-dialog.tsx +++ b/src/modules/textbooks/components/create-chapter-dialog.tsx @@ -50,12 +50,17 @@ export function CreateChapterDialog({ const setOpen = onOpenChange ?? setUncontrolledOpen const handleSubmit = async (formData: FormData) => { - const result = await createChapterAction(textbookId, parentId, null, formData) - if (result.success) { - toast.success(result.message) - setOpen(false) - } else { - toast.error(result.message) + try { + const result = await createChapterAction(textbookId, parentId, null, formData) + if (result.success) { + toast.success(result.message) + setOpen(false) + } else { + toast.error(result.message) + } + } catch (e) { + console.error("Failed to create chapter", e) + toast.error(t("reader.createFailed")) } } diff --git a/src/modules/textbooks/components/graph-prerequisite-edge.tsx b/src/modules/textbooks/components/graph-prerequisite-edge.tsx index 9385cbf..c1f8b96 100644 --- a/src/modules/textbooks/components/graph-prerequisite-edge.tsx +++ b/src/modules/textbooks/components/graph-prerequisite-edge.tsx @@ -3,6 +3,7 @@ import { memo } from "react" import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react" import { cn } from "@/shared/lib/utils" +import type { GraphEdgeData } from "../types" function GraphPrerequisiteEdgeComponent({ id, @@ -23,7 +24,9 @@ function GraphPrerequisiteEdgeComponent({ targetPosition, }) - const isHighlighted = (data as { isHighlighted?: boolean } | undefined)?.isHighlighted ?? false + // EdgeProps.data 类型为 Record,经 unknown 安全转换读取 GraphEdgeData + const edgeData = data as unknown as GraphEdgeData | undefined + const isHighlighted = edgeData?.isHighlighted ?? false return ( (initialViewMode) const [searchText, setSearchText] = useState("") const [selectedKpId, setSelectedKpId] = useState(null) + // 添加前置依赖对话框状态 + const [addPrereqOpen, setAddPrereqOpen] = useState(false) + const [newPrereqId, setNewPrereqId] = useState("") + const [isSavingPrereq, setIsSavingPrereq] = useState(false) - const { data, isLoading, error } = useGraphData(textbookId, viewMode) + const { data, isLoading, error, reload } = useGraphData(textbookId, viewMode) const availableViewModes: GraphViewMode[] = isTeacher ? ["structure", "class-mastery"] @@ -173,6 +198,51 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 }) }, [reactFlow]) + // 添加前置依赖 + const handleAddPrerequisite = useCallback(async () => { + if (!selectedKpId || !newPrereqId || !textbookId) return + setIsSavingPrereq(true) + const formData = new FormData() + formData.set("knowledgePointId", selectedKpId) + formData.set("prerequisiteKpId", newPrereqId) + formData.set("textbookId", textbookId) + const result = await createPrerequisiteAction(formData) + setIsSavingPrereq(false) + if (result.success) { + toast.success(t("graph.detail.prerequisiteAdded")) + setAddPrereqOpen(false) + setNewPrereqId("") + reload() + } else { + toast.error(result.message) + } + }, [selectedKpId, newPrereqId, textbookId, t, reload]) + + // 删除前置依赖 + const handleRemovePrerequisite = useCallback(async (prereqId: string) => { + if (!selectedKpId || !textbookId) return + const formData = new FormData() + formData.set("knowledgePointId", selectedKpId) + formData.set("prerequisiteKpId", prereqId) + formData.set("textbookId", textbookId) + const result = await deletePrerequisiteAction(formData) + if (result.success) { + toast.success(t("graph.detail.prerequisiteRemoved")) + reload() + } else { + toast.error(result.message) + } + }, [selectedKpId, textbookId, t, reload]) + + // 可选的前置知识点(排除自身和已是前置的) + const availablePrereqs = useMemo(() => { + if (!data || !selectedKpId) return [] + const existing = new Set(data.knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? []) + return data.knowledgePoints.filter((kp) => + kp.id !== selectedKpId && !existing.has(kp.id), + ) + }, [data, selectedKpId]) + if (isLoading && !data) { return (
@@ -255,15 +325,44 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know textbookId={textbookId} onClose={() => setSelectedKpId(null)} onJumpToKp={onJumpToKp} - onAddPrerequisite={() => { - // 后续迭代:打开添加前置对话框 - }} - onRemovePrerequisite={(_prereqId: string) => { - // 后续迭代:调用 deletePrerequisiteAction - }} + onAddPrerequisite={() => setAddPrereqOpen(true)} + onRemovePrerequisite={handleRemovePrerequisite} />
)} + + {/* 添加前置依赖对话框 */} + + + + {t("graph.detail.addPrerequisiteTitle")} + {t("graph.detail.addPrerequisiteDesc")} + + + + + + + +
) } diff --git a/src/modules/textbooks/components/teacher-textbook-reader.tsx b/src/modules/textbooks/components/teacher-textbook-reader.tsx new file mode 100644 index 0000000..283da28 --- /dev/null +++ b/src/modules/textbooks/components/teacher-textbook-reader.tsx @@ -0,0 +1,50 @@ +"use client" + +import type { ReactNode } from "react" +import { useTranslations } from "next-intl" +import { TextbookReader, type TextbookReaderProps } from "./textbook-reader" +import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" +import type { KnowledgePoint } from "../types" + +/** + * 教师端 TextbookReader 包装组件。 + * + * 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader) + * 传递函数 prop(renderQuestionCreator)。此包装组件在客户端层组装 + * renderQuestionCreator,避免违反 Next.js App Router 的 Server→Client 序列化约束。 + */ +export function TeacherTextbookReader({ + chapters, + textbookId, +}: { + chapters: TextbookReaderProps["chapters"] + textbookId: string +}) { + const t = useTranslations("textbooks") + const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({ + open, + onOpenChange, + targetKp, + }: { + open: boolean + onOpenChange: (open: boolean) => void + targetKp: KnowledgePoint | null + }): ReactNode => ( + + ) + + return ( + + ) +} diff --git a/src/modules/textbooks/components/textbook-card.tsx b/src/modules/textbooks/components/textbook-card.tsx index 27c17f3..801e5c1 100644 --- a/src/modules/textbooks/components/textbook-card.tsx +++ b/src/modules/textbooks/components/textbook-card.tsx @@ -1,6 +1,8 @@ "use client" +import { useState } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { Book, MoreVertical, Edit, Trash2, BookOpen, GraduationCap, Building2 } from "lucide-react" import { @@ -17,9 +19,21 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" import { cn, formatDate } from "@/shared/lib/utils" import type { Textbook } from "../types" -import { getSubjectColor } from "../constants" +import { getSubjectColor, getSubjectLabelKey, getGradeLabelKey } from "../constants" +import { deleteTextbookAction } from "../actions" +import { toast } from "sonner" interface TextbookCardProps { textbook: Textbook @@ -29,8 +43,30 @@ interface TextbookCardProps { export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardProps) { const t = useTranslations("textbooks") + const router = useRouter() const base = hrefBase || "/teacher/textbooks" const colorClass = getSubjectColor(textbook.subject) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + setIsDeleting(true) + try { + const result = await deleteTextbookAction(textbook.id) + if (result.success) { + toast.success(result.message) + router.refresh() + } else { + toast.error(result.message) + } + } catch (e) { + console.error("Failed to delete textbook", e) + toast.error(t("reader.deleteFailed")) + } finally { + setIsDeleting(false) + setShowDeleteDialog(false) + } + } return ( @@ -38,14 +74,14 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
- {textbook.subject} + {t(`subject.${getSubjectLabelKey(textbook.subject)}`)}
- {textbook.grade || t("card.gradeNA")} + {textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}
@@ -63,7 +99,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
- {textbook.grade || t("card.gradeNA")} + {textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}
@@ -86,7 +122,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
- + {t("card.updated")} {formatDate(textbook.updatedAt)} {!hideActions && ( @@ -104,7 +140,10 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr {t("card.editContent")} - + setShowDeleteDialog(true)} + > {t("card.delete")} @@ -113,6 +152,30 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr )}
+ + + + + {t("dialog.settings.deleteConfirmTitle")} + + {t("dialog.settings.deleteConfirmDesc")} + + + + {t("dialog.knowledge.cancel")} + { + e.preventDefault() + handleDelete() + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? t("dialog.settings.processing") : t("dialog.settings.delete")} + + + + ) } diff --git a/src/modules/textbooks/components/textbook-form-dialog.tsx b/src/modules/textbooks/components/textbook-form-dialog.tsx index cdf6025..40e719c 100644 --- a/src/modules/textbooks/components/textbook-form-dialog.tsx +++ b/src/modules/textbooks/components/textbook-form-dialog.tsx @@ -42,12 +42,17 @@ export function TextbookFormDialog() { const [open, setOpen] = useState(false) const handleSubmit = async (formData: FormData) => { - const result = await createTextbookAction(null, formData) - if (result.success) { - toast.success(result.message) - setOpen(false) - } else { - toast.error(result.message) + try { + const result = await createTextbookAction(null, formData) + if (result.success) { + toast.success(result.message) + setOpen(false) + } else { + toast.error(result.message) + } + } catch (e) { + console.error("Failed to create textbook", e) + toast.error(t("reader.createFailed")) } } diff --git a/src/modules/textbooks/components/textbook-reader.tsx b/src/modules/textbooks/components/textbook-reader.tsx index d0088b4..71f8cef 100644 --- a/src/modules/textbooks/components/textbook-reader.tsx +++ b/src/modules/textbooks/components/textbook-reader.tsx @@ -193,7 +193,7 @@ export function TextbookReader({ } } catch (e) { console.error("Failed to save chapter content", e) - toast.error("Failed to save content") + toast.error(t("reader.saveFailed")) } finally { setIsSaving(false) } diff --git a/src/modules/textbooks/components/textbook-settings-dialog.tsx b/src/modules/textbooks/components/textbook-settings-dialog.tsx index 48bccb8..3298372 100644 --- a/src/modules/textbooks/components/textbook-settings-dialog.tsx +++ b/src/modules/textbooks/components/textbook-settings-dialog.tsx @@ -52,13 +52,19 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi const handleUpdate = async (formData: FormData) => { setLoading(true) - const result = await updateTextbookAction(textbook.id, null, formData) - setLoading(false) - if (result.success) { - toast.success(result.message) - setOpen(false) - } else { - toast.error(result.message) + try { + const result = await updateTextbookAction(textbook.id, null, formData) + if (result.success) { + toast.success(result.message) + setOpen(false) + } else { + toast.error(result.message) + } + } catch (e) { + console.error("Failed to update textbook", e) + toast.error(t("reader.updateFailed")) + } finally { + setLoading(false) } } @@ -66,14 +72,20 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi const handleDelete = async () => { setDeleteDialogOpen(false) setLoading(true) - const result = await deleteTextbookAction(textbook.id) + try { + const result = await deleteTextbookAction(textbook.id) - if (result.success) { - toast.success(result.message) - router.push("/teacher/textbooks") - } else { + if (result.success) { + toast.success(result.message) + router.push("/teacher/textbooks") + } else { + toast.error(result.message) + } + } catch (e) { + console.error("Failed to delete textbook", e) + toast.error(t("reader.deleteFailed")) + } finally { setLoading(false) - toast.error(result.message) } } diff --git a/src/shared/i18n/messages/en/textbooks.json b/src/shared/i18n/messages/en/textbooks.json index f06aad9..4625013 100644 --- a/src/shared/i18n/messages/en/textbooks.json +++ b/src/shared/i18n/messages/en/textbooks.json @@ -48,6 +48,11 @@ "cancel": "Cancel", "save": "Save", "saving": "Saving...", + "saveFailed": "Failed to save", + "createFailed": "Failed to create", + "updateFailed": "Failed to update", + "deleteFailed": "Failed to delete", + "questionCreatorDefaultContent": "Please explain the knowledge point: {name}", "addKnowledgePoint": "Add Knowledge Point", "clickToViewKp": "Click to view knowledge point details", "noChapters": "No chapters", @@ -228,12 +233,20 @@ "viewAllQuestions": "View all questions", "editPrerequisite": "Edit prerequisites", "addPrerequisite": "Add prerequisite", + "addPrerequisiteTitle": "Add Prerequisite Knowledge Point", + "addPrerequisiteDesc": "Select a knowledge point as the prerequisite of the current one.", + "selectPrerequisite": "Select a prerequisite knowledge point", "removePrerequisite": "Remove", "noPrerequisites": "No prerequisite knowledge points", "noSuccessors": "No successor knowledge points", "masteryNotAssessed": "Not assessed", "correctRate": "Correct rate", - "totalQuestions": "Total questions" + "totalQuestions": "Total questions", + "prerequisiteAdded": "Prerequisite added", + "prerequisiteRemoved": "Prerequisite removed", + "cancel": "Cancel", + "confirm": "Confirm", + "saving": "Saving..." }, "toolbar": { "search": "Search knowledge points", diff --git a/src/shared/i18n/messages/zh-CN/textbooks.json b/src/shared/i18n/messages/zh-CN/textbooks.json index 55e7845..95b0eef 100644 --- a/src/shared/i18n/messages/zh-CN/textbooks.json +++ b/src/shared/i18n/messages/zh-CN/textbooks.json @@ -48,6 +48,11 @@ "cancel": "取消", "save": "保存", "saving": "保存中...", + "saveFailed": "保存失败", + "createFailed": "创建失败", + "updateFailed": "更新失败", + "deleteFailed": "删除失败", + "questionCreatorDefaultContent": "请讲解知识点:{name}", "addKnowledgePoint": "添加知识点", "clickToViewKp": "点击查看知识点详情", "noChapters": "暂无章节", @@ -228,12 +233,20 @@ "viewAllQuestions": "查看全部题目", "editPrerequisite": "编辑前置依赖", "addPrerequisite": "添加前置", + "addPrerequisiteTitle": "添加前置知识点", + "addPrerequisiteDesc": "选择一个知识点作为当前知识点的前置依赖。", + "selectPrerequisite": "请选择前置知识点", "removePrerequisite": "移除", "noPrerequisites": "暂无前置知识点", "noSuccessors": "暂无后置知识点", "masteryNotAssessed": "未测评", "correctRate": "正确率", - "totalQuestions": "总题数" + "totalQuestions": "总题数", + "prerequisiteAdded": "前置依赖已添加", + "prerequisiteRemoved": "前置依赖已移除", + "cancel": "取消", + "confirm": "确认", + "saving": "保存中..." }, "toolbar": { "search": "搜索知识点",