forked from admin/hair-keeper
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
'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,
|
||
}}
|
||
/>
|
||
)
|
||
} |