forked from admin/hair-keeper
500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import React, { useCallback, useMemo, useState, useRef } from 'react'
|
||
import {
|
||
ReactFlow,
|
||
Node,
|
||
Edge,
|
||
Controls,
|
||
Background,
|
||
MiniMap,
|
||
Panel,
|
||
useReactFlow,
|
||
ReactFlowProvider,
|
||
NodeTypes,
|
||
MarkerType,
|
||
ReactFlowProps,
|
||
useNodesState,
|
||
useEdgesState,
|
||
} from '@xyflow/react'
|
||
import '@xyflow/react/dist/style.css'
|
||
import { Search, Maximize2 } from 'lucide-react'
|
||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import dagre from 'dagre'
|
||
import { useDebouncedCallback } from '@/hooks/use-debounced-callback'
|
||
import { useTheme } from 'next-themes'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
// 布局配置类型
|
||
export interface LayoutConfig {
|
||
direction?: 'TB' | 'LR'
|
||
ranksep?: number
|
||
nodesep?: number
|
||
nodeWidth?: number
|
||
nodeHeight?: number
|
||
}
|
||
|
||
// 自动布局函数
|
||
const getLayoutedElements = (
|
||
nodes: Node[],
|
||
edges: Edge[],
|
||
config: LayoutConfig = {}
|
||
) => {
|
||
const {
|
||
direction = 'TB',
|
||
ranksep = 100,
|
||
nodesep = 80,
|
||
nodeWidth = 200,
|
||
nodeHeight = 80,
|
||
} = config
|
||
|
||
const dagreGraph = new dagre.graphlib.Graph()
|
||
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||
dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep })
|
||
|
||
nodes.forEach((node) => {
|
||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight })
|
||
})
|
||
|
||
edges.forEach((edge) => {
|
||
dagreGraph.setEdge(edge.source, edge.target)
|
||
})
|
||
|
||
dagre.layout(dagreGraph)
|
||
|
||
const layoutedNodes = nodes.map((node) => {
|
||
const nodeWithPosition = dagreGraph.node(node.id)
|
||
return {
|
||
...node,
|
||
position: {
|
||
x: nodeWithPosition.x - nodeWidth / 2,
|
||
y: nodeWithPosition.y - nodeHeight / 2,
|
||
},
|
||
}
|
||
})
|
||
|
||
return { nodes: layoutedNodes, edges }
|
||
}
|
||
|
||
// 通用图组件 Props
|
||
export interface AdaptiveGraphProps<TNodeData = any> {
|
||
// 原始节点数据
|
||
nodes: TNodeData[]
|
||
// 原始边数据
|
||
edges: Array<{ source: string; target: string; label?: string }>
|
||
// 节点类型定义
|
||
nodeTypes: NodeTypes
|
||
// 过滤函数:根据搜索查询返回过滤后的节点 ID 集合和高亮节点 ID 集合(可选)
|
||
onFilter?: (nodes: TNodeData[], query: string) => {
|
||
filteredNodeIds: Set<string>
|
||
highlightedNodeIds: Set<string>
|
||
}
|
||
// 节点转换函数:将原始节点数据转换为 ReactFlow 节点
|
||
transformNode: (
|
||
node: TNodeData,
|
||
options: {
|
||
isHighlighted: boolean
|
||
isDimmed: boolean
|
||
}
|
||
) => Node
|
||
// 边转换函数:将原始边数据转换为 ReactFlow 边(可选)
|
||
transformEdge?: (
|
||
edge: { source: string; target: string; label?: string },
|
||
index: number,
|
||
options: {
|
||
isHighlighted: boolean
|
||
}
|
||
) => Edge
|
||
// MiniMap 节点颜色函数(可选)
|
||
getNodeColor?: (node: Node) => string
|
||
// 节点点击回调
|
||
onNodeClick?: (node: TNodeData) => void
|
||
// 搜索占位符文本
|
||
searchPlaceholder?: string
|
||
// 统计信息渲染函数(可选)
|
||
renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode
|
||
// 布局配置
|
||
layoutConfig?: LayoutConfig
|
||
// 容器样式类名(用于控制高度等样式)
|
||
className?: string
|
||
// ReactFlow 额外属性(可选)
|
||
reactFlowProps?: Partial<ReactFlowProps>
|
||
}
|
||
|
||
/**
|
||
* GraphContent - 图表渲染核心组件
|
||
*
|
||
* 这是一个独立的图表渲染组件,负责实际的 ReactFlow 图表展示和交互。
|
||
* 它必须在 ReactFlowProvider 内部使用,因为它依赖 useReactFlow hook。
|
||
*
|
||
* 职责:
|
||
* - 渲染 ReactFlow 图表(节点、边、控件、小地图等)
|
||
* - 管理节点和边的交互状态(拖拽、选择等)
|
||
* - 处理搜索输入和全屏按钮的 UI
|
||
* - 自动调整视野以适应过滤后的节点
|
||
*
|
||
* 与 AdaptiveGraph 的关系:
|
||
* - AdaptiveGraph 是外层容器组件,负责数据处理、状态管理和布局计算
|
||
* - GraphContent 是内层渲染组件,接收处理好的数据并负责图表的实际展示
|
||
* - AdaptiveGraph 会在两个地方使用 GraphContent:普通视图和全屏对话框
|
||
* - 两者通过 ReactFlowProvider 隔离,确保每个实例有独立的 ReactFlow 上下文
|
||
*/
|
||
function GraphContent<TNodeData = any>({
|
||
flowNodes,
|
||
flowEdges,
|
||
handleNodeClick,
|
||
nodeTypes,
|
||
getNodeColor,
|
||
onFilter,
|
||
searchPlaceholder,
|
||
inputValue,
|
||
handleInputChange,
|
||
isFullscreen,
|
||
setFullscreenOpen,
|
||
renderStats,
|
||
filteredNodeIds,
|
||
rawNodes,
|
||
highlightedNodeIds,
|
||
reactFlowProps,
|
||
filteredNodeIdsForFitView,
|
||
}: {
|
||
flowNodes: Node[]
|
||
flowEdges: Edge[]
|
||
handleNodeClick: (event: React.MouseEvent, node: Node) => void
|
||
nodeTypes: NodeTypes
|
||
getNodeColor?: (node: Node) => string
|
||
onFilter?: any
|
||
searchPlaceholder: string
|
||
inputValue: string
|
||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||
isFullscreen: boolean
|
||
setFullscreenOpen?: (open: boolean) => void
|
||
renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode
|
||
filteredNodeIds: Set<string>
|
||
rawNodes: TNodeData[]
|
||
highlightedNodeIds: Set<string>
|
||
reactFlowProps: Partial<ReactFlowProps>
|
||
filteredNodeIdsForFitView: Set<string>
|
||
}) {
|
||
const { fitView } = useReactFlow()
|
||
const fitViewTimeoutRef = useRef<number>(0)
|
||
const previousFilteredNodeIdsRef = useRef<Set<string>>(new Set())
|
||
// 使用 useNodesState 和 useEdgesState 来管理节点和边的状态,支持拖拽等交互
|
||
const [nodes, setNodes, onNodesChange] = useNodesState(flowNodes)
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState(flowEdges)
|
||
const { theme, resolvedTheme } = useTheme() // Tailwind CSS的样式变化无法自动传递到ReactFlow内部,需要显式监听网站主题的变化
|
||
const themeIsDark = resolvedTheme === 'dark' || (theme === 'system' && resolvedTheme === 'dark')
|
||
|
||
// 当 flowNodes 或 flowEdges 变化时,更新内部状态
|
||
React.useEffect(() => {
|
||
setNodes(flowNodes)
|
||
}, [flowNodes, setNodes])
|
||
|
||
React.useEffect(() => {
|
||
setEdges(flowEdges)
|
||
}, [flowEdges, setEdges])
|
||
|
||
|
||
// 当过滤节点变化时,自动调整视野
|
||
React.useEffect(() => {
|
||
// 比较 filteredNodeIds 的实际内容是否发生变化
|
||
const previousIds = previousFilteredNodeIdsRef.current
|
||
const currentIds = filteredNodeIdsForFitView
|
||
|
||
// 检查节点ID集合是否真正发生了变化
|
||
const hasChanged =
|
||
previousIds.size !== currentIds.size ||
|
||
Array.from(currentIds).some(id => !previousIds.has(id))
|
||
|
||
if (hasChanged) {
|
||
// 更新引用
|
||
previousFilteredNodeIdsRef.current = new Set(currentIds)
|
||
|
||
// 清除之前的定时器
|
||
if (fitViewTimeoutRef.current) {
|
||
window.clearTimeout(fitViewTimeoutRef.current)
|
||
}
|
||
|
||
// 延迟执行 fitView,确保节点已经渲染
|
||
fitViewTimeoutRef.current = window.setTimeout(() => {
|
||
if (flowNodes.length > 0) {
|
||
fitView({
|
||
padding: 0.2,
|
||
duration: 300,
|
||
maxZoom: 1.5,
|
||
minZoom: 0.1,
|
||
})
|
||
}
|
||
}, 100)
|
||
}
|
||
|
||
return () => {
|
||
if (fitViewTimeoutRef.current) {
|
||
window.clearTimeout(fitViewTimeoutRef.current)
|
||
}
|
||
}
|
||
}, [filteredNodeIdsForFitView, fitView, flowNodes.length])
|
||
|
||
// 默认统计信息渲染
|
||
const defaultRenderStats = (filteredCount: number, totalCount: number, matchedCount: number) => (
|
||
onFilter ?
|
||
<div className="mt-2 text-xs text-muted-foreground">
|
||
显示 {filteredCount} / {totalCount} 个节点
|
||
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
|
||
</div> : <></>
|
||
)
|
||
|
||
return (
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onNodeClick={handleNodeClick}
|
||
nodeTypes={nodeTypes}
|
||
fitView
|
||
minZoom={0.1}
|
||
maxZoom={2}
|
||
defaultEdgeOptions={{
|
||
type: 'smoothstep',
|
||
animated: false,
|
||
}}
|
||
proOptions={{ hideAttribution: true }}
|
||
colorMode={themeIsDark ? 'dark' : 'light'}
|
||
{...reactFlowProps}
|
||
>
|
||
<Background />
|
||
<Controls />
|
||
<MiniMap
|
||
nodeColor={getNodeColor || (() => '#cbd5e1')}
|
||
maskColor="rgba(0, 0, 0, 0.1)"
|
||
/>
|
||
<Panel position="top-left" className="bg-background/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border">
|
||
<div className="flex items-center gap-2">
|
||
{onFilter && (
|
||
<InputGroup className="min-w-[300px]">
|
||
<InputGroupAddon>
|
||
<InputGroupText>
|
||
<Search className="h-4 w-4" />
|
||
</InputGroupText>
|
||
</InputGroupAddon>
|
||
<InputGroupInput
|
||
placeholder={searchPlaceholder}
|
||
value={inputValue}
|
||
onChange={handleInputChange}
|
||
/>
|
||
</InputGroup>
|
||
)}
|
||
{!isFullscreen && setFullscreenOpen && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-9 px-3"
|
||
title="全屏显示"
|
||
onClick={() => setFullscreenOpen(true)}
|
||
>
|
||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{(renderStats || defaultRenderStats)(filteredNodeIds.size, rawNodes.length, highlightedNodeIds.size)}
|
||
</Panel>
|
||
</ReactFlow>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* AdaptiveGraph - 自适应图表容器组件
|
||
*
|
||
* 这是一个高度可配置的图表容器组件,提供完整的图表展示解决方案。
|
||
* 它处理数据转换、过滤、布局计算和状态管理,然后将处理好的数据传递给 GraphContent 进行渲染。
|
||
*
|
||
* 主要功能:
|
||
* - 接收原始节点和边数据,通过 transformNode/transformEdge 转换为 ReactFlow 格式
|
||
* - 支持自定义过滤逻辑(搜索、高亮)
|
||
* - 使用 dagre 算法自动计算节点布局
|
||
* - 提供普通视图和全屏对话框两种展示模式
|
||
* - 管理搜索状态和防抖处理
|
||
* ```
|
||
*/
|
||
export function AdaptiveGraph<TNodeData = any>({
|
||
nodes: rawNodes,
|
||
edges: rawEdges,
|
||
nodeTypes,
|
||
onFilter,
|
||
transformNode,
|
||
transformEdge,
|
||
getNodeColor,
|
||
onNodeClick,
|
||
searchPlaceholder = '搜索...',
|
||
renderStats,
|
||
layoutConfig,
|
||
className = 'h-[800px] max-h-[calc(100vh-12rem)]',
|
||
reactFlowProps = {},
|
||
}: AdaptiveGraphProps<TNodeData>) {
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [inputValue, setInputValue] = useState('')
|
||
const [fullscreenOpen, setFullscreenOpen] = useState(false)
|
||
|
||
// 使用外部提供的过滤函数,如果未提供则显示所有节点
|
||
const { filteredNodeIds, highlightedNodeIds } = useMemo(() => {
|
||
if (!onFilter) {
|
||
// 未提供过滤函数时,显示所有节点且不高亮
|
||
const allNodeIds = new Set(
|
||
rawNodes.map((node) => {
|
||
const transformed = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||
return transformed.id
|
||
})
|
||
)
|
||
return {
|
||
filteredNodeIds: allNodeIds,
|
||
highlightedNodeIds: new Set<string>(),
|
||
}
|
||
}
|
||
return onFilter(rawNodes, searchQuery)
|
||
}, [rawNodes, searchQuery, onFilter, transformNode])
|
||
|
||
// 转换为 ReactFlow 节点
|
||
const nodes = useMemo(() => {
|
||
return rawNodes
|
||
.filter((node) => {
|
||
const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||
return filteredNodeIds.has(nodeData.id)
|
||
})
|
||
.map((node) => {
|
||
const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false })
|
||
return transformNode(node, {
|
||
isHighlighted: highlightedNodeIds.has(nodeData.id),
|
||
isDimmed: searchQuery.trim() !== '' && !highlightedNodeIds.has(nodeData.id),
|
||
})
|
||
})
|
||
}, [rawNodes, filteredNodeIds, highlightedNodeIds, searchQuery, transformNode])
|
||
|
||
// 转换为 ReactFlow 边
|
||
const edges = useMemo(() => {
|
||
const defaultTransform = (
|
||
edge: { source: string; target: string; label?: string },
|
||
index: number,
|
||
options: { isHighlighted: boolean }
|
||
): Edge => ({
|
||
id: `${edge.source}-${edge.target}-${index}`,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
type: 'smoothstep',
|
||
animated: options.isHighlighted,
|
||
style: {
|
||
stroke: options.isHighlighted ? '#3b82f6' : '#94a3b8',
|
||
strokeWidth: options.isHighlighted ? 2 : 1,
|
||
},
|
||
markerEnd: {
|
||
type: MarkerType.ArrowClosed,
|
||
color: options.isHighlighted ? '#3b82f6' : '#94a3b8',
|
||
},
|
||
})
|
||
|
||
const transform = transformEdge || defaultTransform
|
||
|
||
return rawEdges
|
||
.filter((edge) => filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target))
|
||
.map((edge, index) =>
|
||
transform(edge, index, {
|
||
isHighlighted: highlightedNodeIds.has(edge.source) || highlightedNodeIds.has(edge.target),
|
||
})
|
||
)
|
||
}, [rawEdges, filteredNodeIds, highlightedNodeIds, transformEdge])
|
||
|
||
// 应用自动布局
|
||
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||
return getLayoutedElements(nodes, edges, layoutConfig)
|
||
}, [nodes, edges, layoutConfig])
|
||
|
||
// 防抖更新搜索查询
|
||
const debouncedSetSearchQuery = useDebouncedCallback((value: string) => {
|
||
setSearchQuery(value)
|
||
}, 300)
|
||
|
||
// 处理输入变化
|
||
const handleInputChange = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value
|
||
setInputValue(value)
|
||
debouncedSetSearchQuery(value)
|
||
},
|
||
[debouncedSetSearchQuery]
|
||
)
|
||
|
||
// 处理节点点击
|
||
const handleNodeClick = useCallback(
|
||
(_event: React.MouseEvent, node: Node) => {
|
||
const nodeData = rawNodes.find((n) => {
|
||
const transformed = transformNode(n, { isHighlighted: false, isDimmed: false })
|
||
return transformed.id === node.id
|
||
})
|
||
if (nodeData && onNodeClick) {
|
||
onNodeClick(nodeData)
|
||
}
|
||
},
|
||
[rawNodes, transformNode, onNodeClick]
|
||
)
|
||
|
||
// 共享的 GraphContent props
|
||
const graphContentProps = {
|
||
flowNodes: layoutedNodes,
|
||
flowEdges: layoutedEdges,
|
||
handleNodeClick,
|
||
nodeTypes,
|
||
getNodeColor,
|
||
onFilter,
|
||
searchPlaceholder,
|
||
inputValue,
|
||
handleInputChange,
|
||
isFullscreen: false,
|
||
setFullscreenOpen,
|
||
renderStats,
|
||
filteredNodeIds,
|
||
rawNodes,
|
||
highlightedNodeIds,
|
||
reactFlowProps,
|
||
filteredNodeIdsForFitView: filteredNodeIds,
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className={cn('w-full border rounded-lg bg-background', className)}>
|
||
<ReactFlowProvider>
|
||
<GraphContent {...graphContentProps} />
|
||
</ReactFlowProvider>
|
||
</div>
|
||
{/* 全屏对话框 */}
|
||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||
<DialogContent className="p-0" variant="fullscreen">
|
||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||
<DialogTitle className="px-6 text-base">图表查看</DialogTitle>
|
||
<DialogDescription className="sr-only">全屏查看图表</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="h-full my-3 px-6">
|
||
<ReactFlowProvider>
|
||
<GraphContent {...graphContentProps} isFullscreen={true} setFullscreenOpen={undefined} />
|
||
</ReactFlowProvider>
|
||
</div>
|
||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||
<DialogClose asChild>
|
||
<Button type="button">关闭</Button>
|
||
</DialogClose>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|