feat: 更新 README,添加 3D 图展示和项目结构;增强 Code3DGraph 组件,支持节点发光效果和屏幕坐标投影;优化 CodePod 组件,增加文件保存和刷新功能;调整 NodeInfo 组件位置;扩展状态管理,增加保存文件功能

This commit is contained in:
xiongxiao
2026-03-14 03:54:35 +08:00
committed by cnb
parent 7d66ccb3a1
commit 0dcefbcdc8
5 changed files with 199 additions and 41 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import ForceGraph3D from '3d-force-graph';
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
import * as THREE from 'three';
import { FileProjectData } from '../modules/tree';
import { NodeSearchEntry } from '../modules/graph';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
@@ -189,19 +190,26 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const { setNodeInfo } = useCodeGraphStore(
const { setNodeInfo, selectedNodeId } = useCodeGraphStore(
useShallow((s) => ({
setNodeInfo: s.setNodeInfo,
selectedNodeId: s.nodeInfoData?.fullPath ?? null,
})),
);
// 用 ref 避免 nodeThreeObject 回调的陈旧闭包
const selectedNodeIdRef = useRef<string | null>(null);
useEffect(() => {
selectedNodeIdRef.current = selectedNodeId;
}, [selectedNodeId]);
// 节点跳转
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 distance = 160;
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 },
@@ -210,6 +218,22 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
);
}, []);
// 将节点世界坐标投影为屏幕坐标
const nodeToScreenPos = useCallback((node: Graph3DNode): { x: number; y: number } => {
const graph = graphRef.current;
if (!graph) return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const camera = graph.camera();
const renderer = (graph as any).renderer?.() as THREE.WebGLRenderer | undefined;
if (!camera || !renderer) return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const vec = new THREE.Vector3(node.x ?? 0, node.y ?? 0, node.z ?? 0);
vec.project(camera);
const rect = renderer.domElement.getBoundingClientRect();
return {
x: ((vec.x + 1) / 2) * rect.width + rect.left,
y: ((-vec.y + 1) / 2) * rect.height + rect.top,
};
}, []);
// 初始化 & 数据更新
useEffect(() => {
const el = containerRef.current;
@@ -231,31 +255,65 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
.nodeRelSize(1)
.nodeVal((node) => {
const n = node as Graph3DNode;
return n.nodeSize * n.nodeSize;
return (n.nodeSize ?? 3) * (n.nodeSize ?? 3);
})
.nodeColor((node) => (node as Graph3DNode).color)
.nodeLabel((node) => (node as Graph3DNode).label)
.nodeThreeObject(((node: Graph3DNode) => {
const n = node as Graph3DNode;
if (!n?.fullPath || n.fullPath !== selectedNodeIdRef.current) return null;
const geometry = new THREE.SphereGeometry(n.nodeSize * 1.2, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: n.color,
emissive: n.color,
emissiveIntensity: 1.5,
roughness: 0.3,
metalness: 0.5,
transparent: true,
opacity: 0.9,
});
const mesh = new THREE.Mesh(geometry, material);
const glowGeometry = new THREE.SphereGeometry(n.nodeSize * 2, 32, 32);
const glowMaterial = new THREE.MeshBasicMaterial({
color: n.color,
transparent: true,
opacity: 0.25,
});
const glowMesh = new THREE.Mesh(glowGeometry, glowMaterial);
mesh.add(glowMesh);
return mesh;
}) as any)
.linkWidth(0)
.linkColor(() => 'rgba(255,255,255,0.6)')
.linkDirectionalParticles(2)
.linkDirectionalParticleWidth(1.5)
.linkDirectionalParticleSpeed(0.004)
.linkDirectionalParticleColor(() => '#ffffff')
.onNodeClick((node, event) => {
.onNodeClick((node) => {
if (!node) return;
const n = node as Graph3DNode;
if (!n.fullPath) return;
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 },
);
// 等相机飞行动画结束800ms用节点投影坐标打开 NodeInfo
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
projectPath: n.projectPath,
kind: n.kind,
color: n.color,
fileId: n.fileId,
nodeSize: n.nodeSize,
},
pos,
);
}, 850);
})
.onNodeHover((node) => {
if (el) el.style.cursor = node ? 'pointer' : 'default';
@@ -287,7 +345,7 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
return () => {
ro.disconnect();
};
}, [files, focusNode, setNodeInfo]);
}, [files, focusNode, setNodeInfo, nodeToScreenPos]);
// 卸载时销毁
useEffect(() => {
@@ -297,6 +355,14 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
};
}, []);
// 选中节点变化时刷新节点渲染以更新发光效果
useEffect(() => {
const graph = graphRef.current;
if (graph) {
graph.refresh();
}
}, [selectedNodeId]);
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div ref={containerRef} className='w-full h-full' />
@@ -304,7 +370,28 @@ export function Code3DGraph({ files, className }: Code3DGraphProps) {
<NodeSearchBox
ref={searchBoxRef}
searchIndex={searchIndex}
onSelect={(entry) => focusNode(entry.nodeKey)}
onSelect={(entry) => {
focusNode(entry.nodeKey);
const graph = graphRef.current;
if (!graph) return;
const n = (graph.graphData().nodes as Graph3DNode[]).find((nd) => nd.id === entry.nodeKey);
if (!n) return;
setTimeout(() => {
const pos = nodeToScreenPos(n);
setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
projectPath: n.projectPath,
kind: n.kind,
color: n.color,
fileId: n.fileId,
nodeSize: n.nodeSize,
},
pos,
);
}, 850);
}}
/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { use, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Save, RefreshCw } from 'lucide-react';
import './CodePod.css';
import CodeMirror from '@uiw/react-codemirror';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
@@ -189,7 +190,12 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
const rootPath = nodeAttrs?.fullPath ?? '';
const codeGraphStore = useCodeGraphStore(useShallow((s) => ({
getFiles: s.getFiles,
saveFile: s.saveFile,
})));
const projectPath = nodeAttrs?.projectPath ?? '';
const displayPath = rootPath.startsWith(projectPath)
? rootPath.slice(projectPath.length) || '/'
: rootPath;
// 打开时重置
useEffect(() => {
@@ -198,10 +204,10 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
setDirFiles([]);
setSelectedFile(null);
setFileContent('');
init(nodeAttrs);
refresh(nodeAttrs);
setSidebarOpen(isDir ? true : false);
}, [open, nodeAttrs]);
const init = async (nodeAttrs: GraphNode) => {
const refresh = async (nodeAttrs: GraphNode) => {
setLoading(true);
const res = await codeGraphStore.getFiles({
filepath: nodeAttrs.fullPath,
@@ -218,7 +224,21 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
if (fileList.length === 0) return;
const file = fileList[0];
setSelectedFile(file);
}
};
const handleRefresh = () => {
if (!nodeAttrs) return;
setDirFiles([]);
setSelectedFile(null);
setFileContent('');
refresh(nodeAttrs);
};
const handleSave = async () => {
const filepath = selectedFile?.filepath ?? (isDir ? '' : nodeAttrs?.fullPath ?? '');
if (!filepath) return;
await codeGraphStore.saveFile(filepath, fileContent);
};
useEffect(() => {
if (!selectedFile) return;
if (selectedFile.content) {
@@ -255,11 +275,11 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
<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 */}
{/* 标题:显示相对路径(去掉 projectPath */}
<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}
{displayPath}
</div>
</div>
{/* 目录树 */}
@@ -293,7 +313,21 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
<span className='truncate text-slate-200'>
{selectedFile?.filepath ?? nodeAttrs.fullPath}
</span>
{loading && <span className='ml-auto text-slate-500'></span>}
<div className='ml-1 flex items-center gap-1 shrink-0'>
{loading && <span className='text-slate-500 text-xs'></span>}
<button
onClick={handleRefresh}
className='flex items-center justify-center w-6 h-6 rounded text-slate-400 hover:text-white hover:bg-white/10 transition-colors'
title='刷新'>
<RefreshCw size={13} />
</button>
<button
onClick={handleSave}
className='flex items-center justify-center w-6 h-6 rounded text-slate-400 hover:text-white hover:bg-white/10 transition-colors'
title='保存'>
<Save size={13} />
</button>
</div>
</div>
{/* CodeMirror */}
@@ -303,7 +337,7 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
height='100%'
theme={vscodeDark}
extensions={langExt}
readOnly
onChange={(val) => setFileContent(val)}
className='scrollbar'
basicSetup={{
lineNumbers: true,

View File

@@ -95,7 +95,7 @@ export function NodeInfo() {
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 };
: { left: nodeInfoPos.x + offset.x + 40, top: nodeInfoPos.y + offset.y - 40 };
const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label;
const projectPath = nodeInfoData.projectPath || '';

View File

@@ -57,6 +57,7 @@ type State = {
getContent?: boolean; // 是否获取文件内容,默认为 false
}) => Promise<Result<{ list: FileProjectData[] }>>;
createQuestion: (opts: { question: string, projectPath: string, engine?: 'openclaw' | 'opencode' }) => any;
saveFile: (filepath: string, content: string) => Promise<void>;
};
export const useCodeGraphStore = create<State>()((set, get) => ({
@@ -130,7 +131,7 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
nodeInfoData: data,
nodeInfoPos: pos ?? { x: 0, y: 0 },
}),
closeNodeInfo: () => set({ nodeInfoOpen: false }),
closeNodeInfo: () => set({ nodeInfoOpen: false, nodeInfoData: null }),
url: API_URL,
init: async (user) => {
// 可以在这里根据用户信息初始化一些数据,比如权限相关的设置等
@@ -161,6 +162,20 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
});
return res;
},
saveFile: async (filepath, content) => {
const url = get().url || API_URL;
try {
const b64 = btoa(new TextEncoder().encode(content).reduce((s, b) => s + String.fromCharCode(b), ''));
const res = await projectApi['project-file']['update-content']({ filepath, content: b64 }, { url });
if (res.code === 200) {
toast.success('保存成功');
} else {
toast.error(res.message ?? '保存失败');
}
} catch {
toast.error('保存失败');
}
},
createQuestion: async (opts) => {
const { question, projectPath, engine = 'opencode' } = opts;
const url = get().url
@@ -169,6 +184,7 @@ export const useCodeGraphStore = create<State>()((set, get) => ({
项目路径: ${projectPath}`
const res = await opencodeApi["opencode-cnb"].question({
question: q,
directory: projectPath,
}, {
url
});