新增源码绑定、代码面板、流程分组可视化与分组模式切换

- Web: 点击节点在底部代码面板展示源码(语法高亮、可折叠)
- Web: 画布按目录/文件聚类为背景框,头部新增分组模式切换(目录/文件/无)
- Web: 流程横幅、流程/分组列表与节点详情展示 summary
- CLI: 新增 overview 推导与运行器
- scripts: 手动标注 self-report summary

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
朱葛
2026-07-22 18:22:45 +08:00
parent cb23f814c7
commit 35958b408c
23 changed files with 5313 additions and 1168 deletions
+118
View File
@@ -0,0 +1,118 @@
// 人工为 self-report 的「分组(文件/类)」与「流程」写入一句话摘要,
// 写进 CodeFile.summary / Container.summary / Flow.summary,供 UI 展示。
//
// 匹配用稳定标识(文件路径 / 类名 / 流程名),不依赖会随代码改动漂移的行号,
// 因此重新生成报告后可直接重跑本脚本恢复标注。
// 用法: node scripts/annotate-self-report.mjs
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { resolve } from "node:path";
const target = resolve("web/public/self-report.json");
const mirror = resolve("self-report.json");
// 文件分组摘要(key = CodeFile.path
const fileSummaries = {
"src/scanner.ts": "递归扫描项目目录,按扩展名收集源文件并识别语言",
"src/types.ts": "标准化代码报告的核心类型定义(Schema 真相源)",
"src/externals.ts": "聚合函数交互,派生外部系统(数据库 / 服务)清单",
"src/flows.ts": "从入口点遍历调用图,自动生成调用链流程",
"src/analyzer.ts": "基于 TS 编译器 API 的解析核心:构建函数层级与调用边",
"src/cli.ts": "命令行入口:解析参数、运行分析、写出报告 JSON",
"src/overview.ts": "从报告推导「概览」确定层数据(接口 / 入口 / 外部系统 / 热点)",
"src/overview-cli.ts": "概览推导的命令行运行器,打印可读的项目地图",
"web/src/model/report.ts": "前端侧报告类型定义(与 CLI Schema 对齐)",
"web/src/model/reportRepository.ts": "报告数据源抽象,HTTP 加载报告 JSON",
"web/src/model/graphModel.ts": "将 Flow 转换为与渲染无关的「中性图」结构",
"web/src/services/dagreLayout.ts": "用 Dagre 计算图的自动布局坐标",
"web/src/model/overview.ts": "前端侧概览推导(与 CLI overview 同构)",
"web/src/services/sourceService.ts": "源码绑定:向开发服务器请求真实源码片段",
"web/src/services/languageMap.ts": "文件扩展名到语法高亮语言的映射",
"web/src/viewmodel/GraphViewModel.ts": "图视图 ViewModel:编排加载 / 选择 / 布局 / 搜索 / 源码",
"web/src/view/vmContext.ts": "提供 GraphViewModel 的 React Context",
"web/src/view/components/FlowNode.tsx": "自定义流程图节点:显示函数名 / 归属 / 交互徽标",
"web/src/view/components/FlowGraph.tsx": "React Flow 画布容器,渲染当前流程图",
"web/src/view/components/FlowList.tsx": "左侧流程列表面板",
"web/src/view/components/GroupList.tsx": "最左侧分组列表:按类 / 文件过滤流程",
"web/src/view/components/NodeDetailPanel.tsx": "右侧节点详情:位置 / 归属 / 交互 / 所在流程",
"web/src/view/components/SearchBar.tsx": "顶部搜索栏,检索流程与函数",
"web/src/view/components/OverviewPage.tsx": "概览落地页视图",
"web/src/view/components/CodePanel.tsx": "底部源码面板:语法高亮显示选中函数实现",
"web/src/view/components/FlowBanner.tsx": "画布顶部横幅:显示当前流程名与说明",
"web/src/view/App.tsx": "应用主组件:编排头部与各面板布局",
"web/src/main.tsx": "前端入口:构建数据源与 ViewModel 并挂载 App",
"web/vite.config.ts": "Vite 配置 + 开发期源码服务中间件",
};
// 类分组摘要(key = Container.name
const containerSummaries = {
HttpReportRepository: "通过 HTTP 从 URL 加载报告 JSON 的仓库实现",
GraphViewModel: "图视图 ViewModel:持有全部可观察状态与命令",
};
// 流程摘要(key = Flow.name
const flowSummaries = {
"<module> (web/src/main.tsx)": "前端启动:构建报告数据源与 ViewModel,挂载 React 应用",
"visit (src/analyzer.ts)": "AST 遍历核心:递归访问语法树,登记函数 / 容器并建立调用边",
"observer() 回调@24 (web/src/view/components/OverviewPage.tsx)": "渲染概览落地页:项目信息、接口分组、外部系统、热点",
"observer() 回调@13 (web/src/view/components/NodeDetailPanel.tsx)": "渲染右侧节点详情:位置、归属、交互、所在流程",
"observer() 回调@7 (web/src/view/components/CodePanel.tsx)": "渲染底部源码面板:随选中节点显示语法高亮源码",
"observer() 回调@11 (web/src/view/App.tsx)": "渲染应用骨架:头部、搜索、各面板布局",
"GraphViewModel.positionedGraph (web/src/viewmodel/GraphViewModel.ts)": "计算当前流程的带坐标中性图(含 Dagre 自动布局)",
"observer() 回调@16 (web/src/view/components/FlowGraph.tsx)": "把中性图适配为 React Flow 节点 / 边并渲染画布",
"observer() 回调@4 (web/src/view/components/FlowList.tsx)": "渲染流程列表并处理流程选择",
"observer() 回调@4 (web/src/view/components/GroupList.tsx)": "渲染分组列表,按类 / 文件过滤流程",
"observer() 回调@6 (web/src/view/components/SearchBar.tsx)": "渲染搜索框与结果下拉",
"匿名函数@26 (web/src/view/components/SearchBar.tsx)": "搜索输入变更处理回调",
"map() 回调@72 (src/flows.ts)": "生成流程步骤时的映射回调",
"inProject (src/analyzer.ts)": "判断文件是否属于目标项目,过滤外部依赖",
"map() 回调@229 (src/overview.ts)": "概览推导中的流程映射回调",
"map() 回调@44 (web/src/model/graphModel.ts)": "将流程步骤映射为图节点",
"map() 回调@205 (web/src/model/overview.ts)": "前端概览推导中的映射回调",
"GraphViewModel.overview (web/src/viewmodel/GraphViewModel.ts)": "从报告推导概览确定层数据的计算属性",
"GraphViewModel.groups (web/src/viewmodel/GraphViewModel.ts)": "派生分组列表的计算属性",
"GraphViewModel.visibleFlows (web/src/viewmodel/GraphViewModel.ts)": "按当前分组过滤后的流程列表",
"GraphViewModel.selectedFuncOwner (web/src/viewmodel/GraphViewModel.ts)": "计算选中函数的归属(类 / 文件)及其说明",
"reaction() 回调@79 (web/src/viewmodel/GraphViewModel.ts)": "监听选中节点变化,自动拉取对应源码",
"memo() 回调@40 (web/src/view/components/FlowNode.tsx)": "自定义节点渲染(memo 回调)",
"匿名函数@41 (web/src/view/components/SearchBar.tsx)": "搜索结果项点击处理",
"observer() 回调@4 (web/src/view/components/FlowBanner.tsx)": "渲染当前流程横幅:流程名与一句话说明",
"<module> (src/cli.ts)": "CLI 主流程:解析参数、运行分析、写出报告",
"<module> (src/overview-cli.ts)": "概览 CLI 主流程:读取报告并打印项目地图",
"<module> (web/vite.config.ts)": "Vite 配置与源码服务中间件初始化",
};
const report = JSON.parse(readFileSync(target, "utf-8"));
let f = 0;
for (const file of report.files) {
if (fileSummaries[file.path]) {
file.summary = fileSummaries[file.path];
f++;
}
}
let c = 0;
for (const cont of report.containers) {
if (containerSummaries[cont.name]) {
cont.summary = containerSummaries[cont.name];
c++;
}
}
let fl = 0;
const unmatched = [];
for (const flow of report.flows) {
if (flowSummaries[flow.name]) {
flow.summary = flowSummaries[flow.name];
flow.provenance = "manual";
fl++;
} else {
unmatched.push(flow.name);
}
}
writeFileSync(target, JSON.stringify(report, null, 2), "utf-8");
copyFileSync(target, mirror);
console.log(`已标注: 文件 ${f}/${report.files.length} · 类 ${c}/${report.containers.length} · 流程 ${fl}/${report.flows.length}`);
if (unmatched.length) console.log(`未匹配流程: ${unmatched.join(" | ")}`);
console.log(`写入: ${target}\n镜像: ${mirror}`);
+1873 -564
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
// 用法: npx tsx src/overview-cli.ts <report.json>
// 读取一份报告,推导「确定层」落地页数据并打印成可读文本地图。
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { deriveOverview } from "./overview";
import type { Report } from "./types";
function main() {
const arg = process.argv[2] ?? "sample-report.json";
const file = resolve(process.cwd(), arg);
const report = JSON.parse(readFileSync(file, "utf-8")) as Report;
const ov = deriveOverview(report);
const L: string[] = [];
const flag = (db: boolean, svc: boolean) => {
const t: string[] = [];
if (db) t.push("DB");
if (svc) t.push("外部服务");
return t.length ? " [" + t.join(" · ") + "]" : "";
};
L.push("========================================================");
L.push(" 项目概览 · " + ov.project.name);
L.push("========================================================");
L.push("技术栈 : " + ov.project.languages.join(", "));
L.push(
"规模 : " +
ov.project.fileCount +
" 文件 / " +
ov.project.functionCount +
" 函数 / " +
ov.project.edgeCount +
" 关系边" +
(ov.project.loc ? " / " + ov.project.loc + " 行" : "")
);
L.push("入口 : 共 " + ov.entrypoints.total + " 个(HTTP " + ov.entrypoints.httpCount + " · 其他 " + ov.entrypoints.others.length + "");
L.push("");
L.push("──────────── 一、对外接口(按业务域分组) ────────────");
for (const g of ov.entrypoints.httpGroups) {
L.push("");
L.push("[" + g.key + "] (" + g.endpoints.length + " 个接口)");
for (const ep of g.endpoints) {
const size = ep.flowSize != null ? " ~" + ep.flowSize + "步" : "";
L.push(" " + ep.method.padEnd(6) + ep.path + size + flag(ep.touchesDb, ep.callsService));
}
}
if (ov.entrypoints.others.length) {
L.push("");
L.push("──────────── 二、其他入口(定时/事件/内部) ────────────");
for (const o of ov.entrypoints.others) {
const size = o.flowSize != null ? " ~" + o.flowSize + "步" : "";
L.push(" " + o.funcName + " (" + (o.file ?? "") + ")" + size + flag(o.touchesDb, o.callsService));
}
}
L.push("");
L.push("──────────── 三、外部系统 ────────────");
for (const db of ov.externals.databases) {
L.push(" [库] " + db.name + (db.engine ? " (" + db.engine + ")" : "") + " · " + db.tableCount + " 张表");
L.push(" " + db.tables.join(", "));
}
for (const s of ov.externals.services) {
L.push(" [服务] " + s.name + (s.protocol ? " (" + s.protocol + ")" : "") + " · " + s.endpointCount + " 个端点");
for (const e of s.endpoints) L.push(" " + e);
}
L.push("");
L.push("──────────── 四、重点 · 被调用最多的函数 ────────────");
for (const r of ov.hotspots.mostCalled) {
L.push(" " + String(r.fanIn).padStart(3) + "× " + r.funcName + " (" + (r.file ?? "") + ")");
}
L.push("");
L.push("──────────── 五、重点 · 最大的调用链 ────────────");
for (const f of ov.hotspots.largestFlows) {
L.push(" " + String(f.size).padStart(3) + "步 " + f.name + flag(f.touchesDb, f.callsService));
}
L.push("");
process.stdout.write(L.join("\n") + "\n");
}
main();
+261
View File
@@ -0,0 +1,261 @@
// 落地页「确定层」推导:只用 report JSON 里已确定的结构,
// 不依赖任何 AI / 人工,产出一张“项目地图”。
import type { Report, Func, Flow, Edge, ExternalSystem, Interaction } from "./types";
export interface OverviewProjectInfo {
name: string;
languages: string[];
fileCount: number;
functionCount: number;
edgeCount: number;
loc?: number;
}
export interface HttpEndpoint {
method: string;
path: string;
funcId: string;
funcName: string;
file?: string;
flowId?: string;
flowSize?: number; // 该入口调用链覆盖的函数数
touchesDb: boolean; // 调用链是否触达数据库
callsService: boolean; // 调用链是否发起外部服务请求
}
export interface EndpointGroup {
key: string; // 业务域,如 auth / tasks
endpoints: HttpEndpoint[];
}
export interface OtherEntry {
funcId: string;
funcName: string;
file?: string;
flowId?: string;
flowSize?: number;
touchesDb: boolean;
callsService: boolean;
}
export interface ExternalSummary {
databases: { name: string; engine?: string; tableCount: number; tables: string[] }[];
services: { name: string; protocol?: string; endpointCount: number; endpoints: string[] }[];
}
export interface FuncRank {
funcId: string;
funcName: string;
file?: string;
fanIn: number; // 被多少处调用
}
export interface FlowRank {
flowId: string;
name: string;
size: number; // 覆盖函数数
touchesDb: boolean;
callsService: boolean;
}
export interface Overview {
project: OverviewProjectInfo;
entrypoints: {
total: number;
httpGroups: EndpointGroup[];
httpCount: number;
others: OtherEntry[];
};
externals: ExternalSummary;
hotspots: {
mostCalled: FuncRank[];
largestFlows: FlowRank[];
};
}
// ---------- 工具 ----------
function apiInOf(fn: Func): Interaction | undefined {
return (fn.interactions ?? []).find((it) => it.kind === "api" && it.direction === "in");
}
function groupKeyFromPath(path: string): string {
// /api/auth/sso-login -> auth ; /api/tasks/:id -> tasks ; /health -> health
const segs = path.split("/").filter(Boolean);
if (segs.length === 0) return "(root)";
const idx = segs[0] === "api" ? 1 : 0;
return segs[idx] ?? segs[0];
}
// 从某入口函数出发,沿调用/触发边可达的所有函数 id(有界 BFS)
function reachableFuncs(startId: string, adjacency: Map<string, string[]>, limit = 2000): Set<string> {
const seen = new Set<string>([startId]);
const queue = [startId];
while (queue.length && seen.size < limit) {
const cur = queue.shift()!;
for (const next of adjacency.get(cur) ?? []) {
if (!seen.has(next)) {
seen.add(next);
queue.push(next);
}
}
}
return seen;
}
// ---------- 主推导 ----------
export function deriveOverview(report: Report): Overview {
const funcById = new Map<string, Func>();
for (const fn of report.functions) funcById.set(fn.id, fn);
// 调用/触发邻接表(用于可达性 & fan-in
const adjacency = new Map<string, string[]>();
const fanIn = new Map<string, number>();
for (const e of report.edges as Edge[]) {
if (e.kind === "call" || e.kind === "trigger") {
if (!adjacency.has(e.sourceId)) adjacency.set(e.sourceId, []);
adjacency.get(e.sourceId)!.push(e.targetId);
fanIn.set(e.targetId, (fanIn.get(e.targetId) ?? 0) + 1);
}
}
// 入口函数 -> 对应的 call flow(用 entryNodeId 关联)
const flowByEntry = new Map<string, Flow>();
for (const fl of report.flows) {
if (fl.entryNodeId) flowByEntry.set(fl.entryNodeId, fl);
}
// 判定一个函数集合是否触达 db / 外部服务
const dbFuncIds = new Set<string>();
const svcFuncIds = new Set<string>();
for (const fn of report.functions) {
for (const it of fn.interactions ?? []) {
if (it.kind === "db") dbFuncIds.add(fn.id);
if (it.kind === "api" && it.direction === "out") svcFuncIds.add(fn.id);
}
}
const analyzeEntry = (fn: Func) => {
const flow = flowByEntry.get(fn.id);
const reach = reachableFuncs(fn.id, adjacency);
let touchesDb = false;
let callsService = false;
for (const id of reach) {
if (dbFuncIds.has(id)) touchesDb = true;
if (svcFuncIds.has(id)) callsService = true;
}
return {
flowId: flow?.id,
flowSize: flow ? flow.steps.length : reach.size,
touchesDb,
callsService,
};
};
// 分类入口
const httpEndpoints: HttpEndpoint[] = [];
const others: OtherEntry[] = [];
for (const fn of report.functions) {
if (!fn.isEntry) continue;
const api = apiInOf(fn);
const info = analyzeEntry(fn);
if (api && api.kind === "api") {
httpEndpoints.push({
method: api.method ?? "",
path: api.path ?? "",
funcId: fn.id,
funcName: fn.name,
file: fn.location?.file,
...info,
});
} else {
others.push({
funcId: fn.id,
funcName: fn.name,
file: fn.location?.file,
...info,
});
}
}
// HTTP 分组
const groupMap = new Map<string, HttpEndpoint[]>();
for (const ep of httpEndpoints) {
const key = groupKeyFromPath(ep.path);
if (!groupMap.has(key)) groupMap.set(key, []);
groupMap.get(key)!.push(ep);
}
const httpGroups: EndpointGroup[] = [...groupMap.entries()]
.map(([key, endpoints]) => ({
key,
endpoints: endpoints.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)),
}))
.sort((a, b) => b.endpoints.length - a.endpoints.length || a.key.localeCompare(b.key));
// 外部系统汇总
const externals: ExternalSummary = { databases: [], services: [] };
for (const sys of (report.externalSystems ?? []) as ExternalSystem[]) {
if (sys.kind === "database") {
externals.databases.push({
name: sys.name,
engine: sys.engine,
tableCount: sys.tables.length,
tables: sys.tables,
});
} else {
externals.services.push({
name: sys.name,
protocol: sys.protocol,
endpointCount: sys.endpoints.length,
endpoints: sys.endpoints,
});
}
}
// 热点:被调用最多的函数
const mostCalled: FuncRank[] = [...fanIn.entries()]
.map(([funcId, count]) => {
const fn = funcById.get(funcId);
return { funcId, funcName: fn?.name ?? funcId, file: fn?.location?.file, fanIn: count };
})
.filter((r) => !!funcById.get(r.funcId))
.sort((a, b) => b.fanIn - a.fanIn)
.slice(0, 10);
// 热点:最大的调用链
const largestFlows: FlowRank[] = report.flows
.map((fl) => {
const entry = fl.entryNodeId ? funcById.get(fl.entryNodeId) : undefined;
const info = entry ? analyzeEntry(entry) : { touchesDb: false, callsService: false };
return {
flowId: fl.id,
name: fl.name,
size: fl.steps.length,
touchesDb: info.touchesDb,
callsService: info.callsService,
};
})
.sort((a, b) => b.size - a.size)
.slice(0, 10);
return {
project: {
name: report.project.name,
languages: report.project.languages,
fileCount: report.stats.fileCount,
functionCount: report.stats.functionCount,
edgeCount: report.stats.edgeCount,
loc: report.stats.loc,
},
entrypoints: {
total: httpEndpoints.length + others.length,
httpGroups,
httpCount: httpEndpoints.length,
others: others.sort((a, b) => (b.flowSize ?? 0) - (a.flowSize ?? 0)),
},
externals,
hotspots: { mostCalled, largestFlows },
};
}
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代码分析可视化</title> <title>代码分析可视化</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+309 -3
View File
@@ -9,11 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.3.5", "@xyflow/react": "^12.3.5",
"mobx": "^6.13.5", "mobx": "^6.13.5",
"mobx-react-lite": "^4.0.7", "mobx-react-lite": "^4.0.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-syntax-highlighter": "^16.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -257,6 +259,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1273,11 +1284,25 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/hast": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1293,6 +1318,21 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1424,12 +1464,52 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-reference-invalid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/classcat": { "node_modules/classcat": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1441,7 +1521,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
@@ -1567,6 +1646,19 @@
} }
} }
}, },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
"license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.395", "version": "1.5.395",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
@@ -1626,6 +1718,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1644,6 +1749,14 @@
} }
} }
}, },
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1669,6 +1782,95 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-alphanumerical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^2.0.0",
"is-decimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-decimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1702,6 +1904,20 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1783,6 +1999,31 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"character-entities-legacy": "^3.0.0",
"character-reference-invalid": "^2.0.0",
"decode-named-character-reference": "^1.0.0",
"is-alphanumerical": "^2.0.0",
"is-decimal": "^2.0.0",
"is-hexadecimal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/parse-entities/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1832,6 +2073,25 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1863,6 +2123,42 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1934,6 +2230,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+3 -1
View File
@@ -10,11 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.3.5", "@xyflow/react": "^12.3.5",
"mobx": "^6.13.5", "mobx": "^6.13.5",
"mobx-react-lite": "^4.0.7", "mobx-react-lite": "^4.0.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-syntax-highlighter": "^16.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
+1873 -564
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -6,6 +6,7 @@ import { App } from "./view/App";
import { VMContext } from "./view/vmContext"; import { VMContext } from "./view/vmContext";
import { GraphViewModel } from "./viewmodel/GraphViewModel"; import { GraphViewModel } from "./viewmodel/GraphViewModel";
import { HttpReportRepository, type ReportSource } from "./model/reportRepository"; import { HttpReportRepository, type ReportSource } from "./model/reportRepository";
import { fetchSource } from "./services/sourceService";
// 可切换的报告数据源;新增项目只需往这里加一条 // 可切换的报告数据源;新增项目只需往这里加一条
const sources: ReportSource[] = [ const sources: ReportSource[] = [
@@ -13,7 +14,7 @@ const sources: ReportSource[] = [
{ id: "self", name: "code-visualize(本工具)", url: "/self-report.json" }, { id: "self", name: "code-visualize(本工具)", url: "/self-report.json" },
]; ];
const viewModel = new GraphViewModel(sources, (url) => new HttpReportRepository(url)); const viewModel = new GraphViewModel(sources, (url) => new HttpReportRepository(url), fetchSource);
void viewModel.load(); void viewModel.load();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
+60
View File
@@ -60,3 +60,63 @@ export function computeLayout(
} }
return result; return result;
} }
export interface ClusterRect extends Position {
id: string;
width: number;
height: number;
}
export interface ClusteredLayout {
positions: Map<string, Position>;
clusters: ClusterRect[];
}
/**
* 带「簇(背景大框)」的分层布局:把同组节点聚在一起,返回节点坐标与每个簇的包围矩形。
* 基于 dagre 复合图(compound graph)实现,dagre 会尽量避免不同簇交叉。
*/
export function computeClusteredLayout(
nodes: LayoutNodeInput[],
edges: LayoutEdgeInput[],
nodeToGroup: Map<string, string>,
options: LayoutOptions = {}
): ClusteredLayout {
const { direction = "LR", nodeSep = 40, rankSep = 90 } = options;
const g = new dagre.graphlib.Graph({ compound: true });
g.setGraph({ rankdir: direction, nodesep: nodeSep, ranksep: rankSep, marginx: 24, marginy: 24 });
g.setDefaultEdgeLabel(() => ({}));
const groupIds = new Set(nodeToGroup.values());
for (const gid of groupIds) {
// 簇(父节点)用带内边距的空标签,dagre 会据子节点算出包围盒
g.setNode(gid, { clusterLabelPos: "top", paddingTop: 26, paddingBottom: 16, paddingLeft: 16, paddingRight: 16 });
}
for (const n of nodes) {
g.setNode(n.id, { width: n.width, height: n.height });
const gid = nodeToGroup.get(n.id);
if (gid) g.setParent(n.id, gid);
}
for (const e of edges) {
if (e.source !== e.target) g.setEdge(e.source, e.target);
}
dagre.layout(g);
const positions = new Map<string, Position>();
for (const n of nodes) {
const node = g.node(n.id);
positions.set(n.id, node ? { x: node.x - n.width / 2, y: node.y - n.height / 2 } : { x: 0, y: 0 });
}
const clusters: ClusterRect[] = [];
for (const gid of groupIds) {
const c = g.node(gid) as { x: number; y: number; width: number; height: number } | undefined;
if (!c || !c.width) continue;
clusters.push({ id: gid, x: c.x - c.width / 2, y: c.y - c.height / 2, width: c.width, height: c.height });
}
return { positions, clusters };
}
+48
View File
@@ -0,0 +1,48 @@
// 文件扩展名 → Prism 语法高亮语言标识
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
mts: "typescript",
cts: "typescript",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
py: "python",
go: "go",
java: "java",
kt: "kotlin",
rs: "rust",
rb: "ruby",
php: "php",
cs: "csharp",
cpp: "cpp",
cc: "cpp",
c: "c",
h: "c",
hpp: "cpp",
swift: "swift",
scala: "scala",
sh: "bash",
bash: "bash",
sql: "sql",
css: "css",
scss: "scss",
less: "less",
html: "markup",
xml: "markup",
vue: "markup",
yaml: "yaml",
yml: "yaml",
md: "markdown",
};
export function languageOf(file: string | undefined): string {
if (!file) return "text";
const idx = file.lastIndexOf(".");
if (idx < 0) return "text";
const ext = file.slice(idx + 1).toLowerCase();
return EXT_TO_LANG[ext] ?? "text";
}
+32
View File
@@ -0,0 +1,32 @@
// 源码绑定:按报告里的 location(项目根 + 相对路径 + 行号区间)
// 向开发服务器请求真实源码片段。保持报告精简、内容永远与代码库一致。
export interface CodeSnippet {
file: string;
startLine: number;
endLine: number;
code: string;
}
export type LoadSource = (
root: string,
file: string,
startLine: number,
endLine: number
) => Promise<CodeSnippet>;
/** 通过 Vite 开发中间件 /__source 读取源码片段 */
export const fetchSource: LoadSource = async (root, file, startLine, endLine) => {
const params = new URLSearchParams({
root,
file,
start: String(startLine),
end: String(endLine),
});
const res = await fetch(`/__source?${params.toString()}`);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}${text ? ` · ${text}` : ""}`);
}
return (await res.json()) as CodeSnippet;
};
+265 -7
View File
@@ -1,3 +1,8 @@
:root {
--font-mono: "JetBrains Mono", "Cascadia Code", "SF Mono", ui-monospace,
"Menlo", "Consolas", monospace;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -53,6 +58,25 @@ body {
flex: 0 0 auto; flex: 0 0 auto;
} }
.app__groupmode {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #cbd5e1;
flex: 0 0 auto;
}
.app__groupmode select {
background: #1e293b;
color: #e2e8f0;
border: 1px solid #334155;
border-radius: 6px;
padding: 3px 8px;
font-size: 12.5px;
cursor: pointer;
}
/* ---------- 搜索栏 ---------- */ /* ---------- 搜索栏 ---------- */
.search { .search {
position: relative; position: relative;
@@ -171,10 +195,70 @@ body {
color: #dc2626; color: #dc2626;
} }
.app__center {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.app__canvas { .app__canvas {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
height: 100%; min-height: 0;
}
/* ---------- 当前流程横幅 ---------- */
.flowbanner {
flex: 0 0 auto;
padding: 8px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.flowbanner__title {
font-size: 13.5px;
font-weight: 700;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flowbanner__summary {
font-size: 12px;
color: #475569;
margin-top: 2px;
line-height: 1.4;
}
.flowbanner__summary--empty {
color: #cbd5e1;
font-style: italic;
}
/* ---------- 流程列表:分组说明 ---------- */
.flowlist__groupinfo {
flex: 0 0 auto;
padding: 8px 12px 10px;
background: #f8fafc;
border-bottom: 1px solid #eef2f6;
}
.flowlist__groupname {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
}
.flowlist__groupsummary {
font-size: 11.5px;
color: #64748b;
line-height: 1.45;
margin-top: 4px;
} }
/* ---------- 侧栏面板 ---------- */ /* ---------- 侧栏面板 ---------- */
@@ -191,12 +275,12 @@ body {
} }
.panel--left { .panel--left {
width: 280px; width: 420px;
border-right: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0;
} }
.panel--right { .panel--right {
width: 320px; width: 380px;
border-left: 1px solid #e2e8f0; border-left: 1px solid #e2e8f0;
} }
@@ -225,10 +309,10 @@ body {
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 9px 11px;
cursor: pointer; cursor: pointer;
font-size: 12.5px; font-size: 13.5px;
color: #475569; color: #1e293b;
} }
.flow-item:hover { .flow-item:hover {
@@ -245,6 +329,7 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500;
} }
.flow-item__count { .flow-item__count {
@@ -439,7 +524,7 @@ body {
} }
.detail__mono { .detail__mono {
font-family: Consolas, "Courier New", monospace; font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
color: #334155; color: #334155;
word-break: break-all; word-break: break-all;
@@ -452,6 +537,16 @@ body {
padding: 6px 8px; padding: 6px 8px;
} }
.detail__owner-summary {
font-size: 12px;
color: #475569;
line-height: 1.5;
background: #f8fafc;
border: 1px solid #eef2f6;
border-radius: 6px;
padding: 6px 8px;
}
.detail__ix { .detail__ix {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -545,3 +640,166 @@ body {
margin: auto; margin: auto;
color: #94a3b8; color: #94a3b8;
} }
/* ---------- 背景分组大框 ---------- */
.group-frame {
width: 100%;
height: 100%;
border: 1.5px dashed;
border-radius: 12px;
pointer-events: none;
}
.group-frame__label {
position: absolute;
top: 5px;
left: 12px;
font-size: 11.5px;
font-weight: 700;
font-family: var(--font-mono);
opacity: 0.85;
pointer-events: none;
}
/* ---------- 底部代码面板(亮色主题) ---------- */
.codepanel {
flex: 0 0 auto;
height: 300px;
min-height: 120px;
display: flex;
flex-direction: column;
border-top: 1px solid #e2e8f0;
background: #ffffff;
}
.codepanel--collapsed {
height: auto;
min-height: 0;
}
.codepanel--collapsed .codepanel__body {
display: none;
}
.codepanel__header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
cursor: pointer;
user-select: none;
}
.codepanel__header:hover {
background: #eef2f6;
}
.codepanel__toggle {
flex: 0 0 auto;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #cbd5e1;
background: #fff;
color: #334155;
font-size: 15px;
font-weight: 700;
line-height: 1;
border-radius: 5px;
}
.codepanel__header:hover .codepanel__toggle {
background: #e2e8f0;
color: #0f172a;
}
.codepanel__title {
font-weight: 700;
color: #334155;
}
.codepanel__loc {
font-family: var(--font-mono);
color: #2563eb;
}
.codepanel__lang {
font-size: 10.5px;
font-weight: 700;
color: #6d28d9;
background: #ede9fe;
border-radius: 5px;
padding: 1px 7px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.codepanel__sig {
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.codepanel__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.codepanel__empty {
padding: 16px;
color: #94a3b8;
font-size: 12.5px;
}
/* ---------- 源码视图 ---------- */
.code {
margin: 0;
background: #ffffff;
padding: 8px 0;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.55;
}
.code code {
display: block;
min-width: max-content;
}
.code__line {
display: flex;
white-space: pre;
}
.code__line:hover {
background: #f1f5f9;
}
.code__ln {
flex: 0 0 auto;
width: 46px;
padding: 0 12px 0 0;
text-align: right;
color: #cbd5e1;
user-select: none;
}
.code__text {
flex: 1 1 auto;
padding: 0 12px 0 12px;
color: #1e293b;
border-left: 1px solid #eef2f6;
}
.code__error {
color: #dc2626;
}
+19
View File
@@ -5,6 +5,8 @@ import { FlowList } from "./components/FlowList";
import { GroupList } from "./components/GroupList"; import { GroupList } from "./components/GroupList";
import { NodeDetailPanel } from "./components/NodeDetailPanel"; import { NodeDetailPanel } from "./components/NodeDetailPanel";
import { SearchBar } from "./components/SearchBar"; import { SearchBar } from "./components/SearchBar";
import { CodePanel } from "./components/CodePanel";
import { FlowBanner } from "./components/FlowBanner";
export const App = observer(function App() { export const App = observer(function App() {
const vm = useGraphVM(); const vm = useGraphVM();
@@ -27,6 +29,19 @@ export const App = observer(function App() {
</select> </select>
</div> </div>
{vm.report ? <SearchBar /> : null} {vm.report ? <SearchBar /> : null}
{vm.report ? (
<label className="app__groupmode">
<select
value={vm.groupMode}
onChange={(e) => vm.setGroupMode(e.target.value as typeof vm.groupMode)}
>
<option value="dir"></option>
<option value="file"></option>
<option value="none"></option>
</select>
</label>
) : null}
{vm.report ? ( {vm.report ? (
<div className="app__stats"> <div className="app__stats">
{vm.report.stats.fileCount} · {vm.report.stats.functionCount} · {" "} {vm.report.stats.fileCount} · {vm.report.stats.functionCount} · {" "}
@@ -42,9 +57,13 @@ export const App = observer(function App() {
<> <>
<GroupList /> <GroupList />
<FlowList /> <FlowList />
<div className="app__center">
<FlowBanner />
<main className="app__canvas"> <main className="app__canvas">
<FlowGraph /> <FlowGraph />
</main> </main>
<CodePanel />
</div>
<NodeDetailPanel /> <NodeDetailPanel />
</> </>
) : null} ) : null}
+66
View File
@@ -0,0 +1,66 @@
import { observer } from "mobx-react-lite";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useGraphVM } from "../vmContext";
import { languageOf } from "../../services/languageMap";
export const CodePanel = observer(function CodePanel() {
const vm = useGraphVM();
const fn = vm.selectedFunc;
const { loading, error, snippet } = vm.codeState;
const location = fn?.location ? `${fn.location.file}:${fn.location.startLine}` : "";
const language = languageOf(fn?.location?.file);
const collapsed = vm.codePanelCollapsed;
return (
<section className={`codepanel${collapsed ? " codepanel--collapsed" : ""}`}>
<div
className="codepanel__header"
onClick={() => vm.toggleCodePanel()}
title={collapsed ? "展开源码" : "隐藏源码"}
>
<span className="codepanel__toggle">{collapsed ? "+" : ""}</span>
<span className="codepanel__title"></span>
{fn ? <span className="codepanel__loc">{location}</span> : null}
{fn ? <span className="codepanel__lang">{language}</span> : null}
{fn?.signature ? <span className="codepanel__sig">{fn.signature}</span> : null}
</div>
<div className="codepanel__body">
{!fn ? (
<div className="codepanel__empty"></div>
) : loading ? (
<div className="codepanel__empty"></div>
) : error ? (
<div className="codepanel__empty code__error">: {error}</div>
) : !snippet || !snippet.code ? (
<div className="codepanel__empty"></div>
) : (
<SyntaxHighlighter
language={language}
style={oneLight}
showLineNumbers
startingLineNumber={snippet.startLine}
wrapLongLines={false}
customStyle={{
margin: 0,
padding: "8px 0",
background: "#ffffff",
fontSize: "12.5px",
lineHeight: "1.55",
}}
codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }}
lineNumberStyle={{
minWidth: "3em",
paddingRight: "1em",
color: "#cbd5e1",
userSelect: "none",
}}
>
{snippet.code}
</SyntaxHighlighter>
)}
</div>
</section>
);
});
+16
View File
@@ -0,0 +1,16 @@
import { observer } from "mobx-react-lite";
import { useGraphVM } from "../vmContext";
export const FlowBanner = observer(function FlowBanner() {
const vm = useGraphVM();
const flow = vm.currentFlow;
if (!flow) return null;
return (
<div className="flowbanner">
<div className="flowbanner__title" title={flow.name}>
{flow.name}
</div>
</div>
);
});
+43 -11
View File
@@ -4,32 +4,62 @@ import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
MiniMap,
MarkerType, MarkerType,
type Node, type Node,
type Edge, type Edge,
} from "@xyflow/react"; } from "@xyflow/react";
import { useGraphVM } from "../vmContext"; import { useGraphVM } from "../vmContext";
import { FlowNode, type FlowNodeData } from "./FlowNode"; import { FlowNode, type FlowNodeData } from "./FlowNode";
import { GroupFrameNode, type GroupFrameData } from "./GroupFrameNode";
const nodeTypes = { flowNode: FlowNode }; const nodeTypes = { flowNode: FlowNode, groupFrame: GroupFrameNode };
// 为分组(目录)稳定地分配一个颜色
function colorForGroup(id: string): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360;
return `hsl(${h}, 55%, 45%)`;
}
function lastSegments(dir: string, n = 2): string {
const parts = dir.split("/").filter(Boolean);
return parts.slice(-n).join("/") || dir;
}
// 文件分组时(键形如 src/cli.ts)只显示文件名,目录分组时显示末两级
function frameLabel(id: string): string {
return /\.[a-z0-9]+$/i.test(id) ? id.split("/").pop() || id : lastSegments(id);
}
export const FlowGraph = observer(function FlowGraph() { export const FlowGraph = observer(function FlowGraph() {
const vm = useGraphVM(); const vm = useGraphVM();
const { nodes, edges } = vm.positionedGraph; const { nodes, edges, clusters } = vm.positionedGraph;
const selectedNodeId = vm.selectedNodeId; const selectedNodeId = vm.selectedNodeId;
const rfNodes: Node<FlowNodeData>[] = useMemo( const rfNodes: Node[] = useMemo(() => {
() => const frames: Node<GroupFrameData>[] = clusters.map((c) => ({
nodes.map((n) => ({ id: `frame:${c.id}`,
type: "groupFrame",
position: { x: c.x, y: c.y },
data: { label: frameLabel(c.id), color: colorForGroup(c.id) },
style: { width: c.width, height: c.height },
selectable: false,
draggable: false,
focusable: false,
zIndex: 0,
}));
const flowNodes: Node<FlowNodeData>[] = nodes.map((n) => ({
id: n.id, id: n.id,
type: "flowNode", type: "flowNode",
position: { x: n.x, y: n.y }, position: { x: n.x, y: n.y },
selected: n.id === selectedNodeId, selected: n.id === selectedNodeId,
data: { label: n.label, qualifier: n.qualifier, isEntry: n.isEntry, interactions: n.interactions }, data: { label: n.label, qualifier: n.qualifier, isEntry: n.isEntry, interactions: n.interactions },
})), zIndex: 1,
[nodes, selectedNodeId] }));
);
return [...frames, ...flowNodes];
}, [nodes, clusters, selectedNodeId]);
const rfEdges: Edge[] = useMemo( const rfEdges: Edge[] = useMemo(
() => () =>
@@ -61,12 +91,14 @@ export const FlowGraph = observer(function FlowGraph() {
minZoom={0.1} minZoom={0.1}
maxZoom={2} maxZoom={2}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onNodeClick={(_, node) => vm.selectNode(node.id)} onNodeClick={(_, node) => {
if (node.id.startsWith("frame:")) return;
vm.selectNode(node.id);
}}
onPaneClick={() => vm.selectNode(null)} onPaneClick={() => vm.selectNode(null)}
> >
<Background gap={20} color="#e2e8f0" /> <Background gap={20} color="#e2e8f0" />
<Controls showInteractive={false} /> <Controls showInteractive={false} />
<MiniMap pannable zoomable nodeStrokeWidth={2} />
</ReactFlow> </ReactFlow>
); );
}); });
+15 -2
View File
@@ -4,18 +4,31 @@ import { useGraphVM } from "../vmContext";
export const FlowList = observer(function FlowList() { export const FlowList = observer(function FlowList() {
const vm = useGraphVM(); const vm = useGraphVM();
const group = vm.selectedGroup;
return ( return (
<aside className="panel panel--left"> <aside className="panel panel--left">
<div className="panel__header"> ({vm.visibleFlows.length})</div> <div className="panel__header"> ({vm.visibleFlows.length})</div>
{group ? (
<div className="flowlist__groupinfo">
<div className="flowlist__groupname">
<span className={`group-item__tag group-item__tag--${group.kind}`}>
{group.kind === "class" ? "类" : "文件"}
</span>
{group.name}
</div>
{group.summary ? <div className="flowlist__groupsummary">{group.summary}</div> : null}
</div>
) : null}
<div className="panel__body"> <div className="panel__body">
{vm.visibleFlows.map((flow) => ( {vm.visibleFlows.map((flow) => (
<button <button
key={flow.id} key={flow.id}
className={`flow-item${flow.id === vm.selectedFlowId ? " is-active" : ""}`} className={`flow-item${flow.id === vm.selectedFlowId ? " is-active" : ""}`}
onClick={() => vm.selectFlow(flow.id)} onClick={() => vm.selectFlow(flow.id)}
title={flow.name} title={flow.summary ? `${flow.name}\n${flow.summary}` : flow.name}
> >
<span className="flow-item__name">{flow.name}</span> <span className="flow-item__name">{flow.summary || flow.name}</span>
<span className="flow-item__count">{flow.steps.length}</span> <span className="flow-item__count">{flow.steps.length}</span>
</button> </button>
))} ))}
@@ -0,0 +1,20 @@
import { memo } from "react";
export interface GroupFrameData extends Record<string, unknown> {
label: string;
color: string;
}
/** 背景大框:置于底层,仅作视觉分组,不拦截交互 */
export const GroupFrameNode = memo(function GroupFrameNode({ data }: { data: GroupFrameData }) {
return (
<div
className="group-frame"
style={{ borderColor: data.color, background: `${data.color}14` }}
>
<div className="group-frame__label" style={{ color: data.color }}>
{data.label}
</div>
</div>
);
});
+1 -1
View File
@@ -21,7 +21,7 @@ export const GroupList = observer(function GroupList() {
key={g.id} key={g.id}
className={`group-item${g.id === vm.selectedGroupId ? " is-active" : ""}`} className={`group-item${g.id === vm.selectedGroupId ? " is-active" : ""}`}
onClick={() => vm.selectGroup(g.id)} onClick={() => vm.selectGroup(g.id)}
title={g.name} title={g.summary ? `${g.name}\n${g.summary}` : g.name}
> >
<span className={`group-item__tag group-item__tag--${g.kind}`}> <span className={`group-item__tag group-item__tag--${g.kind}`}>
{g.kind === "class" ? "类" : "文件"} {g.kind === "class" ? "类" : "文件"}
@@ -34,6 +34,19 @@ export const NodeDetailPanel = observer(function NodeDetailPanel() {
</div> </div>
</div> </div>
{vm.selectedFuncOwner ? (
<div className="detail__section">
<div className="detail__label">
{vm.selectedFuncOwner.kind === "class" ? "类" : "文件"} · {vm.selectedFuncOwner.name}
</div>
{vm.selectedFuncOwner.summary ? (
<div className="detail__owner-summary">{vm.selectedFuncOwner.summary}</div>
) : (
<div className="detail__muted"></div>
)}
</div>
) : null}
{fn.signature ? ( {fn.signature ? (
<div className="detail__section"> <div className="detail__section">
<div className="detail__label"></div> <div className="detail__label"></div>
+128 -8
View File
@@ -1,8 +1,16 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, reaction, runInAction } from "mobx";
import type { Report, Flow, Func, Container, CodeFile } from "../model/report"; import type { Report, Flow, Func, Container, CodeFile } from "../model/report";
import type { ReportRepository, ReportSource } from "../model/reportRepository"; import type { ReportRepository, ReportSource } from "../model/reportRepository";
import { flowToGraph, type GraphEdge, type GraphNode } from "../model/graphModel"; import { flowToGraph, type GraphEdge, type GraphNode } from "../model/graphModel";
import { computeLayout, type Position } from "../services/dagreLayout"; import { computeClusteredLayout, type ClusterRect, type Position } from "../services/dagreLayout";
import type { CodeSnippet, LoadSource } from "../services/sourceService";
export interface CodeState {
funcId: string | null;
loading: boolean;
error: string | null;
snippet: CodeSnippet | null;
}
export const NODE_WIDTH = 220; export const NODE_WIDTH = 220;
export const NODE_HEIGHT = 68; export const NODE_HEIGHT = 68;
@@ -15,6 +23,7 @@ export interface PositionedNode extends GraphNode, Position {
export interface PositionedGraph { export interface PositionedGraph {
nodes: PositionedNode[]; nodes: PositionedNode[];
edges: GraphEdge[]; edges: GraphEdge[];
clusters: ClusterRect[];
} }
export interface FlowGroup { export interface FlowGroup {
@@ -22,8 +31,11 @@ export interface FlowGroup {
name: string; name: string;
kind: "class" | "file"; kind: "class" | "file";
count: number; count: number;
summary?: string;
} }
export type GroupMode = "none" | "file" | "dir";
export interface SearchResult { export interface SearchResult {
id: string; id: string;
kind: "flow" | "func"; kind: "flow" | "func";
@@ -50,6 +62,9 @@ export class GraphViewModel {
selectedFlowId: string | null = null; selectedFlowId: string | null = null;
selectedNodeId: string | null = null; selectedNodeId: string | null = null;
searchQuery = ""; searchQuery = "";
codeState: CodeState = { funcId: null, loading: false, error: null, snippet: null };
codePanelCollapsed = false;
groupMode: GroupMode = "dir";
private funcById = new Map<string, Func>(); private funcById = new Map<string, Func>();
private containerById = new Map<string, Container>(); private containerById = new Map<string, Container>();
@@ -57,10 +72,18 @@ export class GraphViewModel {
constructor( constructor(
private readonly sourceList: ReportSource[], private readonly sourceList: ReportSource[],
private readonly makeRepository: (url: string) => ReportRepository private readonly makeRepository: (url: string) => ReportRepository,
private readonly loadSource: LoadSource
) { ) {
this.selectedSourceId = sourceList[0]?.id ?? null; this.selectedSourceId = sourceList[0]?.id ?? null;
makeAutoObservable(this, {}, { autoBind: true }); makeAutoObservable(this, {}, { autoBind: true });
// 选中节点变化时,自动拉取对应源码
reaction(
() => this.selectedFunc?.id ?? null,
() => {
void this.loadSelectedFuncCode();
}
);
} }
get sources(): ReportSource[] { get sources(): ReportSource[] {
@@ -82,16 +105,36 @@ export class GraphViewModel {
void this.load(); void this.load();
} }
toggleCodePanel(): void {
this.codePanelCollapsed = !this.codePanelCollapsed;
}
setGroupMode(mode: GroupMode): void {
this.groupMode = mode;
}
/** 计算一条流程入口所属的分组(方法→类,自由函数→文件) */ /** 计算一条流程入口所属的分组(方法→类,自由函数→文件) */
private groupOfFlow(flow: Flow): FlowGroup { private groupOfFlow(flow: Flow): FlowGroup {
const entry = flow.entryNodeId ? this.funcById.get(flow.entryNodeId) : undefined; const entry = flow.entryNodeId ? this.funcById.get(flow.entryNodeId) : undefined;
if (entry?.ownerId) { if (entry?.ownerId) {
const container = this.containerById.get(entry.ownerId); const container = this.containerById.get(entry.ownerId);
return { id: `class:${entry.ownerId}`, name: container?.name ?? "未知类", kind: "class", count: 0 }; return {
id: `class:${entry.ownerId}`,
name: container?.name ?? "未知类",
kind: "class",
count: 0,
summary: container?.summary,
};
} }
const file = entry ? this.fileById.get(entry.fileId) : undefined; const file = entry ? this.fileById.get(entry.fileId) : undefined;
if (file) { if (file) {
return { id: `file:${file.id}`, name: baseName(file.path), kind: "file", count: 0 }; return {
id: `file:${file.id}`,
name: baseName(file.path),
kind: "file",
count: 0,
summary: file.summary,
};
} }
return { id: "ungrouped", name: "未归类", kind: "file", count: 0 }; return { id: "ungrouped", name: "未归类", kind: "file", count: 0 };
} }
@@ -114,6 +157,25 @@ export class GraphViewModel {
return this.flows.filter((f) => this.groupOfFlow(f).id === this.selectedGroupId); return this.flows.filter((f) => this.groupOfFlow(f).id === this.selectedGroupId);
} }
/** 当前选中的分组(含说明),未选则为 null */
get selectedGroup(): FlowGroup | null {
if (!this.selectedGroupId) return null;
return this.groups.find((g) => g.id === this.selectedGroupId) ?? null;
}
/** 选中函数的归属(所属类或文件)及其说明 */
get selectedFuncOwner(): { name: string; kind: "class" | "file"; summary?: string } | null {
const fn = this.selectedFunc;
if (!fn) return null;
if (fn.ownerId) {
const c = this.containerById.get(fn.ownerId);
if (c) return { name: c.name, kind: "class", summary: c.summary };
}
const f = this.fileById.get(fn.fileId);
if (f) return { name: baseName(f.path), kind: "file", summary: f.summary };
return null;
}
selectGroup(groupId: string | null): void { selectGroup(groupId: string | null): void {
this.selectedGroupId = groupId; this.selectedGroupId = groupId;
const visible = this.visibleFlows; const visible = this.visibleFlows;
@@ -170,16 +232,27 @@ export class GraphViewModel {
/** 计算好坐标的中性图,供 View 适配到具体渲染库 */ /** 计算好坐标的中性图,供 View 适配到具体渲染库 */
get positionedGraph(): PositionedGraph { get positionedGraph(): PositionedGraph {
const flow = this.currentFlow; const flow = this.currentFlow;
if (!flow) return { nodes: [], edges: [] }; if (!flow) return { nodes: [], edges: [], clusters: [] };
const graph = flowToGraph(flow, { const graph = flowToGraph(flow, {
funcById: this.funcById, funcById: this.funcById,
containerById: this.containerById, containerById: this.containerById,
fileById: this.fileById, fileById: this.fileById,
}); });
const positions = computeLayout(
// 按当前分组模式把节点分组,作为背景大框
const nodeToGroup = new Map<string, string>();
if (this.groupMode !== "none") {
for (const n of graph.nodes) {
const key = n.funcId ? this.groupKeyOfFunc(n.funcId) : undefined;
if (key) nodeToGroup.set(n.id, key);
}
}
const { positions, clusters } = computeClusteredLayout(
graph.nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })), graph.nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })),
graph.edges.map((e) => ({ source: e.source, target: e.target })), graph.edges.map((e) => ({ source: e.source, target: e.target })),
nodeToGroup,
{ direction: "LR" } { direction: "LR" }
); );
@@ -188,7 +261,17 @@ export class GraphViewModel {
return { ...n, x: pos.x, y: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT }; return { ...n, x: pos.x, y: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT };
}); });
return { nodes, edges: graph.edges }; return { nodes, edges: graph.edges, clusters };
}
/** 按当前分组模式取分组键:文件用完整路径,目录用其所在目录 */
private groupKeyOfFunc(funcId: string): string | undefined {
const fn = this.funcById.get(funcId);
const file = fn?.location?.file ?? (fn ? this.fileById.get(fn.fileId)?.path : undefined);
if (!file) return undefined;
if (this.groupMode === "file") return file;
const idx = file.lastIndexOf("/");
return idx < 0 ? "(根目录)" : file.slice(0, idx);
} }
get selectedFunc(): Func | null { get selectedFunc(): Func | null {
@@ -286,4 +369,41 @@ export class GraphViewModel {
selectNode(nodeId: string | null): void { selectNode(nodeId: string | null): void {
this.selectedNodeId = nodeId; this.selectedNodeId = nodeId;
} }
// ---------- 源码绑定 ----------
async loadSelectedFuncCode(): Promise<void> {
const fn = this.selectedFunc;
const root = this.report?.project.root;
if (!fn || !fn.location || !root) {
runInAction(() => {
this.codeState = { funcId: fn?.id ?? null, loading: false, error: null, snippet: null };
});
return;
}
const funcId = fn.id;
const { file, startLine, endLine } = fn.location;
runInAction(() => {
this.codeState = { funcId, loading: true, error: null, snippet: null };
});
try {
const snippet = await this.loadSource(root, file, startLine, endLine);
runInAction(() => {
if (this.selectedFunc?.id === funcId) {
this.codeState = { funcId, loading: false, error: null, snippet };
}
});
} catch (e) {
runInAction(() => {
if (this.selectedFunc?.id === funcId) {
this.codeState = {
funcId,
loading: false,
error: e instanceof Error ? e.message : String(e),
snippet: null,
};
}
});
}
}
} }
+52 -2
View File
@@ -1,8 +1,58 @@
import { defineConfig } from "vite"; import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
import { resolve, sep } from "node:path";
/**
* 开发期源码服务:GET /__source?root=&file=&start=&end=
* 按报告里的 location 从磁盘读取真实源码片段,实现「报告 → 代码库」绑定。
* 仅用于本地开发;做了基本的路径穿越防护(结果路径必须落在 root 内)。
*/
function sourceServer(): Plugin {
return {
name: "code-visualize-source-server",
configureServer(server) {
server.middlewares.use("/__source", (req, res) => {
try {
const url = new URL(req.url ?? "", "http://localhost");
const root = url.searchParams.get("root") ?? "";
const file = url.searchParams.get("file") ?? "";
const start = Number(url.searchParams.get("start") ?? "1");
const end = Number(url.searchParams.get("end") ?? String(start));
if (!root || !file) {
res.statusCode = 400;
res.end("missing root/file");
return;
}
const rootResolved = resolve(root);
const target = resolve(rootResolved, file);
if (target !== rootResolved && !target.startsWith(rootResolved + sep)) {
res.statusCode = 403;
res.end("path escapes root");
return;
}
const content = readFileSync(target, "utf-8");
const allLines = content.split(/\r?\n/);
const from = Math.max(1, start);
const to = Math.min(allLines.length, Math.max(from, end));
const code = allLines.slice(from - 1, to).join("\n");
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ file, startLine: from, endLine: to, code }));
} catch (e) {
res.statusCode = 404;
res.end(e instanceof Error ? e.message : "read error");
}
});
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), sourceServer()],
server: { server: {
port: 5300, port: 5300,
open: true, open: true,