Files
stu-ai-demo/src/app/(main)/dev/file/components/FileDependencyGraph.tsx

210 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
}}
/>
)
}