feat: add code graph feature with interactive visualization
- Implemented CodeGraphView component for visualizing project file structure using Sigma.js. - Added NodeSearchBox for searching nodes within the graph. - Created API module to fetch file data from backend. - Developed graph building logic to construct a tree structure from file data. - Integrated new routes and updated route tree to include the code graph page. - Updated Vite configuration to load environment variables and changed server port. - Added example data for testing the code graph functionality.
This commit is contained in:
19
package.json
19
package.json
@@ -17,17 +17,24 @@
|
|||||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@kevisual/api": "^0.0.64",
|
"@kevisual/api": "^0.0.64",
|
||||||
"@kevisual/context": "^0.0.8",
|
"@kevisual/context": "^0.0.8",
|
||||||
"@kevisual/router": "0.1.1",
|
"@kevisual/router": "0.1.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.166.7",
|
"@tanstack/react-router": "^1.166.7",
|
||||||
|
"@uiw/codemirror-theme-vscode": "^4.25.8",
|
||||||
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"convex": "^1.32.0",
|
"convex": "^1.33.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"es-toolkit": "^1.45.1",
|
"es-toolkit": "^1.45.1",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"graphology": "^0.26.0",
|
"graphology": "^0.26.0",
|
||||||
@@ -54,15 +61,15 @@
|
|||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-router-devtools": "^1.166.7",
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
"@tanstack/router-plugin": "^1.166.7",
|
"@tanstack/router-plugin": "^1.166.7",
|
||||||
"@types/node": "^25.4.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "v8.0.0-beta.18"
|
"vite": "v8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,14 @@ const api = {
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -57,6 +65,14 @@ const api = {
|
|||||||
"description": "要移除的项目根目录绝对路径,必填"
|
"description": "要移除的项目根目录绝对路径,必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -80,6 +96,14 @@ const api = {
|
|||||||
"description": "要暂停监听的项目根目录绝对路径,必填"
|
"description": "要暂停监听的项目根目录绝对路径,必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -103,6 +127,14 @@ const api = {
|
|||||||
"description": "要查询的项目根目录绝对路径,必填"
|
"description": "要查询的项目根目录绝对路径,必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -115,6 +147,14 @@ const api = {
|
|||||||
"key": "list",
|
"key": "list",
|
||||||
"description": "列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)",
|
"description": "列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -183,6 +223,14 @@ const api = {
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -195,6 +243,7 @@ const api = {
|
|||||||
* @param data - Request parameters
|
* @param data - Request parameters
|
||||||
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
|
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
|
||||||
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填
|
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填
|
||||||
|
* @param data.filepath - {string} 按文件绝对路径过滤,选填
|
||||||
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填
|
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填
|
||||||
* @param data.title - {string} 按人工标注的标题字段过滤,选填
|
* @param data.title - {string} 按人工标注的标题字段过滤,选填
|
||||||
* @param data.tags - {array} 按人工标注的标签列表过滤,选填
|
* @param data.tags - {array} 按人工标注的标签列表过滤,选填
|
||||||
@@ -223,6 +272,12 @@ const api = {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按文件绝对路径过滤,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"repo": {
|
"repo": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"description": "按代码仓库标识过滤(如 owner/repo),选填",
|
"description": "按代码仓库标识过滤(如 owner/repo),选填",
|
||||||
@@ -284,6 +339,14 @@ const api = {
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -309,6 +372,52 @@ const api = {
|
|||||||
"description": "要读取的文件绝对路径,必填"
|
"description": "要读取的文件绝对路径,必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 将 base64 编码的内容写入指定文件路径,用于更新或创建文件
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要写入的文件绝对路径,必填
|
||||||
|
* @param data.content - {string (minLength: 1)} 文件内容的 base64 编码,必填
|
||||||
|
*/
|
||||||
|
"update-content": {
|
||||||
|
"path": "project-file",
|
||||||
|
"key": "update-content",
|
||||||
|
"description": "将 base64 编码的内容写入指定文件路径,用于更新或创建文件",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要写入的文件绝对路径,必填"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "文件内容的 base64 编码,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -370,6 +479,14 @@ const api = {
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
@@ -393,6 +510,14 @@ const api = {
|
|||||||
"description": "要删除的文件绝对路径,必填"
|
"description": "要删除的文件绝对路径,必填"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewItem": {
|
||||||
|
"api": {
|
||||||
|
"url": "/root/v1/cnb-dev"
|
||||||
|
},
|
||||||
|
"type": "api",
|
||||||
|
"title": "CNB_BOARD",
|
||||||
|
"routerStatus": "active"
|
||||||
|
},
|
||||||
"url": "/root/v1/cnb-dev",
|
"url": "/root/v1/cnb-dev",
|
||||||
"source": "query-proxy-api"
|
"source": "query-proxy-api"
|
||||||
}
|
}
|
||||||
|
|||||||
201
src/pages/code-graph/components/CodeGraph.tsx
Normal file
201
src/pages/code-graph/components/CodeGraph.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import Sigma from 'sigma';
|
||||||
|
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;
|
||||||
|
kind: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeGraphProps {
|
||||||
|
files: FileProjectData[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeGraphView({ files, className }: CodeGraphProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sigmaRef = useRef<Sigma | null>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
|
||||||
|
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(
|
||||||
|
useShallow((s) => ({
|
||||||
|
codePodOpen: s.codePodOpen,
|
||||||
|
setCodePodOpen: s.setCodePodOpen,
|
||||||
|
codePodAttrs: s.codePodAttrs,
|
||||||
|
setCodePodAttrs: s.setCodePodAttrs,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
if (sigmaRef.current) {
|
||||||
|
sigmaRef.current.kill();
|
||||||
|
sigmaRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { graph, searchIndex: idx } = buildTreeGraph(files);
|
||||||
|
graphRef.current = graph;
|
||||||
|
setSearchIndex(idx);
|
||||||
|
setStats({ nodes: graph.order, edges: graph.size });
|
||||||
|
|
||||||
|
const sigma = new Sigma(graph, containerRef.current, {
|
||||||
|
renderEdgeLabels: false,
|
||||||
|
defaultEdgeColor: '#334155',
|
||||||
|
defaultNodeColor: '#6366f1',
|
||||||
|
labelFont: 'Inter, system-ui, sans-serif',
|
||||||
|
labelSize: 11,
|
||||||
|
labelColor: { color: '#e2e8f0' },
|
||||||
|
edgeReducer: (_edge, data) => ({ ...data }),
|
||||||
|
nodeReducer: (_node, data) => ({
|
||||||
|
...data,
|
||||||
|
highlighted: data.highlighted ?? false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
sigma.on('enterNode', ({ node }) => {
|
||||||
|
const attrs = graph.getNodeAttributes(node) as GraphNode;
|
||||||
|
const pos = sigma.graphToViewport({ x: attrs.x, y: attrs.y });
|
||||||
|
setTooltip({ label: attrs.label, fullPath: attrs.fullPath, kind: attrs.kind, x: pos.x, y: pos.y });
|
||||||
|
graph.setNodeAttribute(node, 'highlighted', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
sigma.on('leaveNode', ({ node }) => {
|
||||||
|
setTooltip(null);
|
||||||
|
graph.setNodeAttribute(node, 'highlighted', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
sigma.on('rightClickNode', ({ node }) => {
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
// 跳转到节点
|
||||||
|
const jumpToNode = useCallback((entry: NodeSearchEntry) => {
|
||||||
|
const sigma = sigmaRef.current;
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!sigma || !graph || !graph.hasNode(entry.nodeKey)) return;
|
||||||
|
const displayData = sigma.getNodeDisplayData(entry.nodeKey);
|
||||||
|
if (displayData) {
|
||||||
|
sigma.getCamera().animate({ x: displayData.x, y: displayData.y, ratio: 0.08 }, { duration: 400 });
|
||||||
|
}
|
||||||
|
graph.setNodeAttribute(entry.nodeKey, 'highlighted', true);
|
||||||
|
setTimeout(() => graph.hasNode(entry.nodeKey) && graph.setNodeAttribute(entry.nodeKey, 'highlighted', false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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' };
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Sigma 画布 */}
|
||||||
|
<div ref={containerRef} className='flex-1 bg-slate-950' />
|
||||||
|
|
||||||
|
{/* 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={() => {
|
||||||
|
setCodePodAttrs(contextMenu.nodeAttrs);
|
||||||
|
setCodePodOpen(true);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CodePod 弹窗 */}
|
||||||
|
<CodePod
|
||||||
|
open={codePodOpen}
|
||||||
|
onClose={() => setCodePodOpen(false)}
|
||||||
|
nodeAttrs={codePodAttrs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeGraphView;
|
||||||
|
|
||||||
22
src/pages/code-graph/components/CodePod.css
Normal file
22
src/pages/code-graph/components/CodePod.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.cm-scroller {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-scroller::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-scroller::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-scroller::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-scroller::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
307
src/pages/code-graph/components/CodePod.tsx
Normal file
307
src/pages/code-graph/components/CodePod.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
import { use, useEffect, useState } from 'react';
|
||||||
|
import './CodePod.css';
|
||||||
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
|
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { json } from '@codemirror/lang-json';
|
||||||
|
import { css } from '@codemirror/lang-css';
|
||||||
|
import { html } from '@codemirror/lang-html';
|
||||||
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
|
import { GraphNode } from '../modules/graph';
|
||||||
|
import { queryApi as projectApi } from '@/modules/project-api';
|
||||||
|
import { FileProjectData } from '../modules/tree';
|
||||||
|
import { getFilesApi } from '../modules/api/get-files';
|
||||||
|
import './CodePod.css';
|
||||||
|
// ─── 目录树类型 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TreeNode = {
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
isDir: boolean;
|
||||||
|
children: TreeNode[];
|
||||||
|
file?: FileProjectData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将平铺文件列表转换为目录树 */
|
||||||
|
function buildTree(files: FileProjectData[], rootPath: string): TreeNode[] {
|
||||||
|
const root: TreeNode = { name: '', fullPath: rootPath, isDir: true, children: [] };
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// 获取相对路径
|
||||||
|
let rel = file.filepath.startsWith(rootPath)
|
||||||
|
? file.filepath.slice(rootPath.length)
|
||||||
|
: file.filepath;
|
||||||
|
if (rel.startsWith('/')) rel = rel.slice(1);
|
||||||
|
|
||||||
|
const parts = rel.split('/').filter(Boolean);
|
||||||
|
let cur = root;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
let child = cur.children.find((c) => c.name === part);
|
||||||
|
if (!child) {
|
||||||
|
child = {
|
||||||
|
name: part,
|
||||||
|
fullPath: (cur.fullPath ? cur.fullPath + '/' : '') + part,
|
||||||
|
isDir: !isLast,
|
||||||
|
children: [],
|
||||||
|
file: isLast ? file : undefined,
|
||||||
|
};
|
||||||
|
cur.children.push(child);
|
||||||
|
} else if (isLast) {
|
||||||
|
child.file = file;
|
||||||
|
child.isDir = false;
|
||||||
|
}
|
||||||
|
cur = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归排序:目录在前,文件在后,同类按名称字母序
|
||||||
|
const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
|
||||||
|
return nodes
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
.map((n) => ({ ...n, children: sortNodes(n.children) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return sortNodes(root.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 树节点组件 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TreeItem({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
selectedId?: string;
|
||||||
|
onSelect: (file: FileProjectData) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
if (node.isDir) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className='w-full text-left flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/5 transition-colors truncate'
|
||||||
|
style={{ paddingLeft: `${8 + depth * 12}px` }}>
|
||||||
|
<span className='shrink-0 text-slate-500 text-[10px]'>{expanded ? '▼' : '▶'}</span>
|
||||||
|
<span className='truncate font-medium'>{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeItem
|
||||||
|
key={child.fullPath}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = node.file?.id === selectedId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => node.file && onSelect(node.file)}
|
||||||
|
className={`w-full text-left flex items-center gap-1 py-1 text-xs truncate transition-colors ${active ? 'bg-indigo-600/30 text-indigo-300' : 'text-slate-300 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${8 + depth * 12}px` }}
|
||||||
|
title={node.file?.filepath}>
|
||||||
|
<span className='shrink-0 text-slate-600 text-[10px]'>○</span>
|
||||||
|
<span className='truncate'>{node.file?.title ?? node.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** base64 → 字符串(兼容 Unicode) */
|
||||||
|
function decodeBase64(b64: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(escape(atob(b64)));
|
||||||
|
} catch {
|
||||||
|
return atob(b64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据文件名推断 CodeMirror 语言扩展 */
|
||||||
|
function getLangExtension(filename: string) {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
switch (ext) {
|
||||||
|
case 'ts':
|
||||||
|
case 'tsx':
|
||||||
|
return [javascript({ typescript: true, jsx: ext === 'tsx' })];
|
||||||
|
case 'js':
|
||||||
|
case 'jsx':
|
||||||
|
return [javascript({ jsx: ext === 'jsx' })];
|
||||||
|
case 'json':
|
||||||
|
return [json()];
|
||||||
|
case 'css':
|
||||||
|
case 'scss':
|
||||||
|
return [css()];
|
||||||
|
case 'html':
|
||||||
|
return [html()];
|
||||||
|
case 'md':
|
||||||
|
return [markdown()];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取文件内容(base64 解码) */
|
||||||
|
async function fetchFileContent(filepath: string): Promise<string> {
|
||||||
|
const res = await projectApi['project-file'].get({ filepath });
|
||||||
|
if (res.code !== 200) return '';
|
||||||
|
const raw = res.data?.content ?? '';
|
||||||
|
return raw ? decodeBase64(raw) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 组件 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CodePodProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
nodeAttrs: GraphNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
|
||||||
|
const [dirFiles, setDirFiles] = useState<FileProjectData[]>([]);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<FileProjectData | null>(null);
|
||||||
|
const [fileContent, setFileContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const rootPath = nodeAttrs?.fullPath ?? '';
|
||||||
|
|
||||||
|
const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root';
|
||||||
|
|
||||||
|
// 打开时重置
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !nodeAttrs) return;
|
||||||
|
console.log('打开 CodePod', nodeAttrs);
|
||||||
|
setDirFiles([]);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFileContent('');
|
||||||
|
init(nodeAttrs);
|
||||||
|
}, [open, nodeAttrs]);
|
||||||
|
const init = async (nodeAttrs: GraphNode) => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getFilesApi({
|
||||||
|
filepath: nodeAttrs.fullPath,
|
||||||
|
// projectPath: nodeAttrs.projectPath
|
||||||
|
getContent: true,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (res.code !== 200) {
|
||||||
|
console.error('获取文件列表失败', res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileList = res.data?.list ?? [];
|
||||||
|
setDirFiles(fileList);
|
||||||
|
if (fileList.length === 0) return;
|
||||||
|
const file = fileList[0];
|
||||||
|
setSelectedFile(file);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
if (selectedFile.content) {
|
||||||
|
setFileContent(selectedFile.content);
|
||||||
|
}
|
||||||
|
}, [selectedFile]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!open || !nodeAttrs) return null;
|
||||||
|
|
||||||
|
const filename = isDir
|
||||||
|
? selectedFile?.filepath.split('/').pop() ?? ''
|
||||||
|
: nodeAttrs.fullPath.split('/').pop() ?? '';
|
||||||
|
|
||||||
|
const langExt = getLangExtension(filename);
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* 遮罩 */
|
||||||
|
<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'>
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='absolute top-2 right-3 z-10 flex items-center justify-center w-7 h-7 rounded-full text-slate-400 hover:text-white hover:bg-white/10 transition-colors text-lg leading-none'>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
</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 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'>
|
||||||
|
<span className='truncate text-slate-200'>
|
||||||
|
{isDir ? (selectedFile?.filepath ?? '请选择文件') : nodeAttrs.fullPath}
|
||||||
|
</span>
|
||||||
|
{loading && <span className='ml-auto text-slate-500'>加载中…</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CodeMirror */}
|
||||||
|
<div className='flex-1 overflow-auto'>
|
||||||
|
<CodeMirror
|
||||||
|
value={fileContent}
|
||||||
|
height='100%'
|
||||||
|
theme={vscodeDark}
|
||||||
|
extensions={langExt}
|
||||||
|
readOnly
|
||||||
|
className='scrollbar'
|
||||||
|
basicSetup={{
|
||||||
|
lineNumbers: true,
|
||||||
|
foldGutter: true,
|
||||||
|
highlightActiveLine: true,
|
||||||
|
}}
|
||||||
|
style={{ height: '100%', fontSize: 13 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodePod;
|
||||||
133
src/pages/code-graph/components/NodeSearchBox.tsx
Normal file
133
src/pages/code-graph/components/NodeSearchBox.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import { SearchIcon, FolderIcon, FileIcon, DatabaseIcon } from 'lucide-react';
|
||||||
|
import { NodeSearchEntry, NodeKind } from '../modules/graph';
|
||||||
|
|
||||||
|
export interface NodeSearchBoxHandle {
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeSearchBoxProps {
|
||||||
|
searchIndex: NodeSearchEntry[];
|
||||||
|
onSelect: (entry: NodeSearchEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<NodeKind, string> = { root: '项目根', dir: '目录', file: '文件' };
|
||||||
|
const KIND_COLOR: Record<NodeKind, string> = {
|
||||||
|
root: 'text-amber-400',
|
||||||
|
dir: 'text-blue-400',
|
||||||
|
file: 'text-indigo-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
function KindIcon({ kind }: { kind: NodeKind }) {
|
||||||
|
if (kind === 'root') return <DatabaseIcon className='size-3 shrink-0 text-amber-400' />;
|
||||||
|
if (kind === 'dir') return <FolderIcon className='size-3 shrink-0 text-blue-400' />;
|
||||||
|
return <FileIcon className='size-3 shrink-0 text-indigo-400' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSearchBox = forwardRef<NodeSearchBoxHandle, NodeSearchBoxProps>(function NodeSearchBox({ searchIndex, onSelect }, ref) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus() {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = query.trim()
|
||||||
|
? searchIndex
|
||||||
|
.filter((e) => e.label.toLowerCase().includes(query.toLowerCase()) || e.fullPath.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
.slice(0, 30)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleSelect = (entry: NodeSearchEntry) => {
|
||||||
|
onSelect(entry);
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className='relative w-64'>
|
||||||
|
<CommandPrimitive
|
||||||
|
// cmdk 的 shouldFilter=false,由我们自己过滤
|
||||||
|
shouldFilter={false}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Esc 关闭
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div className='flex items-center gap-1.5 rounded-md border border-white/10 bg-slate-800 px-2 py-1 focus-within:ring-1 focus-within:ring-indigo-500'>
|
||||||
|
<SearchIcon className='size-3 shrink-0 text-slate-500' />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(v.trim().length > 0);
|
||||||
|
}}
|
||||||
|
onFocus={() => query.trim().length > 0 && setOpen(true)}
|
||||||
|
placeholder='搜索节点…'
|
||||||
|
className='flex-1 min-w-0 bg-transparent text-xs text-slate-100 outline-none placeholder:text-slate-500'
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuery('');
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className='text-slate-500 hover:text-slate-300 text-xs leading-none'>✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下拉列表,使用绝对定位浮在画布上方 */}
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className='absolute top-full mt-1 left-0 right-0 z-50 rounded-md border border-white/10 bg-slate-800 shadow-2xl overflow-hidden'>
|
||||||
|
<CommandPrimitive.List className='max-h-64 overflow-y-auto py-1 scrollbar'>
|
||||||
|
<CommandPrimitive.Empty className='py-4 text-center text-xs text-slate-500'>
|
||||||
|
无匹配结果
|
||||||
|
</CommandPrimitive.Empty>
|
||||||
|
{filtered.map((entry) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
key={entry.nodeKey}
|
||||||
|
value={entry.nodeKey}
|
||||||
|
onSelect={() => handleSelect(entry)}
|
||||||
|
className='flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer
|
||||||
|
aria-selected:bg-slate-700 data-[selected=true]:bg-slate-700
|
||||||
|
hover:bg-slate-700 outline-none'>
|
||||||
|
<KindIcon kind={entry.kind} />
|
||||||
|
<span className={`shrink-0 text-[10px] font-medium ${KIND_COLOR[entry.kind]}`}>
|
||||||
|
{KIND_LABEL[entry.kind]}
|
||||||
|
</span>
|
||||||
|
<span className='text-slate-200 truncate'>{entry.label}</span>
|
||||||
|
<span className='text-slate-500 text-[10px] ml-auto shrink-0 max-w-[100px] truncate'>
|
||||||
|
{entry.fullPath.split('/').slice(-2).join('/')}
|
||||||
|
</span>
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
))}
|
||||||
|
</CommandPrimitive.List>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandPrimitive>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
274
src/pages/code-graph/mock/example.ts
Normal file
274
src/pages/code-graph/mock/example.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
export const exampleData = {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC8uZ2l0aWdub3Jl",
|
||||||
|
"hash": "245b57b936db09c473868fa944daea44",
|
||||||
|
"filepath": "/workspace/projects/project-search/.gitignore",
|
||||||
|
"lastModified": 1773396494799,
|
||||||
|
"size": 78,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC8ubnBtcmM",
|
||||||
|
"hash": "e54aa0abede6d610521e67db41b61b61",
|
||||||
|
"filepath": "/workspace/projects/project-search/.npmrc",
|
||||||
|
"lastModified": 1773396494799,
|
||||||
|
"size": 117,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9BR0VOVFMubWQ",
|
||||||
|
"hash": "0f4a840cc37e1c93ecdbee52d2b77702",
|
||||||
|
"filepath": "/workspace/projects/project-search/AGENTS.md",
|
||||||
|
"lastModified": 1773396494799,
|
||||||
|
"size": 12,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9idW4uY29uZmlnLnRz",
|
||||||
|
"hash": "ba21ce50d8b1ec5b08c3d4ff23e16f2f",
|
||||||
|
"filepath": "/workspace/projects/project-search/bun.config.ts",
|
||||||
|
"lastModified": 1773396494799,
|
||||||
|
"size": 143,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9wYWNrYWdlLmpzb24",
|
||||||
|
"hash": "82169d910f803da309f15cb0abf38fdd",
|
||||||
|
"filepath": "/workspace/projects/project-search/package.json",
|
||||||
|
"lastModified": 1773397336672,
|
||||||
|
"size": 969,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9yZWFkbWUubWQ",
|
||||||
|
"hash": "8c9e299c7cefae11029ee65cbac5ebff",
|
||||||
|
"filepath": "/workspace/projects/project-search/readme.md",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 709,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9idW4ubG9jaw",
|
||||||
|
"hash": "da1b6ae634c817cedb15b3c3fcf81136",
|
||||||
|
"filepath": "/workspace/projects/project-search/bun.lock",
|
||||||
|
"lastModified": 1773396504805,
|
||||||
|
"size": 12584,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L2NvbW1vbi50cw",
|
||||||
|
"hash": "d13e790679e574e8143c4c95ba8dc908",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/common.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 307,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L2ZpbGUudHM",
|
||||||
|
"hash": "80f831e09bf6da3aa29b159e606c6ab2",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/file.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 340,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L3JlbW90ZS50cw",
|
||||||
|
"hash": "b45b8f7ffb2bb08b282d15c47dda986f",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/remote.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 527,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L3NlYXJjaC50cw",
|
||||||
|
"hash": "5784efc94c1553fe4ad0490b6691270f",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/search.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 230,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L3N0YXJ0LnRz",
|
||||||
|
"hash": "713df2f506ab2d880c4f55dfffe81072",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/start.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 148,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC90ZXN0L3dhdGhlci50cw",
|
||||||
|
"hash": "4a486000da41166f06c1c3281f61507f",
|
||||||
|
"filepath": "/workspace/projects/project-search/test/wather.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 1305,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvYXBwLnRz",
|
||||||
|
"hash": "54159741bf88c9d17742d3f5eab38a94",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/app.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 407,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvaW5kZXgudHM",
|
||||||
|
"hash": "0a75ccc5f8c9e604f31cca0cb00a3927",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/index.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 511,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcmVtb3RlLnRz",
|
||||||
|
"hash": "c064b91a566fe55e5bc9d5e5036a60a2",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/remote.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 346,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvZmlsZS1zZWFyY2gvaW5kZXgudHM",
|
||||||
|
"hash": "a8a23bf42ed40f3df40ac1454a9dca59",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/file-search/index.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 882,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcm91dGVzL2F1dGgudHM",
|
||||||
|
"hash": "531e07b8e849690f2c351135558b4aee",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/routes/auth.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 540,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcm91dGVzL2ZpbGUudHM",
|
||||||
|
"hash": "167489aa5e2c84671a270e367865e9b3",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/routes/file.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 2789,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcm91dGVzL3Byb2plY3QudHM",
|
||||||
|
"hash": "677c8e7c9ab89c27e8e081b645dd5fe2",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/routes/project.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 5543,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcm91dGVzL3NlYXJjaC50cw",
|
||||||
|
"hash": "7f8aa6dc0535983d2d52426f3b66079b",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/routes/search.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 2474,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC9tYW5hZ2VyLnRz",
|
||||||
|
"hash": "4dc70f824432cdf432eb1132220283f8",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/manager.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 6211,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC9wcm9qZWN0LXN0b3JlLnRz",
|
||||||
|
"hash": "81565e8132fa3f2dc1f056200acd74c3",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/project-store.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 3852,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC91c2VyLWludGVyZmFjZS50cw",
|
||||||
|
"hash": "13f7a980636a8126da5c80ffeda196a7",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/user-interface.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 190,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvc2NoZWR1bGVyL2luZGV4LnRz",
|
||||||
|
"hash": "992051a373a1e8edaf6acf969492ec57",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/scheduler/index.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 1637,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC9wcm9qZWN0LWxpc3RlbmVyL2xpc3RlbmVyLnRz",
|
||||||
|
"hash": "eb604e0647b8dc1b8465e1b39347cef6",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/project-listener/listener.ts",
|
||||||
|
"lastModified": 1773398438146,
|
||||||
|
"size": 3049,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC9wcm9qZWN0LXNlYXJjaC9maWxlLWxpc3QtY29udGVudC50cw",
|
||||||
|
"hash": "b02a9a6c1dfa86470ce2c92161a00580",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/project-search/file-list-content.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 1771,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC9wcm9qZWN0LXNlYXJjaC9pbmRleC50cw",
|
||||||
|
"hash": "0ad5ca74b83986db5752c7ed0ad5685f",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/project-search/index.ts",
|
||||||
|
"lastModified": 1773398469005,
|
||||||
|
"size": 10632,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC91dGlsL2dpdC50cw",
|
||||||
|
"hash": "bc13fe98a61d73d9379c1bb9a019e852",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/util/git.ts",
|
||||||
|
"lastModified": 1773396494800,
|
||||||
|
"size": 579,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "L3dvcmtzcGFjZS9wcm9qZWN0cy9wcm9qZWN0LXNlYXJjaC9zcmMvcHJvamVjdC91dGlscy50cw",
|
||||||
|
"hash": "269f3bbc683671fbf41821f7bf6f46c7",
|
||||||
|
"filepath": "/workspace/projects/project-search/src/project/utils.ts",
|
||||||
|
"lastModified": 1773398421006,
|
||||||
|
"size": 886,
|
||||||
|
"projectPath": "/workspace/projects/project-search",
|
||||||
|
"repo": "kevisual/test-repo"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
src/pages/code-graph/modules/api/get-files.ts
Normal file
21
src/pages/code-graph/modules/api/get-files.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { queryApi as projectApi } from "@/modules/project-api";
|
||||||
|
import { FileProjectData } from "../tree";
|
||||||
|
import { Result } from "@kevisual/query";
|
||||||
|
/** 从后端 API 获取文件列表 */
|
||||||
|
export const getFilesApi = async (opts?: {
|
||||||
|
filepath?: string; // 可选的目录路径,默认为根目录
|
||||||
|
q?: string; // 可选的搜索关键词
|
||||||
|
projectPath?: string; // 项目路径,必填
|
||||||
|
getContent?: boolean; // 是否获取文件内容,默认为 false
|
||||||
|
}, dataOpts?: {
|
||||||
|
url?: string; // 可选的 API 基础 URL,默认为 "/root/v1/dev-cnb"
|
||||||
|
}): Promise<Result<{ list: FileProjectData[] }>> => {
|
||||||
|
const url = dataOpts?.url ?? "/root/v1/cnb-dev";
|
||||||
|
const res = await projectApi["project-search"].files({
|
||||||
|
...opts,
|
||||||
|
q: opts?.q ?? "",
|
||||||
|
}, {
|
||||||
|
url
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
222
src/pages/code-graph/modules/graph.ts
Normal file
222
src/pages/code-graph/modules/graph.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import Graph from 'graphology';
|
||||||
|
import { FileProjectData } from './tree';
|
||||||
|
|
||||||
|
// 简单的路径工具函数(浏览器兼容)
|
||||||
|
const posixPath = {
|
||||||
|
dirname(p: string): string {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
|
return i > 0 ? p.slice(0, i) : '/';
|
||||||
|
},
|
||||||
|
resolve(dir: string, rel: string): string {
|
||||||
|
const parts = (dir + '/' + rel).split('/');
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '..') result.pop();
|
||||||
|
else if (part !== '.') result.push(part);
|
||||||
|
}
|
||||||
|
return result.join('/');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 节点类型 */
|
||||||
|
export type NodeKind = 'root' | 'dir' | 'file';
|
||||||
|
|
||||||
|
/** 图节点属性 */
|
||||||
|
export interface GraphNode {
|
||||||
|
label: string;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** 完整路径(文件或目录) */
|
||||||
|
fullPath: string;
|
||||||
|
/** 所属 projectPath */
|
||||||
|
projectPath: string;
|
||||||
|
kind: NodeKind;
|
||||||
|
/** 对应 FileProjectData.id(仅 file 节点有效) */
|
||||||
|
fileId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索索引项 */
|
||||||
|
export interface NodeSearchEntry {
|
||||||
|
nodeKey: string;
|
||||||
|
label: string;
|
||||||
|
fullPath: string;
|
||||||
|
kind: NodeKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** buildTreeGraph 返回值 */
|
||||||
|
export interface TreeGraphResult {
|
||||||
|
graph: Graph;
|
||||||
|
searchIndex: NodeSearchEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 颜色工具 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 字符串 → 稳定 hex 颜色(HSL→RGB,WebGL 兼容) */
|
||||||
|
function stringToColor(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const h = Math.abs(hash) % 360;
|
||||||
|
const s = 0.65;
|
||||||
|
const l = 0.55;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
};
|
||||||
|
const toHex = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按文件扩展名返回颜色 */
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 图构建 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件列表构建目录树图
|
||||||
|
*
|
||||||
|
* 节点:
|
||||||
|
* - root:每个 projectPath 一个,金色大圆
|
||||||
|
* - dir:中间目录节点,按路径着色中圆
|
||||||
|
* - file:文件节点,按扩展名着色小圆
|
||||||
|
*
|
||||||
|
* 边:父目录 → 子目录/文件
|
||||||
|
*/
|
||||||
|
export function buildTreeGraph(files: FileProjectData[]): TreeGraphResult {
|
||||||
|
const graph = new Graph({ multi: false, allowSelfLoops: false });
|
||||||
|
const searchIndex: NodeSearchEntry[] = [];
|
||||||
|
|
||||||
|
if (files.length === 0) return { graph, searchIndex };
|
||||||
|
|
||||||
|
// 按 projectPath 分组
|
||||||
|
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 = 35;
|
||||||
|
|
||||||
|
projects.forEach((projectPath, pi) => {
|
||||||
|
const groupFiles = projectGroups.get(projectPath)!;
|
||||||
|
|
||||||
|
// ── 1. 收集所有中间目录路径 ──────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 建树索引 ──────────────────────────────────────
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 辐射树布局 ────────────────────────────────────
|
||||||
|
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);
|
||||||
|
radialLayout(child, px + r * Math.cos(angle), py + r * Math.sin(angle), angle - step / 2, angle + step / 2, Math.max(r * 0.5, 1.2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootKids = children.get(rootKey)!;
|
||||||
|
radialLayout(rootKey, cx, cy, 0, 2 * Math.PI, Math.max(rootKids.length * 0.9, 3));
|
||||||
|
|
||||||
|
// ── 4. 将节点加入 graph ─────────────────────────────
|
||||||
|
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' ? '#ffd04c' : fileExtColor(name);
|
||||||
|
const size = kind === 'root' ? 14 : kind === 'dir' ? 8 : 5;
|
||||||
|
const label = fd?.title ?? name;
|
||||||
|
|
||||||
|
if (!graph.hasNode(key)) {
|
||||||
|
graph.addNode(key, { label, size, color, x: pos.x, y: pos.y, fullPath, projectPath, kind, fileId: nodeFileId.get(key) } satisfies GraphNode);
|
||||||
|
searchIndex.push({ nodeKey: key, label, fullPath, kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 添加父子边 ────────────────────────────────────
|
||||||
|
for (const [parentKey, kids] of children) {
|
||||||
|
for (const childKey of kids) {
|
||||||
|
if (graph.hasNode(parentKey) && graph.hasNode(childKey) && !graph.hasEdge(parentKey, childKey)) {
|
||||||
|
const isRoot = nodeKind.get(parentKey) === 'root';
|
||||||
|
graph.addEdge(parentKey, childKey, { size: isRoot ? 1.5 : 1, color: isRoot ? '#78716c' : '#334155' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { graph, searchIndex };
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { CodeGraphView } from './components/CodeGraph';
|
||||||
|
import { FileProjectData } from './modules/tree';
|
||||||
|
import { getFilesApi } from './modules/api/get-files';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { exampleData } from './mock/example';
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
|
||||||
|
}, []);
|
||||||
|
// 页面加载时从 API 获取文件列表
|
||||||
|
const loadData = async () => {
|
||||||
|
const res = await getFilesApi();
|
||||||
|
if (res.code === 200) {
|
||||||
|
setFiles(res.data!.list);
|
||||||
|
} else {
|
||||||
|
toast.error('获取文件列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图视图 */}
|
||||||
|
<div className='flex-1 min-h-0'>
|
||||||
|
<CodeGraphView files={files} className='h-full' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
16
src/pages/code-graph/store/index.ts
Normal file
16
src/pages/code-graph/store/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { GraphNode } from '../modules/graph';
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
codePodOpen: boolean;
|
||||||
|
setCodePodOpen: (open: boolean) => void;
|
||||||
|
codePodAttrs: GraphNode | null;
|
||||||
|
setCodePodAttrs: (attrs: GraphNode | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCodeGraphStore = create<State>((set) => ({
|
||||||
|
codePodOpen: false,
|
||||||
|
setCodePodOpen: (open) => set({ codePodOpen: open }),
|
||||||
|
codePodAttrs: null,
|
||||||
|
setCodePodAttrs: (attrs) => set({ codePodAttrs: attrs }),
|
||||||
|
}));
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
const Home = () => {
|
import App from './code-graph/page'
|
||||||
return <div>Home Page</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home;
|
export default App
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DemoRouteImport } from './routes/demo'
|
import { Route as DemoRouteImport } from './routes/demo'
|
||||||
|
import { Route as CodeGraphRouteImport } from './routes/code-graph'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -23,6 +24,11 @@ const DemoRoute = DemoRouteImport.update({
|
|||||||
path: '/demo',
|
path: '/demo',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const CodeGraphRoute = CodeGraphRouteImport.update({
|
||||||
|
id: '/code-graph',
|
||||||
|
path: '/code-graph',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -31,30 +37,34 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/code-graph': typeof CodeGraphRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/code-graph': typeof CodeGraphRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/code-graph': typeof CodeGraphRoute
|
||||||
'/demo': typeof DemoRoute
|
'/demo': typeof DemoRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/demo' | '/login'
|
fullPaths: '/' | '/code-graph' | '/demo' | '/login'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/demo' | '/login'
|
to: '/' | '/code-graph' | '/demo' | '/login'
|
||||||
id: '__root__' | '/' | '/demo' | '/login'
|
id: '__root__' | '/' | '/code-graph' | '/demo' | '/login'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
CodeGraphRoute: typeof CodeGraphRoute
|
||||||
DemoRoute: typeof DemoRoute
|
DemoRoute: typeof DemoRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
}
|
}
|
||||||
@@ -75,6 +85,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DemoRouteImport
|
preLoaderRoute: typeof DemoRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/code-graph': {
|
||||||
|
id: '/code-graph'
|
||||||
|
path: '/code-graph'
|
||||||
|
fullPath: '/code-graph'
|
||||||
|
preLoaderRoute: typeof CodeGraphRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -87,6 +104,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
CodeGraphRoute: CodeGraphRoute,
|
||||||
DemoRoute: DemoRoute,
|
DemoRoute: DemoRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/routes/code-graph.tsx
Normal file
10
src/routes/code-graph.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import CodeGraphPage from '@/pages/code-graph/page'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/code-graph')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <CodeGraphPage />
|
||||||
|
}
|
||||||
@@ -4,18 +4,20 @@ import path from 'path';
|
|||||||
import pkgs from './package.json';
|
import pkgs from './package.json';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
import 'dotenv/config';
|
import dotenv from 'dotenv';
|
||||||
|
const config = dotenv.config().parsed || {};
|
||||||
|
console.log('Loaded .env config:', config);
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const basename = isDev ? '/' : pkgs?.basename || '/';
|
const basename = isDev ? '/' : pkgs?.basename || '/';
|
||||||
|
|
||||||
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
let target = config.VITE_API_URL || 'http://localhost:51515';
|
||||||
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
let proxy = {
|
let proxy = {
|
||||||
'/root/': apiProxy,
|
'/root/': apiProxy,
|
||||||
'/api': apiProxy,
|
'/api': apiProxy,
|
||||||
'/client': apiProxy,
|
'/client': apiProxy,
|
||||||
};
|
};
|
||||||
|
console.log('API Proxy Target:', target);
|
||||||
/**
|
/**
|
||||||
* @see https://vitejs.dev/config/
|
* @see https://vitejs.dev/config/
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +41,7 @@ export default defineConfig({
|
|||||||
BASE_NAME: JSON.stringify(basename),
|
BASE_NAME: JSON.stringify(basename),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 7008,
|
port: 7009,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy,
|
proxy,
|
||||||
|
|||||||
Reference in New Issue
Block a user