"use client" import { useState, useMemo, useCallback } from "react" import { ReactFlow, Background, BackgroundVariant, Controls, MiniMap, ReactFlowProvider, useReactFlow, type Node, type Edge, } 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 type { GraphLayoutNodeData } 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" import { GraphNodeDetailPanel } from "./graph-node-detail-panel" const nodeTypes = { kpNode: GraphKpNode } const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge } /** 章节颜色调色板 */ const CHAPTER_COLORS = [ "#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", ] interface KnowledgeGraphProps { textbookId: string /** 初始视图模式,默认 structure */ initialViewMode?: GraphViewMode } function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: KnowledgeGraphProps) { const t = useTranslations("textbooks") const { hasPermission } = usePermission() const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE) const reactFlow = useReactFlow() const [viewMode, setViewMode] = useState(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, isRefreshing, error, reload } = useGraphData(textbookId, viewMode) // 教师可查看班级掌握度,学生可查看个人掌握度 const availableViewModes: GraphViewMode[] = canEdit ? ["structure", "class-mastery"] : ["structure", "student-mastery"] // 章节颜色映射 const chapterColorMap = useMemo(() => { const map = new Map() if (!data) return map const chapterIds = [...new Set( data.knowledgePoints .map((kp) => kp.chapterId) .filter((id): id is string => id !== null), )] chapterIds.forEach((id, index) => { map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!) }) return map }, [data]) const layout = useMemo(() => { if (!data) return { nodes: [], edges: [], width: 0, height: 0 } return computeGraphLayout(data.knowledgePoints) }, [data]) // 搜索高亮 const matchedIds = useMemo(() => { if (!searchText || !data) return new Set() const searchLower = searchText.toLowerCase() return new Set( data.knowledgePoints .filter((kp) => kp.name.toLowerCase().includes(searchLower)) .map((kp) => kp.id), ) }, [searchText, data]) // 关联节点高亮(选中节点的前置+后置) const relatedIds = useMemo(() => { if (!selectedKpId || !data) return new Set() const related = new Set([selectedKpId]) const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId) if (selectedKp) { for (const id of selectedKp.prerequisiteIds) related.add(id) for (const kp of data.knowledgePoints) { if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id) } } return related }, [selectedKpId, data]) // 从已加载数据计算前置/后置列表(避免 server-only 导入) const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => { if (!selectedKpId || !data) return [] const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId) if (!selectedKp) return [] return data.knowledgePoints .filter((kp) => selectedKp.prerequisiteIds.includes(kp.id)) .map((kp) => ({ id: kp.id, name: kp.name, description: kp.description })) }, [selectedKpId, data]) const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => { if (!selectedKpId || !data) return [] return data.knowledgePoints .filter((kp) => kp.prerequisiteIds.includes(selectedKpId)) .map((kp) => ({ id: kp.id, name: kp.name, description: kp.description })) }, [selectedKpId, data]) // 组装 React Flow nodes const rfNodes: Node[] = useMemo(() => { return layout.nodes.map((node) => { const kp = node.data.kp const mastery = data?.masteryMap[kp.id] ?? null const isSelected = selectedKpId === node.id const isHighlighted = !searchText ? (selectedKpId === null || relatedIds.has(node.id)) : matchedIds.has(node.id) const graphData: GraphNodeData = { kp, mastery, viewMode, isSelected, isHighlighted, chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "#6b7280", } return { ...node, data: { ...node.data, graphData }, selected: isSelected, } }) }, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap]) // 组装 React Flow edges const rfEdges: Edge[] = useMemo(() => { return layout.edges.map((edge) => ({ ...edge, data: { ...edge.data, isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target), }, })) }, [layout, selectedKpId, relatedIds]) const onNodeClick = useCallback((_event: unknown, node: Node) => { setSelectedKpId(node.id) }, []) const resetView = useCallback(() => { reactFlow.fitView({ duration: 300 }) setSearchText("") setSelectedKpId(null) }, [reactFlow]) const onJumpToKp = useCallback((kpId: string) => { setSelectedKpId(kpId) reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 }) }, [reactFlow]) // 添加前置依赖 const handleAddPrerequisite = useCallback(async () => { if (!selectedKpId || !newPrereqId || !textbookId) return setIsSavingPrereq(true) try { const formData = new FormData() formData.set("knowledgePointId", selectedKpId) formData.set("prerequisiteKpId", newPrereqId) formData.set("textbookId", textbookId) const result = await createPrerequisiteAction(formData) if (result.success) { toast.success(t("graph.detail.prerequisiteAdded")) setAddPrereqOpen(false) setNewPrereqId("") reload() } else { toast.error(result.message) } } catch (e) { toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed")) } finally { setIsSavingPrereq(false) } }, [selectedKpId, newPrereqId, textbookId, t, reload]) // 删除前置依赖 const handleRemovePrerequisite = useCallback(async (prereqId: string) => { if (!selectedKpId || !textbookId) return try { 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) } } catch (e) { toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed")) } }, [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 (
{t("reader.loadingKnowledge")}
) } if (error) { return ( ) } if (!data || data.knowledgePoints.length === 0) { return ( ) } const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null return (
{ // 安全的类型收窄:node.data 是 Record, // GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型 const data = node.data as GraphLayoutNodeData return data.graphData?.chapterColor ?? "#6b7280" }} />
{selectedKp && ( // 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
setSelectedKpId(null)} onJumpToKp={onJumpToKp} onAddPrerequisite={() => setAddPrereqOpen(true)} onRemovePrerequisite={handleRemovePrerequisite} />
)} {/* 添加前置依赖对话框 */} {t("graph.detail.addPrerequisiteTitle")} {t("graph.detail.addPrerequisiteDesc")}
) } export function KnowledgeGraph(props: KnowledgeGraphProps) { return ( ) }