Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View 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>
</>
)
}