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

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