新增源码绑定、代码面板、流程分组可视化与分组模式切换
- Web: 点击节点在底部代码面板展示源码(语法高亮、可折叠) - Web: 画布按目录/文件聚类为背景框,头部新增分组模式切换(目录/文件/无) - Web: 流程横幅、流程/分组列表与节点详情展示 summary - CLI: 新增 overview 推导与运行器 - scripts: 手动标注 self-report summary Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}`);
|
||||
+1871
-562
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+309
-3
@@ -9,11 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xyflow/react": "^12.3.5",
|
||||
"mobx": "^6.13.5",
|
||||
"mobx-react-lite": "^4.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-syntax-highlighter": "^16.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -257,6 +259,15 @@
|
||||
"@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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1273,11 +1284,25 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1293,6 +1318,21 @@
|
||||
"@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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1424,12 +1464,52 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1441,7 +1521,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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": {
|
||||
"version": "1.5.395",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
|
||||
@@ -1626,6 +1718,19 @@
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1669,6 +1782,95 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1702,6 +1904,20 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -1783,6 +1999,31 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1832,6 +2073,25 @@
|
||||
"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": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -1863,6 +2123,42 @@
|
||||
"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": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
@@ -1934,6 +2230,16 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
+3
-1
@@ -10,11 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xyflow/react": "^12.3.5",
|
||||
"mobx": "^6.13.5",
|
||||
"mobx-react-lite": "^4.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-syntax-highlighter": "^16.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
+1871
-562
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -6,6 +6,7 @@ import { App } from "./view/App";
|
||||
import { VMContext } from "./view/vmContext";
|
||||
import { GraphViewModel } from "./viewmodel/GraphViewModel";
|
||||
import { HttpReportRepository, type ReportSource } from "./model/reportRepository";
|
||||
import { fetchSource } from "./services/sourceService";
|
||||
|
||||
// 可切换的报告数据源;新增项目只需往这里加一条
|
||||
const sources: ReportSource[] = [
|
||||
@@ -13,7 +14,7 @@ const sources: ReportSource[] = [
|
||||
{ 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();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -60,3 +60,63 @@ export function computeLayout(
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,8 @@
|
||||
:root {
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", "SF Mono", ui-monospace,
|
||||
"Menlo", "Consolas", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -53,6 +58,25 @@ body {
|
||||
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 {
|
||||
position: relative;
|
||||
@@ -171,10 +195,70 @@ body {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.app__center {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app__canvas {
|
||||
flex: 1 1 auto;
|
||||
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 {
|
||||
width: 280px;
|
||||
width: 420px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel--right {
|
||||
width: 320px;
|
||||
width: 380px;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
@@ -225,10 +309,10 @@ body {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 11px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
font-size: 13.5px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.flow-item:hover {
|
||||
@@ -245,6 +329,7 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-item__count {
|
||||
@@ -439,7 +524,7 @@ body {
|
||||
}
|
||||
|
||||
.detail__mono {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
@@ -452,6 +537,16 @@ body {
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -545,3 +640,166 @@ body {
|
||||
margin: auto;
|
||||
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;
|
||||
}
|
||||
|
||||
+22
-3
@@ -5,6 +5,8 @@ import { FlowList } from "./components/FlowList";
|
||||
import { GroupList } from "./components/GroupList";
|
||||
import { NodeDetailPanel } from "./components/NodeDetailPanel";
|
||||
import { SearchBar } from "./components/SearchBar";
|
||||
import { CodePanel } from "./components/CodePanel";
|
||||
import { FlowBanner } from "./components/FlowBanner";
|
||||
|
||||
export const App = observer(function App() {
|
||||
const vm = useGraphVM();
|
||||
@@ -27,6 +29,19 @@ export const App = observer(function App() {
|
||||
</select>
|
||||
</div>
|
||||
{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 ? (
|
||||
<div className="app__stats">
|
||||
文件 {vm.report.stats.fileCount} · 函数 {vm.report.stats.functionCount} · 边{" "}
|
||||
@@ -42,9 +57,13 @@ export const App = observer(function App() {
|
||||
<>
|
||||
<GroupList />
|
||||
<FlowList />
|
||||
<main className="app__canvas">
|
||||
<FlowGraph />
|
||||
</main>
|
||||
<div className="app__center">
|
||||
<FlowBanner />
|
||||
<main className="app__canvas">
|
||||
<FlowGraph />
|
||||
</main>
|
||||
<CodePanel />
|
||||
</div>
|
||||
<NodeDetailPanel />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -4,32 +4,62 @@ import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
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() {
|
||||
const vm = useGraphVM();
|
||||
const { nodes, edges } = vm.positionedGraph;
|
||||
const { nodes, edges, clusters } = vm.positionedGraph;
|
||||
const selectedNodeId = vm.selectedNodeId;
|
||||
|
||||
const rfNodes: Node<FlowNodeData>[] = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "flowNode",
|
||||
position: { x: n.x, y: n.y },
|
||||
selected: n.id === selectedNodeId,
|
||||
data: { label: n.label, qualifier: n.qualifier, isEntry: n.isEntry, interactions: n.interactions },
|
||||
})),
|
||||
[nodes, selectedNodeId]
|
||||
);
|
||||
const rfNodes: Node[] = useMemo(() => {
|
||||
const frames: Node<GroupFrameData>[] = clusters.map((c) => ({
|
||||
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,
|
||||
type: "flowNode",
|
||||
position: { x: n.x, y: n.y },
|
||||
selected: n.id === selectedNodeId,
|
||||
data: { label: n.label, qualifier: n.qualifier, isEntry: n.isEntry, interactions: n.interactions },
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
return [...frames, ...flowNodes];
|
||||
}, [nodes, clusters, selectedNodeId]);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
() =>
|
||||
@@ -61,12 +91,14 @@ export const FlowGraph = observer(function FlowGraph() {
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
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)}
|
||||
>
|
||||
<Background gap={20} color="#e2e8f0" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable zoomable nodeStrokeWidth={2} />
|
||||
</ReactFlow>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,18 +4,31 @@ import { useGraphVM } from "../vmContext";
|
||||
export const FlowList = observer(function FlowList() {
|
||||
const vm = useGraphVM();
|
||||
|
||||
const group = vm.selectedGroup;
|
||||
|
||||
return (
|
||||
<aside className="panel panel--left">
|
||||
<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">
|
||||
{vm.visibleFlows.map((flow) => (
|
||||
<button
|
||||
key={flow.id}
|
||||
className={`flow-item${flow.id === vm.selectedFlowId ? " is-active" : ""}`}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export const GroupList = observer(function GroupList() {
|
||||
key={g.id}
|
||||
className={`group-item${g.id === vm.selectedGroupId ? " is-active" : ""}`}
|
||||
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}`}>
|
||||
{g.kind === "class" ? "类" : "文件"}
|
||||
|
||||
@@ -34,6 +34,19 @@ export const NodeDetailPanel = observer(function NodeDetailPanel() {
|
||||
</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 ? (
|
||||
<div className="detail__section">
|
||||
<div className="detail__label">签名</div>
|
||||
|
||||
@@ -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 { ReportRepository, ReportSource } from "../model/reportRepository";
|
||||
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_HEIGHT = 68;
|
||||
@@ -15,6 +23,7 @@ export interface PositionedNode extends GraphNode, Position {
|
||||
export interface PositionedGraph {
|
||||
nodes: PositionedNode[];
|
||||
edges: GraphEdge[];
|
||||
clusters: ClusterRect[];
|
||||
}
|
||||
|
||||
export interface FlowGroup {
|
||||
@@ -22,8 +31,11 @@ export interface FlowGroup {
|
||||
name: string;
|
||||
kind: "class" | "file";
|
||||
count: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export type GroupMode = "none" | "file" | "dir";
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
kind: "flow" | "func";
|
||||
@@ -50,6 +62,9 @@ export class GraphViewModel {
|
||||
selectedFlowId: string | null = null;
|
||||
selectedNodeId: string | null = null;
|
||||
searchQuery = "";
|
||||
codeState: CodeState = { funcId: null, loading: false, error: null, snippet: null };
|
||||
codePanelCollapsed = false;
|
||||
groupMode: GroupMode = "dir";
|
||||
|
||||
private funcById = new Map<string, Func>();
|
||||
private containerById = new Map<string, Container>();
|
||||
@@ -57,10 +72,18 @@ export class GraphViewModel {
|
||||
|
||||
constructor(
|
||||
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;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
// 选中节点变化时,自动拉取对应源码
|
||||
reaction(
|
||||
() => this.selectedFunc?.id ?? null,
|
||||
() => {
|
||||
void this.loadSelectedFuncCode();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get sources(): ReportSource[] {
|
||||
@@ -82,16 +105,36 @@ export class GraphViewModel {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
toggleCodePanel(): void {
|
||||
this.codePanelCollapsed = !this.codePanelCollapsed;
|
||||
}
|
||||
|
||||
setGroupMode(mode: GroupMode): void {
|
||||
this.groupMode = mode;
|
||||
}
|
||||
|
||||
/** 计算一条流程入口所属的分组(方法→类,自由函数→文件) */
|
||||
private groupOfFlow(flow: Flow): FlowGroup {
|
||||
const entry = flow.entryNodeId ? this.funcById.get(flow.entryNodeId) : undefined;
|
||||
if (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;
|
||||
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 };
|
||||
}
|
||||
@@ -114,6 +157,25 @@ export class GraphViewModel {
|
||||
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 {
|
||||
this.selectedGroupId = groupId;
|
||||
const visible = this.visibleFlows;
|
||||
@@ -170,16 +232,27 @@ export class GraphViewModel {
|
||||
/** 计算好坐标的中性图,供 View 适配到具体渲染库 */
|
||||
get positionedGraph(): PositionedGraph {
|
||||
const flow = this.currentFlow;
|
||||
if (!flow) return { nodes: [], edges: [] };
|
||||
if (!flow) return { nodes: [], edges: [], clusters: [] };
|
||||
|
||||
const graph = flowToGraph(flow, {
|
||||
funcById: this.funcById,
|
||||
containerById: this.containerById,
|
||||
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.edges.map((e) => ({ source: e.source, target: e.target })),
|
||||
nodeToGroup,
|
||||
{ direction: "LR" }
|
||||
);
|
||||
|
||||
@@ -188,7 +261,17 @@ export class GraphViewModel {
|
||||
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 {
|
||||
@@ -286,4 +369,41 @@ export class GraphViewModel {
|
||||
selectNode(nodeId: string | null): void {
|
||||
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
@@ -1,8 +1,58 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
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({
|
||||
plugins: [react()],
|
||||
plugins: [react(), sourceServer()],
|
||||
server: {
|
||||
port: 5300,
|
||||
open: true,
|
||||
|
||||
Reference in New Issue
Block a user