feat: 更新 README,添加 3D 图展示和项目结构;增强 Code3DGraph 组件,支持节点发光效果和屏幕坐标投影;优化 CodePod 组件,增加文件保存和刷新功能;调整 NodeInfo 组件位置;扩展状态管理,增加保存文件功能
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user