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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user