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

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