updarte
This commit is contained in:
314
src/pages/code-graph/components/Code3DGraph.tsx
Normal file
314
src/pages/code-graph/components/Code3DGraph.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import ForceGraph3D from '3d-force-graph';
|
||||
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
|
||||
import { FileProjectData } from '../modules/tree';
|
||||
import { NodeSearchEntry } from '../modules/graph';
|
||||
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
// ─── 类型定义 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type NodeKind = 'root' | 'dir' | 'file';
|
||||
|
||||
interface Graph3DNode extends NodeObject {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: NodeKind;
|
||||
color: string;
|
||||
nodeSize: number;
|
||||
fullPath: string;
|
||||
projectPath: string;
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
interface Graph3DLink extends LinkObject {
|
||||
source: string;
|
||||
target: string;
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
interface Graph3DData {
|
||||
nodes: Graph3DNode[];
|
||||
links: Graph3DLink[];
|
||||
searchIndex: NodeSearchEntry[];
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const posixPath = {
|
||||
dirname(p: string): string {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i > 0 ? p.slice(0, i) : '/';
|
||||
},
|
||||
};
|
||||
|
||||
function fileExtColor(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
ts: '#818cf8',
|
||||
tsx: '#a78bfa',
|
||||
js: '#fbbf24',
|
||||
jsx: '#fb923c',
|
||||
md: '#34d399',
|
||||
json: '#f97316',
|
||||
css: '#f472b6',
|
||||
scss: '#ec4899',
|
||||
html: '#60a5fa',
|
||||
lock: '#64748b',
|
||||
gitignore: '#64748b',
|
||||
npmrc: '#64748b',
|
||||
};
|
||||
return map[ext] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
// ─── 构建图数据 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function buildGraph3DData(files: FileProjectData[]): Graph3DData {
|
||||
const nodes: Graph3DNode[] = [];
|
||||
const links: Graph3DLink[] = [];
|
||||
const searchIndex: NodeSearchEntry[] = [];
|
||||
|
||||
if (files.length === 0) return { nodes, links, searchIndex };
|
||||
|
||||
const projectGroups = new Map<string, FileProjectData[]>();
|
||||
for (const f of files) {
|
||||
if (!projectGroups.has(f.projectPath)) projectGroups.set(f.projectPath, []);
|
||||
projectGroups.get(f.projectPath)!.push(f);
|
||||
}
|
||||
|
||||
const addedNodes = new Set<string>();
|
||||
|
||||
for (const [projectPath, groupFiles] of projectGroups) {
|
||||
// 收集中间目录
|
||||
const dirSet = new Set<string>();
|
||||
for (const f of groupFiles) {
|
||||
let cur = posixPath.dirname(f.filepath);
|
||||
while (cur.startsWith(projectPath) && cur !== projectPath) {
|
||||
dirSet.add(cur);
|
||||
cur = posixPath.dirname(cur);
|
||||
}
|
||||
}
|
||||
|
||||
const rootKey = `root::${projectPath}`;
|
||||
|
||||
// 添加 root 节点
|
||||
if (!addedNodes.has(rootKey)) {
|
||||
const name = projectPath.split('/').pop() ?? projectPath;
|
||||
nodes.push({
|
||||
id: rootKey,
|
||||
label: name,
|
||||
kind: 'root',
|
||||
color: '#f59e0b',
|
||||
nodeSize: 10,
|
||||
fullPath: projectPath,
|
||||
projectPath,
|
||||
});
|
||||
searchIndex.push({ nodeKey: rootKey, label: name, fullPath: projectPath, kind: 'root' });
|
||||
addedNodes.add(rootKey);
|
||||
}
|
||||
|
||||
// 添加目录节点
|
||||
for (const dir of dirSet) {
|
||||
const key = `dir::${dir}`;
|
||||
if (!addedNodes.has(key)) {
|
||||
const name = dir.split('/').pop() ?? dir;
|
||||
nodes.push({
|
||||
id: key,
|
||||
label: name,
|
||||
kind: 'dir',
|
||||
color: '#ffd04c',
|
||||
nodeSize: 5,
|
||||
fullPath: dir,
|
||||
projectPath,
|
||||
});
|
||||
searchIndex.push({ nodeKey: key, label: name, fullPath: dir, kind: 'dir' });
|
||||
addedNodes.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文件节点
|
||||
for (const f of groupFiles) {
|
||||
const key = `file::${f.id}`;
|
||||
if (!addedNodes.has(key)) {
|
||||
const name = f.filepath.split('/').pop() ?? f.filepath;
|
||||
const label = f.title ?? name;
|
||||
nodes.push({
|
||||
id: key,
|
||||
label,
|
||||
kind: 'file',
|
||||
color: fileExtColor(name),
|
||||
nodeSize: 3,
|
||||
fullPath: f.filepath,
|
||||
projectPath,
|
||||
fileId: f.id,
|
||||
});
|
||||
searchIndex.push({ nodeKey: key, label, fullPath: f.filepath, kind: 'file' });
|
||||
addedNodes.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 建立父子关系 -> links
|
||||
const allKeys = [rootKey, ...[...dirSet].map((d) => `dir::${d}`), ...groupFiles.map((f) => `file::${f.id}`)];
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === rootKey) continue;
|
||||
let fullPath: string;
|
||||
if (key.startsWith('dir::')) fullPath = key.slice(5);
|
||||
else {
|
||||
const fId = key.slice(6);
|
||||
const f = groupFiles.find((f) => f.id === fId);
|
||||
if (!f) continue;
|
||||
fullPath = f.filepath;
|
||||
}
|
||||
const parentPath = posixPath.dirname(fullPath);
|
||||
const parentKey = parentPath === projectPath ? rootKey : `dir::${parentPath}`;
|
||||
if (addedNodes.has(parentKey)) {
|
||||
links.push({ source: parentKey, target: key, isRoot: parentKey === rootKey });
|
||||
} else {
|
||||
links.push({ source: rootKey, target: key, isRoot: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links, searchIndex };
|
||||
}
|
||||
|
||||
// ─── 组件 Props ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Code3DGraphProps {
|
||||
files: FileProjectData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── 主组件 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Code3DGraph({ files, className }: Code3DGraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<ForceGraph3DInstance | null>(null);
|
||||
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
|
||||
|
||||
const { setNodeInfo } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
setNodeInfo: s.setNodeInfo,
|
||||
})),
|
||||
);
|
||||
|
||||
// 节点跳转
|
||||
const focusNode = useCallback((nodeKey: string) => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
const node = (graph.graphData().nodes as Graph3DNode[]).find((n) => n.id === nodeKey);
|
||||
if (!node) return;
|
||||
const distance = 80;
|
||||
const distRatio = 1 + distance / Math.hypot((node.x ?? 0), (node.y ?? 0), (node.z ?? 0));
|
||||
graph.cameraPosition(
|
||||
{ x: (node.x ?? 0) * distRatio, y: (node.y ?? 0) * distRatio, z: (node.z ?? 0) * distRatio },
|
||||
{ x: node.x ?? 0, y: node.y ?? 0, z: node.z ?? 0 },
|
||||
800,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 初始化 & 数据更新
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { nodes, links, searchIndex: idx } = buildGraph3DData(files);
|
||||
setSearchIndex(idx);
|
||||
|
||||
let graph = graphRef.current;
|
||||
|
||||
if (!graph) {
|
||||
graph = new ForceGraph3D(el, { controlType: 'orbit' });
|
||||
graphRef.current = graph;
|
||||
|
||||
graph
|
||||
.backgroundColor('#0f172a')
|
||||
.showNavInfo(false)
|
||||
.nodeId('id')
|
||||
.nodeRelSize(1)
|
||||
.nodeVal((node) => {
|
||||
const n = node as Graph3DNode;
|
||||
return n.nodeSize * n.nodeSize;
|
||||
})
|
||||
.nodeColor((node) => (node as Graph3DNode).color)
|
||||
.nodeLabel((node) => (node as Graph3DNode).label)
|
||||
.linkWidth(0)
|
||||
.linkColor(() => 'rgba(255,255,255,0.6)')
|
||||
.linkDirectionalParticles(2)
|
||||
.linkDirectionalParticleWidth(1.5)
|
||||
.linkDirectionalParticleSpeed(0.004)
|
||||
.linkDirectionalParticleColor(() => '#ffffff')
|
||||
.onNodeClick((node, event) => {
|
||||
const n = node as Graph3DNode;
|
||||
focusNode(n.id);
|
||||
setNodeInfo(
|
||||
{
|
||||
label: n.label,
|
||||
fullPath: n.fullPath,
|
||||
projectPath: n.projectPath,
|
||||
kind: n.kind,
|
||||
color: n.color,
|
||||
fileId: n.fileId,
|
||||
nodeSize: n.nodeSize,
|
||||
},
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
);
|
||||
})
|
||||
.onNodeHover((node) => {
|
||||
if (el) el.style.cursor = node ? 'pointer' : 'default';
|
||||
});
|
||||
}
|
||||
|
||||
graph.width(el.clientWidth || window.innerWidth);
|
||||
graph.height(el.clientHeight || window.innerHeight);
|
||||
graph.graphData({ nodes, links });
|
||||
|
||||
// 缩短连线距离:root→dir 稍长,dir→file 较短
|
||||
const linkForce = graph.d3Force('link');
|
||||
if (linkForce) {
|
||||
(linkForce as any).distance((link: Graph3DLink) => (link.isRoot ? 40 : 20));
|
||||
}
|
||||
// 增强聚合,防止节点散得太开
|
||||
const chargeForce = graph.d3Force('charge');
|
||||
if (chargeForce) {
|
||||
(chargeForce as any).strength(-60);
|
||||
}
|
||||
|
||||
// 响应容器大小变化
|
||||
const ro = new ResizeObserver(() => {
|
||||
graph!.width(el.clientWidth);
|
||||
graph!.height(el.clientHeight);
|
||||
});
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [files, focusNode, setNodeInfo]);
|
||||
|
||||
// 卸载时销毁
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
graphRef.current?._destructor();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||
<div ref={containerRef} className='w-full h-full' />
|
||||
<div className='absolute top-3 left-1/2 -translate-x-1/2 z-10 w-72'>
|
||||
<NodeSearchBox
|
||||
ref={searchBoxRef}
|
||||
searchIndex={searchIndex}
|
||||
onSelect={(entry) => focusNode(entry.nodeKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Code3DGraph;
|
||||
428
src/pages/code-graph/components/CodeG6Graph.tsx
Normal file
428
src/pages/code-graph/components/CodeG6Graph.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Graph, NodeEvent, IElementEvent, NodeData, EdgeData } from '@antv/g6';
|
||||
import { FileProjectData } from '../modules/tree';
|
||||
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
|
||||
import { NodeSearchEntry, GraphNode } from '../modules/graph';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
|
||||
interface CodeG6GraphProps {
|
||||
files: FileProjectData[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── 路径工具 ────────────────────────────────────────────────────────────────
|
||||
const posixPath = {
|
||||
dirname(p: string): string {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i > 0 ? p.slice(0, i) : '/';
|
||||
},
|
||||
};
|
||||
|
||||
type NodeKind = 'root' | 'dir' | 'file';
|
||||
|
||||
function fileExtColor(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const map: Record<string, string> = {
|
||||
ts: '#818cf8',
|
||||
tsx: '#a78bfa',
|
||||
js: '#fbbf24',
|
||||
jsx: '#fb923c',
|
||||
md: '#34d399',
|
||||
json: '#f97316',
|
||||
css: '#f472b6',
|
||||
scss: '#ec4899',
|
||||
html: '#60a5fa',
|
||||
lock: '#64748b',
|
||||
gitignore: '#64748b',
|
||||
npmrc: '#64748b',
|
||||
};
|
||||
return map[ext] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
interface G6NodeExtra {
|
||||
fullPath: string;
|
||||
projectPath: string;
|
||||
kind: NodeKind;
|
||||
fileId?: string;
|
||||
label: string;
|
||||
nodeSize: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface BuildResult {
|
||||
nodes: NodeData[];
|
||||
edges: EdgeData[];
|
||||
searchIndex: NodeSearchEntry[];
|
||||
nodeExtras: Map<string, G6NodeExtra>;
|
||||
}
|
||||
|
||||
function buildG6GraphData(files: FileProjectData[]): BuildResult {
|
||||
const nodes: NodeData[] = [];
|
||||
const edges: EdgeData[] = [];
|
||||
const searchIndex: NodeSearchEntry[] = [];
|
||||
const nodeExtras = new Map<string, G6NodeExtra>();
|
||||
|
||||
if (files.length === 0) return { nodes, edges, searchIndex, nodeExtras };
|
||||
|
||||
const projectGroups = new Map<string, FileProjectData[]>();
|
||||
for (const f of files) {
|
||||
if (!projectGroups.has(f.projectPath)) projectGroups.set(f.projectPath, []);
|
||||
projectGroups.get(f.projectPath)!.push(f);
|
||||
}
|
||||
|
||||
const projects = Array.from(projectGroups.keys());
|
||||
const PROJECT_SPACING = 1200;
|
||||
|
||||
projects.forEach((projectPath, pi) => {
|
||||
const groupFiles = projectGroups.get(projectPath)!;
|
||||
|
||||
const dirSet = new Set<string>();
|
||||
for (const f of groupFiles) {
|
||||
let cur = posixPath.dirname(f.filepath);
|
||||
while (cur.startsWith(projectPath) && cur !== projectPath) {
|
||||
dirSet.add(cur);
|
||||
cur = posixPath.dirname(cur);
|
||||
}
|
||||
}
|
||||
|
||||
const children = new Map<string, string[]>();
|
||||
const nodeKind = new Map<string, NodeKind>();
|
||||
const nodeFullPath = new Map<string, string>();
|
||||
const nodeFileId = new Map<string, string>();
|
||||
|
||||
const rootKey = `root::${projectPath}`;
|
||||
nodeKind.set(rootKey, 'root');
|
||||
nodeFullPath.set(rootKey, projectPath);
|
||||
children.set(rootKey, []);
|
||||
|
||||
for (const dir of dirSet) {
|
||||
const key = `dir::${dir}`;
|
||||
nodeKind.set(key, 'dir');
|
||||
nodeFullPath.set(key, dir);
|
||||
children.set(key, []);
|
||||
}
|
||||
|
||||
for (const f of groupFiles) {
|
||||
const key = `file::${f.id}`;
|
||||
nodeKind.set(key, 'file');
|
||||
nodeFullPath.set(key, f.filepath);
|
||||
nodeFileId.set(key, f.id);
|
||||
children.set(key, []);
|
||||
}
|
||||
|
||||
for (const key of nodeKind.keys()) {
|
||||
if (key === rootKey) continue;
|
||||
const fullPath = nodeFullPath.get(key)!;
|
||||
const parentPath = posixPath.dirname(fullPath);
|
||||
const parentKey = parentPath === projectPath ? rootKey : `dir::${parentPath}`;
|
||||
if (children.has(parentKey)) {
|
||||
children.get(parentKey)!.push(key);
|
||||
} else {
|
||||
children.get(rootKey)!.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cx = pi * PROJECT_SPACING;
|
||||
const cy = 0;
|
||||
const nodePos = new Map<string, { x: number; y: number }>();
|
||||
|
||||
function radialLayout(key: string, px: number, py: number, sa: number, ea: number, r: number) {
|
||||
nodePos.set(key, { x: px, y: py });
|
||||
const kids = children.get(key) ?? [];
|
||||
if (kids.length === 0) return;
|
||||
const step = (ea - sa) / kids.length;
|
||||
kids.forEach((child, i) => {
|
||||
const angle = sa + step * (i + 0.5);
|
||||
const nr = Math.max(r * 0.6, 80);
|
||||
radialLayout(child, px + r * Math.cos(angle), py + r * Math.sin(angle), angle - step / 2, angle + step / 2, nr);
|
||||
});
|
||||
}
|
||||
|
||||
const rootKids = children.get(rootKey)!;
|
||||
radialLayout(rootKey, cx, cy, 0, 2 * Math.PI, Math.max(rootKids.length * 60, 200));
|
||||
|
||||
for (const [key, kind] of nodeKind) {
|
||||
const pos = nodePos.get(key) ?? { x: cx, y: cy };
|
||||
const fullPath = nodeFullPath.get(key)!;
|
||||
const name = fullPath.split('/').pop() ?? fullPath;
|
||||
const fd = kind === 'file' ? groupFiles.find((f) => f.id === nodeFileId.get(key)) : undefined;
|
||||
const color = kind === 'root' ? '#f59e0b' : kind === 'dir' ? '#60a5fa' : fileExtColor(name);
|
||||
const nodeSize = kind === 'root' ? 28 : kind === 'dir' ? 16 : 10;
|
||||
const label = fd?.title ?? name;
|
||||
|
||||
nodes.push({
|
||||
id: key,
|
||||
style: {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: nodeSize,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
lineWidth: 1,
|
||||
// label 为 boolean 控制显示,labelText 设置文字(来自 Prefix<'label', LabelStyleProps>)
|
||||
label: kind !== 'file',
|
||||
labelText: label,
|
||||
labelFill: '#e2e8f0',
|
||||
labelFontSize: kind === 'root' ? 13 : 11,
|
||||
labelFontFamily: 'Inter, system-ui, sans-serif',
|
||||
labelPlacement: kind === 'root' ? 'bottom' : 'right',
|
||||
labelOffsetX: 4,
|
||||
opacity: 1,
|
||||
} as Record<string, unknown>,
|
||||
data: { kind, fullPath, label, fileId: nodeFileId.get(key), projectPath },
|
||||
});
|
||||
|
||||
nodeExtras.set(key, { fullPath, projectPath, kind, fileId: nodeFileId.get(key), label, nodeSize, color });
|
||||
searchIndex.push({ nodeKey: key, label, fullPath, kind });
|
||||
}
|
||||
|
||||
for (const [parentKey, kids] of children) {
|
||||
for (const childKey of kids) {
|
||||
const edgeId = `${parentKey}-->${childKey}`;
|
||||
const isRoot = nodeKind.get(parentKey) === 'root';
|
||||
edges.push({
|
||||
id: edgeId,
|
||||
source: parentKey,
|
||||
target: childKey,
|
||||
style: {
|
||||
stroke: isRoot ? '#78716c' : '#334155',
|
||||
lineWidth: isRoot ? 1.5 : 1,
|
||||
opacity: 0.7,
|
||||
endArrow: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes, edges, searchIndex, nodeExtras };
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeId: string;
|
||||
extra: G6NodeExtra;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
fullPath: string;
|
||||
kind: NodeKind;
|
||||
}
|
||||
|
||||
export function CodeG6Graph({ files, className }: CodeG6GraphProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||
const nodeExtrasRef = useRef<Map<string, G6NodeExtra>>(new Map());
|
||||
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
|
||||
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
|
||||
const { setCodePodOpen, setCodePodAttrs } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
setCodePodOpen: s.setCodePodOpen,
|
||||
setCodePodAttrs: s.setCodePodAttrs,
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (graphRef.current) {
|
||||
graphRef.current.destroy();
|
||||
graphRef.current = null;
|
||||
}
|
||||
|
||||
const { nodes, edges, searchIndex: idx, nodeExtras } = buildG6GraphData(files);
|
||||
nodeExtrasRef.current = nodeExtras;
|
||||
setSearchIndex(idx);
|
||||
setStats({ nodes: nodes.length, edges: edges.length });
|
||||
|
||||
const container = containerRef.current;
|
||||
const { width, height } = container.getBoundingClientRect();
|
||||
|
||||
const graph = new Graph({
|
||||
container,
|
||||
width: width || 800,
|
||||
height: height || 600,
|
||||
data: { nodes, edges },
|
||||
node: {
|
||||
type: 'circle',
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
},
|
||||
behaviors: [
|
||||
{ type: 'drag-canvas', key: 'drag-canvas' },
|
||||
{ type: 'zoom-canvas', key: 'zoom-canvas' },
|
||||
{ type: 'drag-element', key: 'drag-element' },
|
||||
],
|
||||
autoFit: 'view',
|
||||
animation: false,
|
||||
});
|
||||
|
||||
// 获取节点 ID 的辅助函数
|
||||
const getNodeId = (e: IElementEvent): string => (e.target as unknown as { id: string }).id;
|
||||
|
||||
// 节点悬浮 - tooltip
|
||||
graph.on(NodeEvent.POINTER_OVER, (e: IElementEvent) => {
|
||||
const nodeId = getNodeId(e);
|
||||
const extra = nodeExtrasRef.current.get(nodeId);
|
||||
if (!extra) return;
|
||||
setTooltip({
|
||||
x: e.canvas.x,
|
||||
y: e.canvas.y,
|
||||
label: extra.label,
|
||||
fullPath: extra.fullPath,
|
||||
kind: extra.kind,
|
||||
});
|
||||
});
|
||||
|
||||
graph.on(NodeEvent.POINTER_OUT, () => {
|
||||
setTooltip(null);
|
||||
});
|
||||
|
||||
// 右键菜单
|
||||
graph.on(NodeEvent.CONTEXT_MENU, (e: IElementEvent) => {
|
||||
const nodeId = getNodeId(e);
|
||||
const extra = nodeExtrasRef.current.get(nodeId);
|
||||
if (!extra) return;
|
||||
setContextMenu({ x: e.canvas.x, y: e.canvas.y, nodeId, extra });
|
||||
});
|
||||
|
||||
// 点击节点以外区域关闭菜单
|
||||
graph.on(NodeEvent.CLICK, (e: IElementEvent) => {
|
||||
if (e.targetType !== 'node') setContextMenu(null);
|
||||
});
|
||||
|
||||
// 阻止默认右键菜单
|
||||
const onContextMenu = (ev: MouseEvent) => ev.preventDefault();
|
||||
container.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
graph.render();
|
||||
graphRef.current = graph;
|
||||
|
||||
// 响应容器尺寸变化
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!graphRef.current || !containerRef.current) return;
|
||||
const { width: w, height: h } = containerRef.current.getBoundingClientRect();
|
||||
graphRef.current.setSize(w, h);
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
container.removeEventListener('contextmenu', onContextMenu);
|
||||
graph.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
// 跳转到节点(搜索后聚焦)
|
||||
const jumpToNode = useCallback((entry: NodeSearchEntry) => {
|
||||
const g = graphRef.current;
|
||||
if (!g) return;
|
||||
g.focusElement(entry.nodeKey);
|
||||
}, []);
|
||||
|
||||
// Ctrl+F 激活搜索框
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
searchBoxRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const kindLabel: Record<string, string> = { root: '项目根', dir: '目录', file: '文件' };
|
||||
const kindBadgeColor: Record<string, string> = {
|
||||
root: 'bg-amber-500/20 text-amber-300',
|
||||
dir: 'bg-blue-500/20 text-blue-300',
|
||||
file: 'bg-indigo-500/20 text-indigo-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative flex flex-col h-full ${className ?? ''}`}>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className='relative z-10 flex items-center gap-4 px-4 py-2 border-b border-white/10 text-xs text-slate-400 bg-slate-900/60 backdrop-blur shrink-0 overflow-visible'>
|
||||
<NodeSearchBox ref={searchBoxRef} searchIndex={searchIndex} onSelect={jumpToNode} />
|
||||
<span>
|
||||
节点 <span className='text-slate-200 font-semibold'>{stats.nodes}</span>
|
||||
</span>
|
||||
<span>
|
||||
边 <span className='text-slate-200 font-semibold'>{stats.edges}</span>
|
||||
</span>
|
||||
<div className='flex items-center gap-3 ml-auto shrink-0'>
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='inline-block w-3 h-3 rounded-full bg-amber-400'></span>项目根
|
||||
</span>
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='inline-block w-2.5 h-2.5 rounded-full bg-blue-400'></span>目录
|
||||
</span>
|
||||
<span className='flex items-center gap-1'>
|
||||
<span className='inline-block w-2 h-2 rounded-full bg-indigo-400'></span>文件
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* G6 画布 */}
|
||||
<div ref={containerRef} className='flex-1 bg-slate-950 overflow-hidden' />
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className='absolute z-10 pointer-events-none rounded-lg border border-white/10 bg-slate-800/95 px-3 py-2 shadow-xl text-xs leading-relaxed backdrop-blur'
|
||||
style={{ left: tooltip.x + 14, top: tooltip.y - 10 }}>
|
||||
<div className='flex items-center gap-2 mb-0.5'>
|
||||
<span className={`rounded px-1 py-0.5 text-[10px] font-medium ${kindBadgeColor[tooltip.kind] ?? ''}`}>
|
||||
{kindLabel[tooltip.kind] ?? tooltip.kind}
|
||||
</span>
|
||||
<span className='font-semibold text-slate-100'>{tooltip.label}</span>
|
||||
</div>
|
||||
<div className='text-slate-400 max-w-[320px] break-all'>{tooltip.fullPath}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className='absolute z-20 min-w-[120px] rounded-lg border border-white/10 bg-slate-800 shadow-xl py-1 text-xs'
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onMouseLeave={() => setContextMenu(null)}>
|
||||
<button
|
||||
className='w-full text-left px-4 py-1.5 text-slate-200 hover:bg-indigo-600/40 transition-colors'
|
||||
onClick={() => {
|
||||
const extra = contextMenu.extra;
|
||||
const attrs: GraphNode = {
|
||||
label: extra.label,
|
||||
size: extra.nodeSize,
|
||||
color: extra.color,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullPath: extra.fullPath,
|
||||
projectPath: extra.projectPath,
|
||||
kind: extra.kind,
|
||||
fileId: extra.fileId,
|
||||
};
|
||||
setCodePodAttrs(attrs);
|
||||
setCodePodOpen(true);
|
||||
setContextMenu(null);
|
||||
}}>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeG6Graph;
|
||||
@@ -4,17 +4,10 @@ import Graph from 'graphology';
|
||||
import { FileProjectData } from '../modules/tree';
|
||||
import { buildTreeGraph, GraphNode, NodeSearchEntry } from '../modules/graph';
|
||||
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
|
||||
import { CodePod } from './CodePod';
|
||||
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
|
||||
interface ContextMenu {
|
||||
x: number;
|
||||
y: number;
|
||||
nodeKey: string;
|
||||
nodeAttrs: GraphNode;
|
||||
}
|
||||
|
||||
interface TooltipInfo {
|
||||
label: string;
|
||||
fullPath: string;
|
||||
@@ -36,13 +29,9 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
|
||||
const [tooltip, setTooltip] = useState<TooltipInfo | null>(null);
|
||||
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
|
||||
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
||||
const { codePodOpen, setCodePodOpen, codePodAttrs, setCodePodAttrs } = useCodeGraphStore(
|
||||
const { setNodeInfo } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
codePodOpen: s.codePodOpen,
|
||||
setCodePodOpen: s.setCodePodOpen,
|
||||
codePodAttrs: s.codePodAttrs,
|
||||
setCodePodAttrs: s.setCodePodAttrs,
|
||||
setNodeInfo: s.setNodeInfo,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -85,25 +74,29 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
|
||||
graph.setNodeAttribute(node, 'highlighted', false);
|
||||
});
|
||||
|
||||
sigma.on('rightClickNode', ({ node }) => {
|
||||
sigma.on('clickNode', ({ node, event }) => {
|
||||
const attrs = graph.getNodeAttributes(node) as GraphNode;
|
||||
const pos = sigma.graphToViewport({ x: attrs.x, y: attrs.y });
|
||||
setContextMenu({ x: pos.x, y: pos.y, nodeKey: node, nodeAttrs: attrs });
|
||||
const orig = event.original;
|
||||
const clientX = orig instanceof MouseEvent ? orig.clientX : orig.touches[0]?.clientX ?? 0;
|
||||
const clientY = orig instanceof MouseEvent ? orig.clientY : orig.touches[0]?.clientY ?? 0;
|
||||
setNodeInfo(
|
||||
{
|
||||
label: attrs.label,
|
||||
fullPath: attrs.fullPath,
|
||||
projectPath: attrs.projectPath,
|
||||
kind: attrs.kind,
|
||||
color: attrs.color,
|
||||
fileId: attrs.fileId,
|
||||
nodeSize: attrs.size,
|
||||
},
|
||||
{ x: clientX, y: clientY },
|
||||
);
|
||||
});
|
||||
|
||||
// 阻止画布默认右键菜单
|
||||
const onContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
container?.addEventListener('contextmenu', onContextMenu);
|
||||
|
||||
// 点击画布空白处关闭右键菜单
|
||||
sigma.on('clickStage', () => setContextMenu(null));
|
||||
|
||||
sigmaRef.current = sigma;
|
||||
return () => {
|
||||
sigma.kill();
|
||||
sigmaRef.current = null;
|
||||
container?.removeEventListener('contextmenu', onContextMenu);
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
@@ -142,11 +135,11 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
|
||||
{/* 搜索框 */}
|
||||
<NodeSearchBox ref={searchBoxRef} searchIndex={searchIndex} onSelect={jumpToNode} />
|
||||
|
||||
<span>节点 <span className='text-slate-200 font-semibold'>{stats.nodes}</span></span>
|
||||
<span>边 <span className='text-slate-200 font-semibold'>{stats.edges}</span></span>
|
||||
<span className='whitespace-nowrap'>节点 <span className='text-slate-200 font-semibold'>{stats.nodes}</span></span>
|
||||
<span className='whitespace-nowrap'>边 <span className='text-slate-200 font-semibold'>{stats.edges}</span></span>
|
||||
|
||||
{/* 图例 */}
|
||||
<div className='flex items-center gap-3 ml-auto shrink-0'>
|
||||
<div className='hidden sm:flex items-center gap-3 ml-auto shrink-0'>
|
||||
<span className='flex items-center gap-1'><span className='inline-block w-3 h-3 rounded-full bg-amber-400'></span>项目根</span>
|
||||
<span className='flex items-center gap-1'><span className='inline-block w-2.5 h-2.5 rounded-full bg-blue-400'></span>目录</span>
|
||||
<span className='flex items-center gap-1'><span className='inline-block w-2 h-2 rounded-full bg-indigo-400'></span>文件</span>
|
||||
@@ -169,30 +162,9 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className='absolute z-20 min-w-[120px] rounded-lg border border-white/10 bg-slate-800 shadow-xl py-1 text-xs'
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onMouseLeave={() => setContextMenu(null)}>
|
||||
<button
|
||||
className='w-full text-left px-4 py-1.5 text-slate-200 hover:bg-indigo-600/40 transition-colors'
|
||||
onClick={() => {
|
||||
setCodePodAttrs(contextMenu.nodeAttrs);
|
||||
setCodePodOpen(true);
|
||||
setContextMenu(null);
|
||||
}}>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CodePod 弹窗 */}
|
||||
<CodePod
|
||||
open={codePodOpen}
|
||||
onClose={() => setCodePodOpen(false)}
|
||||
nodeAttrs={codePodAttrs}
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,12 +179,14 @@ interface CodePodProps {
|
||||
|
||||
export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||
const [dirFiles, setDirFiles] = useState<FileProjectData[]>([]);
|
||||
const [type, setType] = useState<'text' | null>('text');
|
||||
const [selectedFile, setSelectedFile] = useState<FileProjectData | null>(null);
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root';
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const rootPath = nodeAttrs?.fullPath ?? '';
|
||||
|
||||
const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root';
|
||||
|
||||
// 打开时重置
|
||||
useEffect(() => {
|
||||
@@ -194,6 +196,7 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
init(nodeAttrs);
|
||||
setSidebarOpen(isDir ? true : false);
|
||||
}, [open, nodeAttrs]);
|
||||
const init = async (nodeAttrs: GraphNode) => {
|
||||
setLoading(true);
|
||||
@@ -217,6 +220,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||
if (!selectedFile) return;
|
||||
if (selectedFile.content) {
|
||||
setFileContent(selectedFile.content);
|
||||
} else {
|
||||
setFileContent('展示不支持的文件类型');
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
@@ -234,8 +239,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||
<div
|
||||
className='fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm'
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
{/* 弹窗主体 */}
|
||||
<div className='relative flex w-[90vw] h-[80vh] rounded-xl border border-white/10 bg-slate-900 shadow-2xl overflow-hidden'>
|
||||
{/* 弹窗主体:小屏幕全屏无边框,大屏幕居中带圆角/边框 */}
|
||||
<div className='relative flex w-full h-full sm:w-[90vw] sm:h-[80vh] sm:rounded-xl sm:border sm:border-white/10 bg-slate-900 shadow-2xl overflow-hidden'>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -243,40 +248,47 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* 侧边栏(仅 dir / root) */}
|
||||
{isDir && (
|
||||
<aside className='w-64 shrink-0 border-r border-white/10 bg-slate-950 flex flex-col'>
|
||||
{/* 标题:显示 rootPath */}
|
||||
<div className='px-3 py-2.5 border-b border-white/10 shrink-0'>
|
||||
<div className='text-[10px] text-slate-500 mb-0.5'>路径</div>
|
||||
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
|
||||
{rootPath}
|
||||
</div>
|
||||
{/* 侧边栏 */}
|
||||
<aside
|
||||
className={`${sidebarOpen ? 'w-56 sm:w-64' : 'w-0'
|
||||
} shrink-0 border-r border-white/10 bg-slate-950 flex flex-col overflow-hidden transition-all duration-200`}>
|
||||
{/* 标题:显示 rootPath */}
|
||||
<div className='px-3 py-2.5 border-b border-white/10 shrink-0 min-w-[14rem]'>
|
||||
<div className='text-[10px] text-slate-500 mb-0.5'>路径</div>
|
||||
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
|
||||
{rootPath}
|
||||
</div>
|
||||
{/* 目录树 */}
|
||||
<div className='flex-1 overflow-y-auto py-1'>
|
||||
{loading && dirFiles.length === 0 && (
|
||||
<div className='px-3 py-4 text-xs text-slate-500'>加载中…</div>
|
||||
)}
|
||||
{buildTree(dirFiles, rootPath).map((node) => (
|
||||
<TreeItem
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedId={selectedFile?.id}
|
||||
onSelect={setSelectedFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
{/* 目录树 */}
|
||||
<div className='flex-1 overflow-y-auto py-1 scrollbar min-w-[14rem]'>
|
||||
{loading && dirFiles.length === 0 && (
|
||||
<div className='px-3 py-4 text-xs text-slate-500'>加载中…</div>
|
||||
)}
|
||||
{buildTree(dirFiles, rootPath).map((node) => (
|
||||
<TreeItem
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
depth={0}
|
||||
selectedId={selectedFile?.id}
|
||||
onSelect={setSelectedFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 编辑器区域 */}
|
||||
<div className='flex-1 flex flex-col min-w-0'>
|
||||
{/* 编辑器标题栏 */}
|
||||
<div className='flex items-center gap-2 px-4 py-4 border-b border-white/10 text-xs text-slate-400 shrink-0'>
|
||||
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0'>
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
className='shrink-0 flex items-center justify-center w-6 h-6 rounded text-slate-400 hover:text-white hover:bg-white/10 transition-colors text-sm'
|
||||
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}>
|
||||
{sidebarOpen ? '◀' : '▶'}
|
||||
</button>
|
||||
<span className='truncate text-slate-200'>
|
||||
{isDir ? (selectedFile?.filepath ?? '请选择文件') : nodeAttrs.fullPath}
|
||||
{selectedFile?.filepath ?? nodeAttrs.fullPath}
|
||||
</span>
|
||||
{loading && <span className='ml-auto text-slate-500'>加载中…</span>}
|
||||
</div>
|
||||
|
||||
157
src/pages/code-graph/components/NodeInfo.tsx
Normal file
157
src/pages/code-graph/components/NodeInfo.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon } from 'lucide-react';
|
||||
import { useCodeGraphStore, NodeInfoData } from '../store';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) {
|
||||
const cls = 'size-4 shrink-0';
|
||||
if (kind === 'root') return <DatabaseIcon className={cls} style={{ color }} />;
|
||||
if (kind === 'dir') return <FolderIcon className={cls} style={{ color }} />;
|
||||
return <FileIcon className={cls} style={{ color }} />;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<NodeInfoData['kind'], string> = {
|
||||
root: '项目根目录',
|
||||
dir: '目录',
|
||||
file: '文件',
|
||||
};
|
||||
|
||||
export function NodeInfo() {
|
||||
const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, setCodePodOpen, setCodePodAttrs } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
nodeInfoOpen: s.nodeInfoOpen,
|
||||
nodeInfoData: s.nodeInfoData,
|
||||
nodeInfoPos: s.nodeInfoPos,
|
||||
closeNodeInfo: s.closeNodeInfo,
|
||||
setCodePodOpen: s.setCodePodOpen,
|
||||
setCodePodAttrs: s.setCodePodAttrs,
|
||||
})),
|
||||
);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!nodeInfoData) return;
|
||||
setCodePodAttrs({
|
||||
label: nodeInfoData.label,
|
||||
size: nodeInfoData.nodeSize ?? 3,
|
||||
color: nodeInfoData.color,
|
||||
x: 0,
|
||||
y: 0,
|
||||
fullPath: nodeInfoData.fullPath,
|
||||
projectPath: nodeInfoData.projectPath,
|
||||
kind: nodeInfoData.kind,
|
||||
fileId: nodeInfoData.fileId,
|
||||
});
|
||||
setCodePodOpen(true);
|
||||
// 移到左上角,避免遮挡编辑器
|
||||
setPinLeft(true);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
// 拖拽偏移
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角
|
||||
const dragging = useRef(false);
|
||||
const dragStart = useRef({ mx: 0, my: 0, ox: 0, oy: 0 });
|
||||
|
||||
// 每次新节点被选中时重置偏移和 pinLeft
|
||||
const prevDataRef = useRef<NodeInfoData | null>(null);
|
||||
useEffect(() => {
|
||||
if (nodeInfoData !== prevDataRef.current) {
|
||||
prevDataRef.current = nodeInfoData;
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setPinLeft(false);
|
||||
}
|
||||
}, [nodeInfoData]);
|
||||
|
||||
// 鼠标拖拽
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
dragging.current = true;
|
||||
dragStart.current = { mx: e.clientX, my: e.clientY, ox: offset.x, oy: offset.y };
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
setOffset({
|
||||
x: dragStart.current.ox + e.clientX - dragStart.current.mx,
|
||||
y: dragStart.current.oy + e.clientY - dragStart.current.my,
|
||||
});
|
||||
};
|
||||
const onUp = () => { dragging.current = false; };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!nodeInfoOpen || !nodeInfoData) return null;
|
||||
|
||||
const posStyle = pinLeft
|
||||
? { right: 10 - offset.x, bottom: 10 - offset.y, top: 'auto' as const, left: 'auto' as const }
|
||||
: { left: nodeInfoPos.x + offset.x + 16, top: nodeInfoPos.y + offset.y - 16 };
|
||||
|
||||
const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label;
|
||||
const projectPath = nodeInfoData.projectPath || '';
|
||||
const relativePath = nodeInfoData.fullPath.replace(projectPath + '/', '') || '/';
|
||||
return (
|
||||
<div
|
||||
className='fixed z-50 w-72 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none'
|
||||
style={posStyle}
|
||||
onMouseDown={onMouseDown}>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center gap-2 px-3 py-2.5 border-b border-white/10 cursor-grab active:cursor-grabbing'>
|
||||
<MoveIcon className='size-3 text-slate-600 shrink-0' />
|
||||
<KindIcon kind={nodeInfoData.kind} color={nodeInfoData.color} />
|
||||
<span className='flex-1 text-sm font-medium text-slate-100 truncate' title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
title='查看内容'
|
||||
className='ml-1 text-slate-500 hover:text-indigo-400 transition-colors'>
|
||||
<SquarePenIcon className='size-3.5' />
|
||||
</button>
|
||||
|
||||
<span className='text-[10px] text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded'>
|
||||
{KIND_LABEL[nodeInfoData.kind]}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={closeNodeInfo}
|
||||
className='ml-1 text-slate-500 hover:text-slate-200 transition-colors'>
|
||||
<XIcon className='size-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className='px-3 py-2.5 flex flex-col gap-2'>
|
||||
{/* projectPath */}
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-[10px] uppercase tracking-wide text-slate-500 font-medium'>项目路径</span>
|
||||
<span
|
||||
className='text-xs text-slate-400 break-all leading-relaxed font-mono'
|
||||
title={projectPath}>
|
||||
{projectPath}
|
||||
</span>
|
||||
</div>
|
||||
{/* relativePath */}
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-[10px] uppercase tracking-wide text-slate-500 font-medium'>相对路径</span>
|
||||
<span
|
||||
className='text-xs text-slate-300 break-all leading-relaxed font-mono'
|
||||
title={relativePath}>
|
||||
{relativePath}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodeInfo;
|
||||
134
src/pages/code-graph/components/ProjectDialog.tsx
Normal file
134
src/pages/code-graph/components/ProjectDialog.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCodeGraphStore } from '../store';
|
||||
|
||||
export function ProjectDialog() {
|
||||
const { projectDialogOpen, setProjectDialogOpen, projects, projectsLoading, loadProjects, addProject, removeProject } =
|
||||
useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
projectDialogOpen: s.projectDialogOpen,
|
||||
setProjectDialogOpen: s.setProjectDialogOpen,
|
||||
projects: s.projects,
|
||||
projectsLoading: s.projectsLoading,
|
||||
loadProjects: s.loadProjects,
|
||||
addProject: s.addProject,
|
||||
removeProject: s.removeProject,
|
||||
})),
|
||||
);
|
||||
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [newPath, setNewPath] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newPath.trim()) return;
|
||||
setAddLoading(true);
|
||||
const ok = await addProject(newPath.trim(), newName.trim() || undefined);
|
||||
if (ok) {
|
||||
setNewPath('');
|
||||
setNewName('');
|
||||
}
|
||||
setAddLoading(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setProjectDialogOpen(open);
|
||||
if (open) loadProjects();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg bg-slate-900 text-slate-100 border border-white/10'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2 text-slate-100'>
|
||||
<FolderOpenIcon className='w-4 h-4 text-indigo-400' />
|
||||
项目管理
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-slate-400'>管理已注册的代码分析项目</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 新增项目 */}
|
||||
<div className='space-y-2 rounded-lg bg-slate-800/60 p-3 border border-white/5'>
|
||||
<p className='text-xs font-medium text-slate-400 mb-2'>添加新项目</p>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-slate-400'>项目路径 *</Label>
|
||||
<Input
|
||||
value={newPath}
|
||||
onChange={(e) => setNewPath(e.target.value)}
|
||||
placeholder='/path/to/project'
|
||||
className='bg-slate-700/60 border-white/10 text-slate-100 placeholder:text-slate-500 h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs text-slate-400'>项目名称(可选)</Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder='My Project'
|
||||
className='bg-slate-700/60 border-white/10 text-slate-100 placeholder:text-slate-500 h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleAdd}
|
||||
disabled={addLoading}
|
||||
className='w-full bg-indigo-600 hover:bg-indigo-500 text-white text-xs h-8 mt-1'>
|
||||
<PlusIcon className='w-3.5 h-3.5 mr-1' />
|
||||
{addLoading ? '添加中…' : '添加项目'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<p className='text-xs font-medium text-slate-400'>已注册项目</p>
|
||||
<button
|
||||
onClick={loadProjects}
|
||||
disabled={projectsLoading}
|
||||
className='text-slate-500 hover:text-slate-300 transition-colors'>
|
||||
<RefreshCwIcon className={`w-3.5 h-3.5 ${projectsLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projectsLoading ? (
|
||||
<div className='flex items-center justify-center h-16 text-slate-500 text-xs'>加载中…</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className='flex items-center justify-center h-16 text-slate-500 text-xs border border-dashed border-white/10 rounded-lg'>
|
||||
暂无项目
|
||||
</div>
|
||||
) : (
|
||||
<ul className='space-y-1.5 max-h-56 overflow-y-auto pr-1'>
|
||||
{projects.map((p) => (
|
||||
<li
|
||||
key={p.path}
|
||||
className='flex items-center gap-2 rounded-md bg-slate-800/60 px-3 py-2 border border-white/5 group'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<p className='text-xs font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p>
|
||||
<p className='text-[11px] text-slate-500 truncate'>{p.path}</p>
|
||||
</div>
|
||||
{p.status !== undefined && (
|
||||
<span
|
||||
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full ${p.status === 'active' ? 'bg-green-900/60 text-green-400' : 'bg-slate-700 text-slate-400'
|
||||
}`}>
|
||||
{p.status === 'active' ? '监听中' : '已停止'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeProject(p.path)}
|
||||
className='shrink-0 text-slate-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100'>
|
||||
<Trash2Icon className='w-3.5 h-3.5' />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,34 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { CodeGraphView } from './components/CodeGraph';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileProjectData } from './modules/tree';
|
||||
import { getFilesApi } from './modules/api/get-files';
|
||||
import { toast } from 'sonner';
|
||||
import { exampleData } from './mock/example';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
import { CodePod } from './components/CodePod';
|
||||
import { useCodeGraphStore } from './store';
|
||||
import CodeGraphView from './components/CodeGraph';
|
||||
import { Code3DGraph } from './components/Code3DGraph';
|
||||
import { NodeInfo } from './components/NodeInfo';
|
||||
import { ProjectDialog } from './components/ProjectDialog';
|
||||
|
||||
type ViewMode = '2d' | '3d';
|
||||
|
||||
export default function CodeGraphPage() {
|
||||
const [files, setFiles] = useState<FileProjectData[]>([]);
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
|
||||
const handleLoadJson = useCallback(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput);
|
||||
// 支持 { list: [...] } 或直接数组
|
||||
const arr: FileProjectData[] = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.list) ? parsed.list : null;
|
||||
if (!arr) throw new Error('需要数组或 { list: [...] } 格式');
|
||||
setFiles(arr);
|
||||
setJsonError('');
|
||||
setShowPanel(false);
|
||||
} catch (e) {
|
||||
setJsonError((e as Error).message);
|
||||
}
|
||||
}, [jsonInput]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('3d');
|
||||
const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, loadProjects } = useCodeGraphStore(
|
||||
useShallow((s) => ({
|
||||
codePodOpen: s.codePodOpen,
|
||||
setCodePodOpen: s.setCodePodOpen,
|
||||
codePodAttrs: s.codePodAttrs,
|
||||
setProjectDialogOpen: s.setProjectDialogOpen,
|
||||
loadProjects: s.loadProjects
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
loadProjects();
|
||||
}, []);
|
||||
// 页面加载时从 API 获取文件列表
|
||||
const loadData = async () => {
|
||||
@@ -42,43 +43,53 @@ export default function CodeGraphPage() {
|
||||
<div className='flex flex-col h-full bg-slate-950 text-slate-100'>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className='flex items-center gap-3 px-4 h-12 border-b border-white/10 bg-slate-900/80 shrink-0'>
|
||||
<span className='font-semibold text-sm text-slate-200'>Code Graph</span>
|
||||
<span className='font-semibold text-sm text-slate-200'>
|
||||
<span className='sm:hidden'>Code</span>
|
||||
<span className='hidden sm:inline'>Code Graph</span>
|
||||
</span>
|
||||
<div className='h-4 w-px bg-white/10' />
|
||||
<span className='ml-auto text-xs text-slate-500'>{files.length} 个文件</span>
|
||||
</div>
|
||||
|
||||
{/* JSON 导入面板 */}
|
||||
{showPanel && (
|
||||
<div className='shrink-0 border-b border-white/10 bg-slate-900 p-4 flex flex-col gap-3'>
|
||||
<div className='text-xs text-slate-400'>
|
||||
粘贴 <code className='text-indigo-400'>FileProjectData[]</code> JSON 数组:
|
||||
</div>
|
||||
<textarea
|
||||
className='w-full h-32 rounded-md border border-white/10 bg-slate-800 px-3 py-2 text-xs font-mono text-slate-100 resize-none outline-none focus:ring-1 focus:ring-indigo-500'
|
||||
placeholder='[{ "id": "...", "filepath": "...", ... }]'
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
/>
|
||||
{jsonError && <div className='text-xs text-red-400'>{jsonError}</div>}
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={handleLoadJson}
|
||||
className='text-xs px-4 py-1.5 rounded-md bg-indigo-600 hover:bg-indigo-500 transition-colors'>
|
||||
加载
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPanel(false)}
|
||||
className='text-xs px-4 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 transition-colors'>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
{/* 2D / 3D 切换 */}
|
||||
<div className='flex items-center rounded-md overflow-hidden border border-white/10 text-xs'>
|
||||
<button
|
||||
onClick={() => setViewMode('2d')}
|
||||
className={`px-3 py-1 transition-colors ${viewMode === '2d' ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}>
|
||||
2D
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('3d')}
|
||||
className={`px-3 py-1 transition-colors ${viewMode === '3d' ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}>
|
||||
3D
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className='h-4 w-px bg-white/10' />
|
||||
<button
|
||||
onClick={() => setProjectDialogOpen(true)}
|
||||
title='项目管理'
|
||||
className='ml-auto flex items-center gap-1.5 px-2 py-1 rounded-md text-slate-400 hover:text-slate-200 hover:bg-white/5 transition-colors'>
|
||||
<DatabaseIcon className='w-4 h-4' />
|
||||
<span className='hidden sm:inline text-xs'>项目管理</span>
|
||||
</button>
|
||||
<span className='hidden sm:inline text-xs text-slate-500'>{files.length} 个文件</span>
|
||||
</div>
|
||||
|
||||
{/* 图视图 */}
|
||||
<div className='flex-1 min-h-0'>
|
||||
<CodeGraphView files={files} className='h-full' />
|
||||
{viewMode === '3d' ? (
|
||||
<Code3DGraph files={files} className='h-full' />
|
||||
) : (
|
||||
<CodeGraphView files={files} className='h-full' />
|
||||
)}
|
||||
</div>
|
||||
{/* CodePod 弹窗 */}
|
||||
<CodePod
|
||||
open={codePodOpen}
|
||||
onClose={() => setCodePodOpen(false)}
|
||||
nodeAttrs={codePodAttrs}
|
||||
/>
|
||||
{/* NodeInfo 信息窗 */}
|
||||
<NodeInfo />
|
||||
{/* 项目管理弹窗 */}
|
||||
<ProjectDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,113 @@
|
||||
import { create } from 'zustand';
|
||||
import { GraphNode } from '../modules/graph';
|
||||
import { queryApi as projectApi } from '@/modules/project-api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export type ProjectItem = {
|
||||
path: string;
|
||||
name?: string;
|
||||
repo?: string;
|
||||
status?: 'active' | 'inactive';
|
||||
};
|
||||
|
||||
const API_URL = '/root/v1/cnb-dev';
|
||||
|
||||
export type NodeInfoData = {
|
||||
label: string;
|
||||
fullPath: string;
|
||||
projectPath: string;
|
||||
kind: 'root' | 'dir' | 'file';
|
||||
color: string;
|
||||
fileId?: string;
|
||||
nodeSize?: number;
|
||||
};
|
||||
|
||||
type State = {
|
||||
codePodOpen: boolean;
|
||||
setCodePodOpen: (open: boolean) => void;
|
||||
codePodAttrs: GraphNode | null;
|
||||
setCodePodAttrs: (attrs: GraphNode | null) => void;
|
||||
// 项目管理弹窗
|
||||
projectDialogOpen: boolean;
|
||||
setProjectDialogOpen: (open: boolean) => void;
|
||||
// 项目列表
|
||||
projects: ProjectItem[];
|
||||
projectsLoading: boolean;
|
||||
loadProjects: () => Promise<void>;
|
||||
addProject: (filepath: string, name?: string) => Promise<boolean>;
|
||||
removeProject: (path: string) => Promise<void>;
|
||||
// NodeInfo 弹窗
|
||||
nodeInfoOpen: boolean;
|
||||
nodeInfoData: NodeInfoData | null;
|
||||
nodeInfoPos: { x: number; y: number };
|
||||
setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void;
|
||||
closeNodeInfo: () => void;
|
||||
};
|
||||
|
||||
export const useCodeGraphStore = create<State>((set) => ({
|
||||
export const useCodeGraphStore = create<State>()((set, get) => ({
|
||||
codePodOpen: false,
|
||||
setCodePodOpen: (open) => set({ codePodOpen: open }),
|
||||
codePodAttrs: null,
|
||||
setCodePodAttrs: (attrs) => set({ codePodAttrs: attrs }),
|
||||
projectDialogOpen: false,
|
||||
setProjectDialogOpen: (open) => set({ projectDialogOpen: open }),
|
||||
projects: [],
|
||||
projectsLoading: false,
|
||||
loadProjects: async () => {
|
||||
set({ projectsLoading: true });
|
||||
try {
|
||||
const res = await projectApi.project.list(undefined, { url: API_URL });
|
||||
if (res.code === 200) {
|
||||
set({ projects: (res.data?.list as ProjectItem[]) ?? [] });
|
||||
} else {
|
||||
toast.error('获取项目列表失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error('获取项目列表失败');
|
||||
} finally {
|
||||
set({ projectsLoading: false });
|
||||
}
|
||||
},
|
||||
addProject: async (filepath, name) => {
|
||||
try {
|
||||
const res = await projectApi.project.add(
|
||||
{ filepath, name: name || undefined },
|
||||
{ url: API_URL },
|
||||
);
|
||||
if (res.code === 200) {
|
||||
toast.success('项目添加成功');
|
||||
await get().loadProjects();
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.message ?? '项目添加失败');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
toast.error('项目添加失败');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
removeProject: async (path) => {
|
||||
try {
|
||||
const res = await projectApi.project.remove({ filepath: path }, { url: API_URL });
|
||||
if (res.code === 200) {
|
||||
toast.success('项目已移除');
|
||||
set((s) => ({ projects: s.projects.filter((p) => p.path !== path) }));
|
||||
} else {
|
||||
toast.error(res.message ?? '移除失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error('移除失败');
|
||||
}
|
||||
},
|
||||
nodeInfoOpen: false,
|
||||
nodeInfoData: null,
|
||||
nodeInfoPos: { x: 0, y: 0 },
|
||||
setNodeInfo: (data, pos) =>
|
||||
set({
|
||||
nodeInfoOpen: !!data,
|
||||
nodeInfoData: data,
|
||||
nodeInfoPos: pos ?? { x: 0, y: 0 },
|
||||
}),
|
||||
closeNodeInfo: () => set({ nodeInfoOpen: false }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user