fix(textbooks): 规范核查修复 — 安全漏洞+功能缺失+i18n+类型安全

安全: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 个单元测试全部通过。
This commit is contained in:
SpecialX
2026-06-23 00:30:14 +08:00
parent 58656da983
commit ec87cd9efa
14 changed files with 388 additions and 104 deletions

View File

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

View File

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

View File

@@ -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 && (
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded">
<div {...attributes} {...listeners} className="mr-1 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-muted rounded" aria-label={t("dialog.chapter.dragHandle")}>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/50" />
</div>
)}
@@ -87,7 +90,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
isOpen && "rotate-90"
)}
/>
<span className="sr-only">Toggle</span>
<span className="sr-only">{t("dialog.chapter.toggle")}</span>
</Button>
</CollapsibleTrigger>
) : (
@@ -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")}
>
<Plus className="h-3.5 w-3.5" />
</Button>
@@ -127,7 +130,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId,
e.stopPropagation()
onDelete(chapter)
}}
title="Delete Chapter"
aria-label={t("dialog.chapter.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -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<string | undefined>(undefined)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Chapter | null>(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}
/>
<CreateChapterDialog
@@ -329,16 +324,15 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chapter?</AlertDialogTitle>
<AlertDialogTitle>{t("dialog.chapter.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-medium text-foreground">{deleteTarget?.title}</span>.
This action cannot be undone.
{t("dialog.chapter.deleteDesc", { title: deleteTarget?.title ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("dialog.chapter.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
{isDeleting ? t("dialog.chapter.deleting") : t("dialog.chapter.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

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

View File

@@ -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<string, unknown>,经 unknown 安全转换读取 GraphEdgeData
const edgeData = data as unknown as GraphEdgeData | undefined
const isHighlighted = edgeData?.isHighlighted ?? false
return (
<BaseEdge

View File

@@ -14,13 +14,34 @@ import {
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Share2 } from "lucide-react"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import type { GraphViewMode, GraphNodeData } from "../types"
import { computeGraphLayout } from "../graph-layout"
import { useGraphData } from "../hooks/use-graph-data"
import {
createPrerequisiteAction,
deletePrerequisiteAction,
} from "../actions"
import { GraphKpNode } from "./graph-kp-node"
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
import { GraphToolbar } from "./graph-toolbar"
@@ -51,8 +72,12 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
const [searchText, setSearchText] = useState("")
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
// 添加前置依赖对话框状态
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
const [newPrereqId, setNewPrereqId] = useState<string>("")
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 (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
@@ -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}
/>
</div>
)}
{/* 添加前置依赖对话框 */}
<Dialog open={addPrereqOpen} onOpenChange={setAddPrereqOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
</DialogHeader>
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
<SelectTrigger>
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
</SelectTrigger>
<SelectContent>
{availablePrereqs.map((kp) => (
<SelectItem key={kp.id} value={kp.id}>
{kp.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setAddPrereqOpen(false)}>
{t("graph.detail.cancel")}
</Button>
<Button
onClick={handleAddPrerequisite}
disabled={!newPrereqId || isSavingPrereq}
>
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -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 ComponentTextbookReader
* 传递函数 proprenderQuestionCreator。此包装组件在客户端层组装
* 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 => (
<CreateQuestionDialog
open={open}
onOpenChange={onOpenChange}
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
defaultType="text"
/>
)
return (
<TextbookReader
key={textbookId}
chapters={chapters}
textbookId={textbookId}
renderQuestionCreator={renderQuestionCreator}
/>
)
}

View File

@@ -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 (
<Card className="group flex flex-col h-full overflow-hidden border-border/60 transition-all duration-300 hover:shadow-md hover:border-primary/50">
@@ -38,14 +74,14 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
<div className={cn("relative h-32 w-full overflow-hidden p-5", colorClass)}>
<div className="relative z-10 flex h-full flex-col justify-between">
<Badge variant="secondary" className="w-fit bg-background/80 border border-border/60 shadow-sm">
{textbook.subject}
{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}
</Badge>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-background text-foreground shadow-sm ring-1 ring-border/60">
<Book className="h-5 w-5" />
</div>
<div className="text-xs font-medium text-foreground/70">
{textbook.grade || t("card.gradeNA")}
{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}
</div>
</div>
</div>
@@ -63,7 +99,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
<div className="flex flex-wrap gap-y-1 gap-x-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />
<span>{textbook.grade || t("card.gradeNA")}</span>
<span>{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : t("card.gradeNA")}</span>
</div>
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5" />
@@ -86,7 +122,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground/60 mr-2">
<span className="text-xs text-muted-foreground/60 mr-2">
{t("card.updated")} {formatDate(textbook.updatedAt)}
</span>
{!hideActions && (
@@ -104,7 +140,10 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
{t("card.editContent")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("card.delete")}
</DropdownMenuItem>
@@ -113,6 +152,30 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
)}
</div>
</CardFooter>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("dialog.settings.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("dialog.settings.deleteConfirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleDelete()
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? t("dialog.settings.processing") : t("dialog.settings.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
}

View File

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

View File

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

View File

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