forked from admin/hair-keeper
Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal file
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { Node, Handle, Position } from '@xyflow/react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { FileCode, FileX } from 'lucide-react'
|
||||
import { AdaptiveGraph } from '@/components/features/adaptive-graph'
|
||||
|
||||
// 节点数据类型
|
||||
interface GraphNodeData {
|
||||
id: string
|
||||
path: string
|
||||
fileName: string
|
||||
fileTypeId: string
|
||||
fileTypeName: string
|
||||
summary: string | null
|
||||
dependencyCount: number
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
// 组件 Props
|
||||
interface FileDependencyGraphProps {
|
||||
nodes: GraphNodeData[]
|
||||
edges: Array<{ source: string; target: string; label?: string }>
|
||||
onNodeClick?: (node: GraphNodeData) => void
|
||||
}
|
||||
|
||||
// 自定义节点组件
|
||||
const CustomNode = ({ data }: { data: GraphNodeData & { isHighlighted?: boolean; isDimmed?: boolean } }) => {
|
||||
const bgColor = data.isDeleted
|
||||
? 'bg-red-50 dark:bg-red-950/20'
|
||||
: data.isHighlighted
|
||||
? 'bg-blue-50 dark:bg-blue-950/50'
|
||||
: data.isDimmed
|
||||
? 'bg-gray-50 dark:bg-gray-900/30'
|
||||
: 'bg-white dark:bg-gray-800'
|
||||
|
||||
const borderColor = data.isDeleted
|
||||
? 'border-red-300 dark:border-red-700'
|
||||
: data.isHighlighted
|
||||
? 'border-blue-500 dark:border-blue-400'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
|
||||
const opacity = data.isDimmed ? 'opacity-40' : 'opacity-100'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg border-2 shadow-sm transition-all ${bgColor} ${borderColor} ${opacity} min-w-[180px] max-w-[220px]`}
|
||||
>
|
||||
{/* Handle 组件用于连接边,没有Handle看不见连边,!opacity-0用来隐藏卡片上用来连接的小点 */}
|
||||
<Handle type="target" position={Position.Top} className="!opacity-0" />
|
||||
<div className="flex items-start gap-2">
|
||||
{data.isDeleted ? (
|
||||
<FileX className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<FileCode className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate" title={data.fileName}>
|
||||
{data.fileName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate" title={data.path}>
|
||||
{data.path}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{data.fileTypeName}
|
||||
</Badge>
|
||||
{data.dependencyCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0">
|
||||
{data.dependencyCount} 依赖
|
||||
</Badge>
|
||||
)}
|
||||
{data.isDeleted && (
|
||||
<Badge variant="destructive" className="text-xs px-1 py-0">
|
||||
已删除
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Handle 组件用于连接边,没有Handle看不见连边,!opacity-0用来隐藏卡片上用来连接的小点 */}
|
||||
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 节点类型定义
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
}
|
||||
|
||||
export function FileDependencyGraph({ nodes: rawNodes, edges: rawEdges, onNodeClick }: FileDependencyGraphProps) {
|
||||
// 构建依赖关系映射
|
||||
const dependencyMap = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>()
|
||||
const reverseDependencyMap = new Map<string, Set<string>>()
|
||||
|
||||
rawEdges.forEach((edge) => {
|
||||
// 正向依赖:source 依赖 target
|
||||
if (!map.has(edge.source)) {
|
||||
map.set(edge.source, new Set())
|
||||
}
|
||||
map.get(edge.source)!.add(edge.target)
|
||||
|
||||
// 反向依赖:target 被 source 依赖
|
||||
if (!reverseDependencyMap.has(edge.target)) {
|
||||
reverseDependencyMap.set(edge.target, new Set())
|
||||
}
|
||||
reverseDependencyMap.get(edge.target)!.add(edge.source)
|
||||
})
|
||||
|
||||
return { dependencies: map, reverseDependencies: reverseDependencyMap }
|
||||
}, [rawEdges])
|
||||
|
||||
// 过滤函数:根据搜索查询返回过滤后的节点和高亮节点
|
||||
const handleFilter = useMemo(
|
||||
() => (nodes: GraphNodeData[], query: string) => {
|
||||
if (!query.trim()) {
|
||||
return {
|
||||
filteredNodeIds: new Set(nodes.map((n) => n.id)),
|
||||
highlightedNodeIds: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const matchedNodes = nodes.filter(
|
||||
(node) =>
|
||||
node.fileName.toLowerCase().includes(lowerQuery) ||
|
||||
node.path.toLowerCase().includes(lowerQuery) ||
|
||||
node.summary?.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
|
||||
// 包含匹配的节点及其依赖和被依赖的节点
|
||||
const resultSet = new Set<string>()
|
||||
matchedNodes.forEach((node) => {
|
||||
resultSet.add(node.id)
|
||||
|
||||
// 添加该节点依赖的节点
|
||||
const deps = dependencyMap.dependencies.get(node.id)
|
||||
if (deps) {
|
||||
deps.forEach((depId) => resultSet.add(depId))
|
||||
}
|
||||
|
||||
// 添加依赖该节点的节点
|
||||
const reverseDeps = dependencyMap.reverseDependencies.get(node.id)
|
||||
if (reverseDeps) {
|
||||
reverseDeps.forEach((depId) => resultSet.add(depId))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
filteredNodeIds: resultSet,
|
||||
highlightedNodeIds: new Set(matchedNodes.map((n) => n.id)),
|
||||
}
|
||||
},
|
||||
[dependencyMap]
|
||||
)
|
||||
|
||||
// 节点转换函数
|
||||
const transformNode = (
|
||||
node: GraphNodeData,
|
||||
options: { isHighlighted: boolean; isDimmed: boolean }
|
||||
): Node => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
data: {
|
||||
...node,
|
||||
isHighlighted: options.isHighlighted,
|
||||
isDimmed: options.isDimmed,
|
||||
},
|
||||
position: { x: 0, y: 0 }, // 将由布局算法设置
|
||||
})
|
||||
|
||||
// MiniMap 节点颜色函数
|
||||
const getNodeColor = (node: Node) => {
|
||||
const data = node.data as unknown as GraphNodeData & { isHighlighted?: boolean; isDeleted?: boolean }
|
||||
if (data.isDeleted) return '#fca5a5'
|
||||
if (data.isHighlighted) return '#60a5fa'
|
||||
return '#cbd5e1'
|
||||
}
|
||||
|
||||
// 统计信息渲染
|
||||
const renderStats = (filteredCount: number, totalCount: number, matchedCount: number) => (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
显示 {filteredCount} / {totalCount} 个文件
|
||||
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AdaptiveGraph
|
||||
nodes={rawNodes}
|
||||
edges={rawEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onFilter={handleFilter}
|
||||
transformNode={transformNode}
|
||||
getNodeColor={getNodeColor}
|
||||
onNodeClick={onNodeClick}
|
||||
searchPlaceholder="搜索文件名、路径或摘要..."
|
||||
renderStats={renderStats}
|
||||
className="h-[800px] max-h-[calc(100vh-12rem)]"
|
||||
reactFlowProps={{
|
||||
nodesDraggable: true,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user