forked from admin/hair-keeper
Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
499
src/components/features/adaptive-graph.tsx
Normal file
499
src/components/features/adaptive-graph.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user