Files
NextEdu/src/modules/textbooks/components/knowledge-graph.tsx
SpecialX f3c223d914 feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
settings:

- Add actions-brand, brand-config, data-access-brand for brand management

- Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card

- Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card

- Add security-recent-logins-section, security-two-factor-section

- Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils

questions:

- Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor

- Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils

school:

- Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards

- Add use-grade-data hook

textbooks:

- Add textbook-form-fields component

- Add use-kp-create, use-kp-delete, use-kp-update hooks
2026-07-03 10:26:00 +08:00

389 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<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, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
// 教师可查看班级掌握度,学生可查看个人掌握度
const availableViewModes: GraphViewMode[] = canEdit
? ["structure", "class-mastery"]
: ["structure", "student-mastery"]
// 章节颜色映射
const chapterColorMap = useMemo(() => {
const map = new Map<string, string>()
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<string>()
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<string>()
const related = new Set<string>([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 (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
{t("reader.loadingKnowledge")}
</div>
)
}
if (error) {
return (
<EmptyState
icon={Share2}
title={t("graph.error.loadFailed")}
description={error}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
if (!data || data.knowledgePoints.length === 0) {
return (
<EmptyState
icon={Share2}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
return (
<div className="flex h-full">
<div className="flex-1 flex flex-col min-h-0">
<GraphToolbar
viewMode={viewMode}
onViewModeChange={setViewMode}
availableViewModes={availableViewModes}
searchText={searchText}
onSearchChange={setSearchText}
onResetView={resetView}
isRefreshing={isRefreshing}
/>
<div className="flex-1 min-h-0 relative">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.2}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="!bg-background !border !rounded-lg" />
<MiniMap
className="!bg-background !border !rounded-lg"
nodeColor={(node) => {
// 安全的类型收窄node.data 是 Record<string, unknown>
// GraphLayoutNodeData 有索引签名 [key: string]: unknown是 Record 的子类型
const data = node.data as GraphLayoutNodeData
return data.graphData?.chapterColor ?? "#6b7280"
}}
/>
</ReactFlow>
</div>
</div>
{selectedKp && (
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
<div className="w-[300px] shrink-0">
<GraphNodeDetailPanel
kp={selectedKp}
mastery={selectedMastery}
prerequisites={prerequisites}
successors={successors}
canEdit={canEdit}
onClose={() => setSelectedKpId(null)}
onJumpToKp={onJumpToKp}
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>
)
}
export function KnowledgeGraph(props: KnowledgeGraphProps) {
return (
<ReactFlowProvider>
<KnowledgeGraphInner {...props} />
</ReactFlowProvider>
)
}