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
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
"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>
|
||
)
|
||
}
|