初始提交:代码可视化分析工具(CLI 分析器 + Web 可视化 + 设计文档)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
# 代码分析可视化工具 · 设计文档
|
||||
|
||||
> 面向 AI 生成的「大锅饭」代码,帮助开发者快速掌握有哪些模块、模块间关系如何,颗粒度到函数级;聚焦宏观流程打通,不做具体实现的 bug 排查。以流程图为核心展示,抽象出一套标准化报告数据结构,任何项目按此结构产出报告均可在工具内展示。
|
||||
|
||||
---
|
||||
|
||||
## 一、产品概述
|
||||
- 1.1 背景与问题(AI 大锅饭代码难以掌握全貌)
|
||||
- 1.2 目标用户与典型使用场景
|
||||
- 1.3 核心价值主张
|
||||
- 1.4 范围与非目标(只讲宏观模块与流程,不做 bug 检测 / 代码质量评审)
|
||||
- 1.5 术语表
|
||||
|
||||
## 二、核心设计理念
|
||||
- 2.1 以流程图为核心的展示范式
|
||||
- 2.2 模块 → 函数 两级颗粒度
|
||||
- 2.3 报告即数据(标准化数据结构驱动展示)
|
||||
- 2.4 项目无关性(跨语言 / 跨框架 / 跨规模)
|
||||
|
||||
## 三、标准化代码报告数据结构(核心 Schema)
|
||||
|
||||
### 3.0 设计原则
|
||||
|
||||
- **聚焦原汁原味的逻辑**:核心是函数以及函数之间的调用关系,不纠结语言特性。
|
||||
- **不区分语言细节**:class / struct / interface 统一抽象成「容器 Container」,只负责持有函数与字段;不处理继承、可见性、抽象等语法概念。
|
||||
- **扁平存储 + ID 引用**:所有实体平铺存放,用 `id` 互相引用(如容器用 `functionIds` 持有函数,函数用 `ownerId` 回指容器),便于图渲染、跨模块跳转与下钻。
|
||||
- **统一边模型**:调用、导入、引用、触发、数据流等所有关系归一为一种 `Edge`,作为关系的唯一真相源;函数上不再冗余存调用列表。
|
||||
- **来源可追溯**:每个实体/边带 `provenance`,区分「AST 确定的结构」与「AI 推断的解读」(尤其业务流程)。
|
||||
|
||||
### 3.1 基础通用类型
|
||||
|
||||
```ts
|
||||
type Id = string;
|
||||
|
||||
// 实体/边的来源:ast=语法确定, resolved=引用解析, ai=模型推断, manual=人工
|
||||
type Provenance = "ast" | "resolved" | "ai" | "manual";
|
||||
|
||||
interface SourceLocation {
|
||||
file: string; // 相对仓库根的路径
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
startCol?: number;
|
||||
endCol?: number;
|
||||
}
|
||||
|
||||
// 所有实体的公共字段
|
||||
interface BaseEntity {
|
||||
id: Id;
|
||||
name: string;
|
||||
qualifiedName?: string; // 完全限定名,如 server/TaskRepo.create
|
||||
location?: SourceLocation;
|
||||
language?: string;
|
||||
provenance: Provenance;
|
||||
confidence?: number; // 0~1,启发式解析时使用
|
||||
summary?: string; // 一句话职责(可由 AI 生成)
|
||||
tags?: string[];
|
||||
extra?: Record<string, unknown>; // 场景/语言专属扩展位
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 模块 Module
|
||||
|
||||
模块 = 目录 / 包 / 逻辑分组,可嵌套成树,是宏观视图的分层单位。
|
||||
|
||||
```ts
|
||||
interface Module extends BaseEntity {
|
||||
kind: "module";
|
||||
parentId?: Id;
|
||||
childModuleIds: Id[];
|
||||
fileIds: Id[];
|
||||
path?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 文件 File
|
||||
|
||||
```ts
|
||||
interface CodeFile extends BaseEntity {
|
||||
kind: "file";
|
||||
moduleId: Id;
|
||||
path: string;
|
||||
loc?: number; // 行数
|
||||
containerIds: Id[]; // 本文件定义的容器(类/结构等)
|
||||
functionIds: Id[]; // 本文件的顶层函数
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 容器 Container(类 / 结构 等的统一抽象)
|
||||
|
||||
不区分 class / struct / interface,只作为函数与字段的持有者。
|
||||
|
||||
```ts
|
||||
interface Container extends BaseEntity {
|
||||
kind: "container";
|
||||
fileId: Id;
|
||||
moduleId?: Id;
|
||||
functionIds: Id[]; // ← 持有的函数(方法)
|
||||
fieldIds: Id[]; // ← 持有的字段/属性
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 函数 Function(核心)
|
||||
|
||||
顶层函数与容器方法统一用一个结构;是否为方法由 `ownerId` 区分。函数之间的调用关系不存在这里,而是统一走 `Edge`。
|
||||
|
||||
```ts
|
||||
interface Func extends BaseEntity {
|
||||
kind: "function";
|
||||
fileId: Id;
|
||||
ownerId?: Id; // 若为方法,指向所属容器;顶层函数留空
|
||||
moduleId?: Id;
|
||||
signature?: string; // 原始签名文本
|
||||
params: Param[];
|
||||
returnType?: string;
|
||||
isEntry?: boolean; // 是否入口点(main / 路由处理器 / 事件回调)
|
||||
interactions?: Interaction[]; // ← 该函数的跨边界交互(前后端协议 / 数据库)
|
||||
loc?: number;
|
||||
}
|
||||
|
||||
interface Param {
|
||||
name: string;
|
||||
typeRef?: string; // 声明类型(文本即可,不强求解析)
|
||||
optional?: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 跨边界交互 Interaction
|
||||
|
||||
理解宏观逻辑的关键,往往不在于「函数调了谁」,而在于它**越过了哪条系统边界**:前端发往后端的一次请求、后端打到数据库的一次读写。这类信息直接作为函数的属性挂在函数上,而非独立节点。外部系统清单(用到哪些数据库 / 外部服务)可由全部函数的 `interactions` 聚合派生,无需单独维护。
|
||||
|
||||
```ts
|
||||
type InteractionKind = "api" | "db"; // 可扩展:mq / cache / rpc ...
|
||||
|
||||
// in = 对外暴露 / 被外部调用(如接口处理器)
|
||||
// out = 本函数主动发起(如发请求 / 查库)
|
||||
type InteractionDirection = "in" | "out";
|
||||
|
||||
interface InteractionBase {
|
||||
id: Id;
|
||||
direction: InteractionDirection;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// 前后端协议交互
|
||||
interface ApiInteraction extends InteractionBase {
|
||||
kind: "api";
|
||||
protocol: "http" | "ws" | "rpc" | "graphql";
|
||||
method?: string; // GET / POST / ...(http)
|
||||
path?: string; // 如 /api/tasks/:id
|
||||
peer?: string; // 对端:前端页面 / 外部服务名(若可知)
|
||||
}
|
||||
|
||||
// 数据库交互
|
||||
interface DbInteraction extends InteractionBase {
|
||||
kind: "db";
|
||||
operation: "read" | "create" | "update" | "delete" | "other";
|
||||
target?: string; // 表 / 模型 / 集合名
|
||||
engine?: string; // postgres / mysql / mongo ...(可选)
|
||||
}
|
||||
|
||||
type Interaction = ApiInteraction | DbInteraction;
|
||||
```
|
||||
|
||||
### 3.6 字段 Field
|
||||
|
||||
```ts
|
||||
interface Field extends BaseEntity {
|
||||
kind: "field";
|
||||
ownerId: Id; // 所属容器
|
||||
typeRef?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 关系 Edge(统一边模型,关系的唯一真相源)
|
||||
|
||||
```ts
|
||||
type EdgeKind =
|
||||
| "call" // 函数调用(Call-Flow 的原子)
|
||||
| "import" // 文件/模块导入
|
||||
| "reference" // 一般引用/使用
|
||||
| "dataflow" // 数据流(可选,后期)
|
||||
| "trigger"; // 触发:路由→处理器、事件→回调
|
||||
|
||||
interface Edge {
|
||||
id: Id;
|
||||
kind: EdgeKind;
|
||||
sourceId: Id; // 起点实体 id
|
||||
targetId: Id; // 终点实体 id(未解析时指向占位节点)
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
label?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.8 流程 Flow(Call-Flow 与 Business-Flow 同结构)
|
||||
|
||||
```ts
|
||||
interface Flow {
|
||||
id: Id;
|
||||
name: string;
|
||||
flowType: "call" | "business"; // 调用链 / 业务流程
|
||||
summary?: string;
|
||||
provenance: Provenance; // call 多为 resolved,business 多为 ai/manual
|
||||
entryNodeId?: Id;
|
||||
steps: FlowStep[];
|
||||
edgeIds?: Id[]; // 关联到结构层的底层调用边,支持回溯
|
||||
}
|
||||
|
||||
interface FlowStep {
|
||||
id: Id;
|
||||
order: number;
|
||||
nodeId?: Id; // 指向某函数/容器/模块
|
||||
title: string;
|
||||
description?: string;
|
||||
branchLabel?: string; // 分支条件文字
|
||||
nextStepIds: Id[]; // 支持分支/汇合
|
||||
}
|
||||
```
|
||||
|
||||
### 3.9 外部系统 ExternalSystem(由交互派生)
|
||||
|
||||
外部系统不再作为原始录入节点,而是由聚合全部函数的 `interactions` **派生**得到,作为报告里的物化视图供展示消费:
|
||||
|
||||
- `db` 交互的 `engine` / `target` 去重 → 数据库系统及其数据表清单;
|
||||
- `out` 方向的 `api` 交互的 `peer` / `path` 去重 → 依赖的外部服务清单。
|
||||
|
||||
每个外部系统都回指与之交互的函数,保证可回溯。
|
||||
|
||||
```ts
|
||||
type ExternalSystemKind = "database" | "service";
|
||||
|
||||
interface ExternalSystemBase {
|
||||
id: Id;
|
||||
kind: ExternalSystemKind;
|
||||
name: string; // 数据库/引擎名 或 外部服务名
|
||||
functionIds: Id[]; // 与之交互的函数(回溯来源)
|
||||
interactionCount: number; // 交互次数
|
||||
provenance: Provenance; // 派生结果,通常为 resolved
|
||||
}
|
||||
|
||||
// 数据库系统
|
||||
interface DatabaseSystem extends ExternalSystemBase {
|
||||
kind: "database";
|
||||
engine?: string; // postgres / mysql / mongo ...
|
||||
tables: string[]; // 去重后的表 / 模型清单
|
||||
}
|
||||
|
||||
// 外部服务
|
||||
interface ServiceSystem extends ExternalSystemBase {
|
||||
kind: "service";
|
||||
protocol?: string; // http / ws / rpc / graphql
|
||||
endpoints: string[]; // 去重后的 path / peer 清单
|
||||
}
|
||||
|
||||
type ExternalSystem = DatabaseSystem | ServiceSystem;
|
||||
```
|
||||
|
||||
### 3.10 顶层报告 Report
|
||||
|
||||
```ts
|
||||
interface Report {
|
||||
schemaVersion: string;
|
||||
project: {
|
||||
name: string;
|
||||
root: string;
|
||||
languages: string[];
|
||||
generatedAt: string;
|
||||
generator?: string; // 生成工具 / 版本
|
||||
};
|
||||
stats: {
|
||||
fileCount: number;
|
||||
containerCount: number;
|
||||
functionCount: number;
|
||||
edgeCount: number;
|
||||
loc?: number;
|
||||
};
|
||||
modules: Module[];
|
||||
files: CodeFile[];
|
||||
containers: Container[];
|
||||
functions: Func[];
|
||||
fields: Field[];
|
||||
edges: Edge[];
|
||||
flows: Flow[];
|
||||
externalSystems?: ExternalSystem[]; // 由 interactions 派生的物化视图(可选缓存)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.11 持有关系总览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Report --> Module
|
||||
Module --> CodeFile
|
||||
CodeFile --> Container
|
||||
CodeFile --> Func
|
||||
Container -->|functionIds| Func
|
||||
Container -->|fieldIds| Field
|
||||
Func -->|interactions| Interaction
|
||||
Report -->|"call/import/..."| Edge
|
||||
Report --> Flow
|
||||
Flow -->|steps.nodeId| Func
|
||||
Edge -->|source/target| Func
|
||||
```
|
||||
|
||||
## 四、可视化展示设计
|
||||
- 4.1 整体架构图(分层视图)
|
||||
- 4.2 模块关系图(依赖 / 引用)
|
||||
- 4.3 流程图 · 核心视图(Call-Flow / Business-Flow 同画布切换)
|
||||
- 4.4 函数级调用图(下钻视图)
|
||||
- 4.5 源码目录结构树
|
||||
- 4.6 数据模型 ER 图
|
||||
- 4.7 交互能力(缩放 / 下钻 / 搜索 / 高亮 / 跳转 / 联动)
|
||||
- 4.8 多视图切换与状态同步
|
||||
|
||||
## 五、报告生成与接入方式
|
||||
- 5.1 手工编辑
|
||||
- 5.2 AI 辅助分析生成
|
||||
- 5.3 静态解析工具导入
|
||||
- 5.4 报告导入 / 导出格式
|
||||
|
||||
## 六、软件架构与技术选型
|
||||
- 6.1 整体架构
|
||||
- 6.2 前端渲染方案(图形库选型)
|
||||
- 6.3 数据加载与存储
|
||||
- 6.4 部署形态
|
||||
|
||||
## 七、界面与页面结构
|
||||
- 7.1 报告列表 / 项目切换
|
||||
- 7.2 主画布与侧边面板
|
||||
- 7.3 详情抽屉与检索
|
||||
|
||||
## 八、里程碑与迭代计划
|
||||
Generated
+570
@@ -0,0 +1,570 @@
|
||||
{
|
||||
"name": "code-visualize-cli",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "code-visualize-cli",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"bin": {
|
||||
"codeviz": "src/cli.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"tsx": "^4.19.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "code-visualize-cli",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "分析目标代码库,产出符合标准 Schema 的代码报告 JSON",
|
||||
"bin": {
|
||||
"codeviz": "src/cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"analyze": "tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.5",
|
||||
"tsx": "^4.19.1"
|
||||
}
|
||||
}
|
||||
+28907
File diff suppressed because it is too large
Load Diff
+5190
File diff suppressed because it is too large
Load Diff
+609
@@ -0,0 +1,609 @@
|
||||
import ts from "typescript";
|
||||
import path from "node:path";
|
||||
import { toPosix, collectSourceFiles, languageOf } from "./scanner";
|
||||
import type {
|
||||
Report,
|
||||
Module,
|
||||
CodeFile,
|
||||
Container,
|
||||
Func,
|
||||
Field,
|
||||
Edge,
|
||||
Interaction,
|
||||
DbInteraction,
|
||||
ApiInteraction,
|
||||
Param,
|
||||
SourceLocation,
|
||||
} from "./types";
|
||||
import { deriveExternalSystems } from "./externals";
|
||||
import { generateCallFlows } from "./flows";
|
||||
|
||||
const SCHEMA_VERSION = "0.1.0";
|
||||
|
||||
const ORM_READ = new Set(["findMany", "findFirst", "findUnique", "findUniqueOrThrow", "findFirstOrThrow", "count", "aggregate", "groupBy"]);
|
||||
const ORM_CREATE = new Set(["create", "createMany", "upsert", "createManyAndReturn"]);
|
||||
const ORM_UPDATE = new Set(["update", "updateMany"]);
|
||||
const ORM_DELETE = new Set(["delete", "deleteMany"]);
|
||||
const ORM_RAW = new Set(["$queryRaw", "$executeRaw", "$queryRawUnsafe", "$executeRawUnsafe", "$transaction"]);
|
||||
const ORM_ALL = new Set([...ORM_READ, ...ORM_CREATE, ...ORM_UPDATE, ...ORM_DELETE]);
|
||||
|
||||
const HTTP_VERBS = new Set(["get", "post", "put", "delete", "patch", "options", "head", "all"]);
|
||||
|
||||
export function analyzeProject(root: string): Report {
|
||||
const absRoot = path.resolve(root);
|
||||
const projectName = path.basename(absRoot);
|
||||
const files = collectSourceFiles(absRoot);
|
||||
|
||||
const program = ts.createProgram(files, {
|
||||
allowJs: true,
|
||||
checkJs: false,
|
||||
jsx: ts.JsxEmit.Preserve,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
allowImportingTsExtensions: true,
|
||||
esModuleInterop: true,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
|
||||
// ---------- 收集容器 ----------
|
||||
const modules = new Map<string, Module>();
|
||||
const fileEntities = new Map<string, CodeFile>(); // key: rel path
|
||||
const containers: Container[] = [];
|
||||
const functions: Func[] = [];
|
||||
const fields: Field[] = [];
|
||||
const edges = new Map<string, Edge>();
|
||||
|
||||
const funcIdByNode = new Map<ts.Node, string>();
|
||||
const funcById = new Map<string, Func>();
|
||||
const registered = new Set<ts.Node>();
|
||||
const languages = new Set<string>();
|
||||
|
||||
const relOf = (sf: ts.SourceFile): string => toPosix(path.relative(absRoot, path.resolve(sf.fileName)));
|
||||
const inProject = (sf: ts.SourceFile): boolean => {
|
||||
const rel = relOf(sf);
|
||||
return !rel.startsWith("..") && !rel.includes("node_modules") && !sf.isDeclarationFile;
|
||||
};
|
||||
|
||||
const locOf = (node: ts.Node, sf: ts.SourceFile): SourceLocation => {
|
||||
const start = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
||||
const end = sf.getLineAndCharacterOfPosition(node.getEnd());
|
||||
return {
|
||||
file: relOf(sf),
|
||||
startLine: start.line + 1,
|
||||
endLine: end.line + 1,
|
||||
startCol: start.character + 1,
|
||||
endCol: end.character + 1,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------- 模块树 ----------
|
||||
const ensureModule = (dir: string): Module => {
|
||||
const key = dir === "." ? "" : dir;
|
||||
const existing = modules.get(key);
|
||||
if (existing) return existing;
|
||||
const name = key === "" ? projectName : key.split("/").pop()!;
|
||||
const mod: Module = {
|
||||
id: `module:${key || "."}`,
|
||||
kind: "module",
|
||||
name,
|
||||
provenance: "ast",
|
||||
path: key || ".",
|
||||
childModuleIds: [],
|
||||
fileIds: [],
|
||||
};
|
||||
modules.set(key, mod);
|
||||
if (key !== "") {
|
||||
const parentDir = key.includes("/") ? key.slice(0, key.lastIndexOf("/")) : "";
|
||||
const parent = ensureModule(parentDir);
|
||||
mod.parentId = parent.id;
|
||||
if (!parent.childModuleIds.includes(mod.id)) parent.childModuleIds.push(mod.id);
|
||||
}
|
||||
return mod;
|
||||
};
|
||||
|
||||
// ---------- 参数/签名 ----------
|
||||
const getParams = (fn: ts.SignatureDeclarationBase, sf: ts.SourceFile): Param[] => {
|
||||
return fn.parameters.map((p) => ({
|
||||
name: p.name.getText(sf),
|
||||
typeRef: p.type?.getText(sf),
|
||||
optional: !!p.questionToken || !!p.initializer,
|
||||
defaultValue: p.initializer?.getText(sf),
|
||||
}));
|
||||
};
|
||||
|
||||
const buildSignature = (name: string, params: Param[], ret?: string): string => {
|
||||
const ps = params.map((p) => (p.typeRef ? `${p.name}: ${p.typeRef}` : p.name)).join(", ");
|
||||
return `${name}(${ps})${ret ? `: ${ret}` : ""}`;
|
||||
};
|
||||
|
||||
const registerFunc = (
|
||||
declNode: ts.Node,
|
||||
fnNode: ts.SignatureDeclarationBase & ts.Node,
|
||||
name: string,
|
||||
sf: ts.SourceFile,
|
||||
rel: string,
|
||||
fileEntity: CodeFile,
|
||||
ownerId?: string,
|
||||
qualifiedPrefix?: string
|
||||
): Func => {
|
||||
if (registered.has(declNode)) return funcById.get(funcIdByNode.get(declNode)!)!;
|
||||
registered.add(declNode);
|
||||
const loc = locOf(declNode, sf);
|
||||
const params = getParams(fnNode, sf);
|
||||
const returnType = (fnNode as ts.FunctionLikeDeclaration).type?.getText(sf);
|
||||
const qualified = qualifiedPrefix ? `${qualifiedPrefix}.${name}` : name;
|
||||
const id = `func:${rel}#${qualified}@${loc.startLine}`;
|
||||
const isEntry = /(^|\/)(server|main|index|app)\.[jt]sx?$/.test(rel) && !ownerId;
|
||||
const fn: Func = {
|
||||
id,
|
||||
kind: "function",
|
||||
name,
|
||||
qualifiedName: qualified,
|
||||
location: loc,
|
||||
language: languageOf(rel),
|
||||
provenance: "ast",
|
||||
fileId: fileEntity.id,
|
||||
ownerId,
|
||||
moduleId: fileEntity.moduleId,
|
||||
signature: buildSignature(name, params, returnType),
|
||||
params,
|
||||
returnType,
|
||||
isEntry: isEntry || undefined,
|
||||
loc: loc.endLine - loc.startLine + 1,
|
||||
};
|
||||
functions.push(fn);
|
||||
funcById.set(id, fn);
|
||||
funcIdByNode.set(declNode, id);
|
||||
if (ownerId) {
|
||||
const c = containers.find((x) => x.id === ownerId);
|
||||
c?.functionIds.push(id);
|
||||
} else {
|
||||
fileEntity.functionIds.push(id);
|
||||
}
|
||||
return fn;
|
||||
};
|
||||
|
||||
const isFnInit = (node?: ts.Node): node is ts.ArrowFunction | ts.FunctionExpression =>
|
||||
!!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
||||
|
||||
// 判断函数体内是否存在调用(用于筛选「有意义的」内联回调,跳过琐碎箭头)
|
||||
const containsCall = (n: ts.Node): boolean => {
|
||||
let found = false;
|
||||
const scan = (x: ts.Node): void => {
|
||||
if (found) return;
|
||||
if (ts.isCallExpression(x) || ts.isNewExpression(x)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(x, scan);
|
||||
};
|
||||
ts.forEachChild(n, scan);
|
||||
return found;
|
||||
};
|
||||
|
||||
// 若某个调用是路由注册(app/router.verb('/path', ...)),返回形如 "GET /path" 的名字
|
||||
const routeNameFromCall = (call: ts.CallExpression, sf: ts.SourceFile): string | undefined => {
|
||||
const e = call.expression;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(e) &&
|
||||
HTTP_VERBS.has(e.name.text) &&
|
||||
/app|router|server|route/i.test(e.expression.getText(sf))
|
||||
) {
|
||||
const p = extractPathArg(call.arguments[0], sf);
|
||||
if (p !== undefined) return `${e.name.text.toUpperCase()} ${p}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 为内联匿名函数按上下文取名:路由 handler > 回调所属调用 > JSX 属性 > 匿名
|
||||
const inlineName = (fn: ts.ArrowFunction | ts.FunctionExpression, sf: ts.SourceFile): string => {
|
||||
const line = locOf(fn, sf).startLine;
|
||||
const parent = fn.parent;
|
||||
if (parent && ts.isCallExpression(parent)) {
|
||||
const routeName = routeNameFromCall(parent, sf);
|
||||
if (routeName) return routeName;
|
||||
const callee = parent.expression;
|
||||
const cn = ts.isPropertyAccessExpression(callee)
|
||||
? callee.name.text
|
||||
: ts.isIdentifier(callee)
|
||||
? callee.text
|
||||
: "fn";
|
||||
return `${cn}() 回调@${line}`;
|
||||
}
|
||||
if (parent && ts.isJsxAttribute(parent)) {
|
||||
return `${parent.name.getText(sf)} 处理@${line}`;
|
||||
}
|
||||
return `匿名函数@${line}`;
|
||||
};
|
||||
|
||||
// ---------- 注册 pass ----------
|
||||
const registerFile = (sf: ts.SourceFile): void => {
|
||||
const rel = relOf(sf);
|
||||
languages.add(languageOf(rel));
|
||||
const dir = toPosix(path.dirname(rel));
|
||||
const mod = ensureModule(dir === "." ? "" : dir);
|
||||
const lineCount = sf.getLineAndCharacterOfPosition(sf.getEnd()).line + 1;
|
||||
const fileEntity: CodeFile = {
|
||||
id: `file:${rel}`,
|
||||
kind: "file",
|
||||
name: toPosix(path.basename(rel)),
|
||||
location: { file: rel, startLine: 1, endLine: lineCount },
|
||||
language: languageOf(rel),
|
||||
provenance: "ast",
|
||||
moduleId: mod.id,
|
||||
path: rel,
|
||||
loc: lineCount,
|
||||
containerIds: [],
|
||||
functionIds: [],
|
||||
};
|
||||
fileEntities.set(rel, fileEntity);
|
||||
if (!mod.fileIds.includes(fileEntity.id)) mod.fileIds.push(fileEntity.id);
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
// 类 / 类似容器
|
||||
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
|
||||
const cname = node.name?.text ?? "<anonymous class>";
|
||||
const loc = locOf(node, sf);
|
||||
const container: Container = {
|
||||
id: `container:${rel}#${cname}@${loc.startLine}`,
|
||||
kind: "container",
|
||||
name: cname,
|
||||
qualifiedName: cname,
|
||||
location: loc,
|
||||
language: languageOf(rel),
|
||||
provenance: "ast",
|
||||
fileId: fileEntity.id,
|
||||
moduleId: mod.id,
|
||||
functionIds: [],
|
||||
fieldIds: [],
|
||||
};
|
||||
containers.push(container);
|
||||
fileEntity.containerIds.push(container.id);
|
||||
for (const member of node.members) {
|
||||
if (
|
||||
ts.isMethodDeclaration(member) ||
|
||||
ts.isConstructorDeclaration(member) ||
|
||||
ts.isGetAccessor(member) ||
|
||||
ts.isSetAccessor(member)
|
||||
) {
|
||||
const mname = ts.isConstructorDeclaration(member) ? "constructor" : member.name?.getText(sf) ?? "<anon>";
|
||||
registerFunc(member, member, mname, sf, rel, fileEntity, container.id, cname);
|
||||
} else if (ts.isPropertyDeclaration(member)) {
|
||||
registered.add(member);
|
||||
if (isFnInit(member.initializer)) {
|
||||
registered.add(member.initializer);
|
||||
const mname = member.name.getText(sf);
|
||||
registerFunc(member, member.initializer, mname, sf, rel, fileEntity, container.id, cname);
|
||||
} else {
|
||||
const loc2 = locOf(member, sf);
|
||||
const fname = member.name.getText(sf);
|
||||
const field: Field = {
|
||||
id: `field:${rel}#${cname}.${fname}@${loc2.startLine}`,
|
||||
kind: "field",
|
||||
name: fname,
|
||||
qualifiedName: `${cname}.${fname}`,
|
||||
location: loc2,
|
||||
language: languageOf(rel),
|
||||
provenance: "ast",
|
||||
ownerId: container.id,
|
||||
typeRef: member.type?.getText(sf),
|
||||
};
|
||||
fields.push(field);
|
||||
container.fieldIds.push(field.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isFunctionDeclaration(node) && node.name && !registered.has(node)) {
|
||||
registerFunc(node, node, node.name.text, sf, rel, fileEntity);
|
||||
} else if (ts.isVariableDeclaration(node) && isFnInit(node.initializer) && ts.isIdentifier(node.name)) {
|
||||
registered.add(node.initializer);
|
||||
registerFunc(node, node.initializer, node.name.text, sf, rel, fileEntity);
|
||||
} else if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
isFnInit(node.initializer) &&
|
||||
(ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
|
||||
!registered.has(node)
|
||||
) {
|
||||
registered.add(node.initializer);
|
||||
registerFunc(node, node.initializer, node.name.getText(sf), sf, rel, fileEntity);
|
||||
} else if (
|
||||
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
||||
!registered.has(node) &&
|
||||
ts.isBlock(node.body) &&
|
||||
containsCall(node.body)
|
||||
) {
|
||||
// 有意义的内联匿名回调(含调用的块体):登记为节点,避免调用被拍平到 <module>
|
||||
registerFunc(node, node, inlineName(node, sf), sf, rel, fileEntity);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
ts.forEachChild(sf, visit);
|
||||
};
|
||||
|
||||
// ---------- 交互识别 ----------
|
||||
const extractPathArg = (node: ts.Expression | undefined, sf: ts.SourceFile): string | undefined => {
|
||||
if (!node) return undefined;
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
if (ts.isTemplateExpression(node)) return node.getText(sf);
|
||||
if (ts.isCallExpression(node)) return extractPathArg(node.arguments[0], sf); // apiPath('/x')
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractMethodFromOptions = (arg: ts.Expression | undefined, sf: ts.SourceFile): string | undefined => {
|
||||
if (!arg || !ts.isObjectLiteralExpression(arg)) return undefined;
|
||||
for (const p of arg.properties) {
|
||||
if (ts.isPropertyAssignment(p) && p.name.getText(sf) === "method") {
|
||||
const v = p.initializer;
|
||||
if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) return v.text.toUpperCase();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const hostOf = (url?: string): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
const m = /^https?:\/\/([^/]+)/i.exec(url);
|
||||
return m ? m[1] : undefined;
|
||||
};
|
||||
|
||||
const detectDb = (call: ts.CallExpression, sf: ts.SourceFile): DbInteraction | undefined => {
|
||||
const expr = call.expression;
|
||||
if (!ts.isPropertyAccessExpression(expr)) return undefined;
|
||||
const method = expr.name.text;
|
||||
if (ORM_RAW.has(method)) {
|
||||
const baseText = expr.expression.getText(sf);
|
||||
if (!/prisma|db|client/i.test(baseText)) return undefined;
|
||||
return { id: "", kind: "db", direction: "out", provenance: "resolved", operation: "other", engine: "prisma" };
|
||||
}
|
||||
if (!ORM_ALL.has(method)) return undefined;
|
||||
// 形如 prisma.<model>.<op>()
|
||||
const modelAccess = expr.expression;
|
||||
if (!ts.isPropertyAccessExpression(modelAccess)) return undefined;
|
||||
const baseText = modelAccess.expression.getText(sf);
|
||||
if (!/prisma|db|client|repository|repo/i.test(baseText)) return undefined;
|
||||
const target = modelAccess.name.text;
|
||||
let operation: DbInteraction["operation"] = "other";
|
||||
if (ORM_READ.has(method)) operation = "read";
|
||||
else if (ORM_CREATE.has(method)) operation = "create";
|
||||
else if (ORM_UPDATE.has(method)) operation = "update";
|
||||
else if (ORM_DELETE.has(method)) operation = "delete";
|
||||
return { id: "", kind: "db", direction: "out", provenance: "resolved", operation, target, engine: "prisma" };
|
||||
};
|
||||
|
||||
const detectApiOut = (call: ts.CallExpression, sf: ts.SourceFile): ApiInteraction | undefined => {
|
||||
const expr = call.expression;
|
||||
// fetch(url, opts)
|
||||
if (ts.isIdentifier(expr) && expr.text === "fetch") {
|
||||
const url = extractPathArg(call.arguments[0], sf);
|
||||
const method = extractMethodFromOptions(call.arguments[1], sf) ?? "GET";
|
||||
return { id: "", kind: "api", direction: "out", provenance: "resolved", protocol: "http", method, path: url, peer: hostOf(url) };
|
||||
}
|
||||
// axios.get(url) / http.post(url)
|
||||
if (ts.isPropertyAccessExpression(expr) && HTTP_VERBS.has(expr.name.text)) {
|
||||
const baseText = expr.expression.getText(sf);
|
||||
if (!/axios|http|client|request|api/i.test(baseText)) return undefined;
|
||||
const url = extractPathArg(call.arguments[0], sf);
|
||||
return { id: "", kind: "api", direction: "out", provenance: "resolved", protocol: "http", method: expr.name.text.toUpperCase(), path: url, peer: hostOf(url) };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const detectApiIn = (
|
||||
call: ts.CallExpression,
|
||||
sf: ts.SourceFile
|
||||
): { interaction: ApiInteraction; handler?: ts.Node } | undefined => {
|
||||
const expr = call.expression;
|
||||
if (!ts.isPropertyAccessExpression(expr)) return undefined;
|
||||
const verb = expr.name.text;
|
||||
if (!HTTP_VERBS.has(verb)) return undefined;
|
||||
const baseText = expr.expression.getText(sf);
|
||||
if (!/app|router|server|route/i.test(baseText)) return undefined;
|
||||
const p = extractPathArg(call.arguments[0], sf);
|
||||
if (p === undefined) return undefined;
|
||||
const handler = call.arguments[call.arguments.length - 1];
|
||||
return {
|
||||
interaction: { id: "", kind: "api", direction: "in", provenance: "resolved", protocol: "http", method: verb.toUpperCase(), path: p },
|
||||
handler,
|
||||
};
|
||||
};
|
||||
|
||||
const addInteraction = (fnId: string, ix: Interaction): void => {
|
||||
const fn = funcById.get(fnId);
|
||||
if (!fn) return;
|
||||
if (!fn.interactions) fn.interactions = [];
|
||||
const key =
|
||||
ix.kind === "db"
|
||||
? `db:${ix.direction}:${(ix as DbInteraction).operation}:${(ix as DbInteraction).target ?? ""}`
|
||||
: `api:${ix.direction}:${(ix as ApiInteraction).method ?? ""}:${(ix as ApiInteraction).path ?? ""}`;
|
||||
ix.id = `ix:${fnId}:${key}`;
|
||||
if (fn.interactions.some((x) => x.id === ix.id)) return;
|
||||
fn.interactions.push(ix);
|
||||
};
|
||||
|
||||
// ---------- 关系 pass ----------
|
||||
const addEdge = (kind: Edge["kind"], sourceId: string, targetId: string, provenance: Edge["provenance"]): void => {
|
||||
if (!sourceId || !targetId || sourceId === targetId) return;
|
||||
const id = `edge:${kind}:${sourceId}=>${targetId}`;
|
||||
if (edges.has(id)) return;
|
||||
edges.set(id, { id, kind, sourceId, targetId, provenance });
|
||||
};
|
||||
|
||||
const resolveCallTargetFuncId = (call: ts.CallExpression): string | undefined => {
|
||||
const expr = call.expression;
|
||||
let sym = checker.getSymbolAtLocation(expr);
|
||||
if (!sym && ts.isPropertyAccessExpression(expr)) sym = checker.getSymbolAtLocation(expr.name);
|
||||
if (!sym) return undefined;
|
||||
if (sym.flags & ts.SymbolFlags.Alias) {
|
||||
try {
|
||||
sym = checker.getAliasedSymbol(sym);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const decls = sym.getDeclarations();
|
||||
if (!decls) return undefined;
|
||||
for (const d of decls) {
|
||||
const found = funcIdByNode.get(d);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const moduleFuncCache = new Map<string, string>();
|
||||
const getModuleFunc = (sf: ts.SourceFile, rel: string, fileEntity: CodeFile): string => {
|
||||
const cached = moduleFuncCache.get(rel);
|
||||
if (cached) return cached;
|
||||
const id = `func:${rel}#<module>@0`;
|
||||
const fn: Func = {
|
||||
id,
|
||||
kind: "function",
|
||||
name: "<module>",
|
||||
qualifiedName: "<module>",
|
||||
location: { file: rel, startLine: 1, endLine: fileEntity.loc ?? 1 },
|
||||
language: languageOf(rel),
|
||||
provenance: "ast",
|
||||
fileId: fileEntity.id,
|
||||
moduleId: fileEntity.moduleId,
|
||||
params: [],
|
||||
isEntry: /(^|\/)(server|main|index|app)\.[jt]sx?$/.test(rel) || undefined,
|
||||
summary: "模块顶层代码(副作用/初始化)",
|
||||
};
|
||||
functions.push(fn);
|
||||
funcById.set(id, fn);
|
||||
fileEntity.functionIds.push(id);
|
||||
moduleFuncCache.set(rel, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const analyzeRelations = (sf: ts.SourceFile): void => {
|
||||
const rel = relOf(sf);
|
||||
const fileEntity = fileEntities.get(rel);
|
||||
if (!fileEntity) return;
|
||||
|
||||
const walk = (node: ts.Node, currentFuncId: string | undefined): void => {
|
||||
let cur = currentFuncId;
|
||||
const mapped = funcIdByNode.get(node);
|
||||
if (mapped) cur = mapped;
|
||||
|
||||
// import 边(内部文件间)
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const targetRel = resolveInternalImport(rel, node.moduleSpecifier.text);
|
||||
if (targetRel && fileEntities.has(targetRel)) {
|
||||
addEdge("import", fileEntity.id, `file:${targetRel}`, "ast");
|
||||
}
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node)) {
|
||||
const owner = cur ?? getModuleFunc(sf, rel, fileEntity);
|
||||
|
||||
// 调用边
|
||||
const targetFn = resolveCallTargetFuncId(node);
|
||||
if (targetFn) addEdge("call", owner, targetFn, "resolved");
|
||||
|
||||
// 交互
|
||||
const db = detectDb(node, sf);
|
||||
if (db) addInteraction(owner, db);
|
||||
const apiOut = detectApiOut(node, sf);
|
||||
if (apiOut) addInteraction(owner, apiOut);
|
||||
const apiIn = detectApiIn(node, sf);
|
||||
if (apiIn) {
|
||||
let handlerFn: string | undefined;
|
||||
const h = apiIn.handler;
|
||||
if (h) {
|
||||
if (funcIdByNode.has(h)) {
|
||||
handlerFn = funcIdByNode.get(h); // 内联 handler 已在 pass1 登记
|
||||
} else if (ts.isIdentifier(h)) {
|
||||
const sym = checker.getSymbolAtLocation(h);
|
||||
for (const d of sym?.getDeclarations() ?? []) {
|
||||
const f = funcIdByNode.get(d);
|
||||
if (f) {
|
||||
handlerFn = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = handlerFn ?? owner;
|
||||
addInteraction(target, apiIn.interaction);
|
||||
// 注册作用域 → handler 的触发边,保持连通性
|
||||
if (handlerFn && handlerFn !== owner) addEdge("trigger", owner, handlerFn, "resolved");
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, (c) => walk(c, cur));
|
||||
};
|
||||
|
||||
ts.forEachChild(sf, (n) => walk(n, undefined));
|
||||
};
|
||||
|
||||
const resolveInternalImport = (fromRel: string, spec: string): string | undefined => {
|
||||
if (!spec.startsWith(".")) return undefined;
|
||||
const fromDir = toPosix(path.dirname(fromRel));
|
||||
const joined = toPosix(path.normalize(path.join(fromDir, spec)));
|
||||
const candidates = [
|
||||
joined,
|
||||
`${joined}.ts`,
|
||||
`${joined}.tsx`,
|
||||
`${joined}.js`,
|
||||
`${joined}.jsx`,
|
||||
`${joined}/index.ts`,
|
||||
`${joined}/index.tsx`,
|
||||
`${joined}/index.js`,
|
||||
];
|
||||
// 处理带扩展名的显式导入(如 ./db.js -> ./db.ts)
|
||||
const stripped = joined.replace(/\.(js|jsx|ts|tsx)$/, "");
|
||||
candidates.push(`${stripped}.ts`, `${stripped}.tsx`, `${stripped}.js`, `${stripped}.jsx`);
|
||||
for (const c of candidates) {
|
||||
if (fileEntities.has(c)) return c;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// ---------- 执行 ----------
|
||||
const projectSourceFiles = program.getSourceFiles().filter(inProject);
|
||||
for (const sf of projectSourceFiles) registerFile(sf);
|
||||
for (const sf of projectSourceFiles) analyzeRelations(sf);
|
||||
|
||||
ensureModule(""); // 保证根模块存在
|
||||
|
||||
const totalLoc = [...fileEntities.values()].reduce((s, f) => s + (f.loc ?? 0), 0);
|
||||
const edgeList = [...edges.values()];
|
||||
const flows = generateCallFlows(functions, edgeList);
|
||||
|
||||
const report: Report = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
project: {
|
||||
name: projectName,
|
||||
root: toPosix(absRoot),
|
||||
languages: [...languages].sort(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
generator: "code-visualize-cli@" + SCHEMA_VERSION,
|
||||
},
|
||||
stats: {
|
||||
fileCount: fileEntities.size,
|
||||
containerCount: containers.length,
|
||||
functionCount: functions.length,
|
||||
edgeCount: edgeList.length,
|
||||
loc: totalLoc,
|
||||
},
|
||||
modules: [...modules.values()],
|
||||
files: [...fileEntities.values()],
|
||||
containers,
|
||||
functions,
|
||||
fields,
|
||||
edges: edgeList,
|
||||
flows,
|
||||
externalSystems: deriveExternalSystems(functions),
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { analyzeProject } from "./analyzer";
|
||||
|
||||
interface CliArgs {
|
||||
projectDir?: string;
|
||||
output?: string;
|
||||
pretty: boolean;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { pretty: true, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "-h" || a === "--help") args.help = true;
|
||||
else if (a === "-o" || a === "--output") args.output = argv[++i];
|
||||
else if (a === "--minify") args.pretty = false;
|
||||
else if (!a.startsWith("-") && !args.projectDir) args.projectDir = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const HELP = `代码分析 CLI — 产出标准化代码报告 JSON
|
||||
|
||||
用法:
|
||||
codeviz <项目目录> [选项]
|
||||
|
||||
选项:
|
||||
-o, --output <文件> 输出 JSON 路径(默认 <项目目录>/code-report.json)
|
||||
--minify 输出压缩 JSON(默认美化)
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
npm run analyze -- E:/task-flow-manager
|
||||
npm run analyze -- E:/task-flow-manager -o report.json
|
||||
`;
|
||||
|
||||
function main(): void {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help || !args.projectDir) {
|
||||
console.log(HELP);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const projectDir = path.resolve(args.projectDir!);
|
||||
if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
|
||||
console.error(`错误: 目录不存在或不是文件夹 -> ${projectDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outPath = path.resolve(args.output ?? path.join(projectDir, "code-report.json"));
|
||||
|
||||
console.error(`正在分析: ${projectDir}`);
|
||||
const started = Date.now();
|
||||
const report = analyzeProject(projectDir);
|
||||
const json = JSON.stringify(report, null, args.pretty ? 2 : 0);
|
||||
fs.writeFileSync(outPath, json, "utf8");
|
||||
const ms = Date.now() - started;
|
||||
|
||||
console.error(
|
||||
`完成 (${ms}ms): 文件 ${report.stats.fileCount} · 容器 ${report.stats.containerCount} · 函数 ${report.stats.functionCount} · 边 ${report.stats.edgeCount} · 流程 ${report.flows.length}`
|
||||
);
|
||||
console.error(`外部系统: ${(report.externalSystems ?? []).map((e) => e.name).join(", ") || "无"}`);
|
||||
console.error(`报告已写入: ${outPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Func, ExternalSystem, DatabaseSystem, ServiceSystem, ApiInteraction, DbInteraction } from "./types";
|
||||
|
||||
/** 由所有函数的 interactions 聚合派生出外部系统清单 */
|
||||
export function deriveExternalSystems(functions: Func[]): ExternalSystem[] {
|
||||
const databases = new Map<string, DatabaseSystem>();
|
||||
const services = new Map<string, ServiceSystem>();
|
||||
|
||||
for (const fn of functions) {
|
||||
for (const ix of fn.interactions ?? []) {
|
||||
if (ix.kind === "db") {
|
||||
const db = ix as DbInteraction;
|
||||
const engine = db.engine ?? "database";
|
||||
const key = engine;
|
||||
let sys = databases.get(key);
|
||||
if (!sys) {
|
||||
sys = {
|
||||
id: `ext:db:${engine}`,
|
||||
kind: "database",
|
||||
name: engine,
|
||||
engine,
|
||||
tables: [],
|
||||
functionIds: [],
|
||||
interactionCount: 0,
|
||||
provenance: "resolved",
|
||||
};
|
||||
databases.set(key, sys);
|
||||
}
|
||||
sys.interactionCount += 1;
|
||||
if (!sys.functionIds.includes(fn.id)) sys.functionIds.push(fn.id);
|
||||
if (db.target && !sys.tables.includes(db.target)) sys.tables.push(db.target);
|
||||
} else if (ix.kind === "api" && ix.direction === "out") {
|
||||
const api = ix as ApiInteraction;
|
||||
// 仅绝对 URL(有 peer 主机)算外部服务;相对路径是前后端内部协议
|
||||
if (!api.peer) continue;
|
||||
let sys = services.get(api.peer);
|
||||
if (!sys) {
|
||||
sys = {
|
||||
id: `ext:service:${api.peer}`,
|
||||
kind: "service",
|
||||
name: api.peer,
|
||||
protocol: api.protocol,
|
||||
endpoints: [],
|
||||
functionIds: [],
|
||||
interactionCount: 0,
|
||||
provenance: "resolved",
|
||||
};
|
||||
services.set(api.peer, sys);
|
||||
}
|
||||
sys.interactionCount += 1;
|
||||
if (!sys.functionIds.includes(fn.id)) sys.functionIds.push(fn.id);
|
||||
if (api.path && !sys.endpoints.includes(api.path)) sys.endpoints.push(api.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of databases.values()) s.tables.sort();
|
||||
for (const s of services.values()) s.endpoints.sort();
|
||||
|
||||
return [...databases.values(), ...services.values()];
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import type { Func, Edge, Flow, FlowStep } from "./types";
|
||||
|
||||
export interface FlowOptions {
|
||||
maxNodes: number; // 单条流程最多包含的函数数
|
||||
maxDepth: number; // 调用链最大深度
|
||||
maxFlows: number; // 最多生成的流程数
|
||||
}
|
||||
|
||||
const DEFAULTS: FlowOptions = { maxNodes: 60, maxDepth: 12, maxFlows: 80 };
|
||||
|
||||
/**
|
||||
* 从调用边自动生成 Call-Flow:
|
||||
* 以「入口函数」为根,沿调用边做有界 BFS,得到一条以函数为步骤的调用链流程。
|
||||
* 入口 = isEntry 标记,或调用入度为 0 且有出边的函数(天然的顶层起点)。
|
||||
*/
|
||||
export function generateCallFlows(functions: Func[], edges: Edge[], options?: Partial<FlowOptions>): Flow[] {
|
||||
const opts = { ...DEFAULTS, ...options };
|
||||
const callEdges = edges.filter((e) => e.kind === "call");
|
||||
|
||||
const funcById = new Map(functions.map((f) => [f.id, f] as const));
|
||||
const outAdj = new Map<string, string[]>();
|
||||
const inDeg = new Map<string, number>();
|
||||
for (const f of functions) {
|
||||
outAdj.set(f.id, []);
|
||||
inDeg.set(f.id, 0);
|
||||
}
|
||||
for (const e of callEdges) {
|
||||
if (!outAdj.has(e.sourceId)) outAdj.set(e.sourceId, []);
|
||||
outAdj.get(e.sourceId)!.push(e.targetId);
|
||||
inDeg.set(e.targetId, (inDeg.get(e.targetId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const roots = functions.filter(
|
||||
(f) => (f.isEntry || (inDeg.get(f.id) ?? 0) === 0) && (outAdj.get(f.id)?.length ?? 0) > 0
|
||||
);
|
||||
// 入口优先、出边多者优先,保证先生成信息量大的主流程
|
||||
roots.sort(
|
||||
(a, b) =>
|
||||
(b.isEntry ? 1 : 0) - (a.isEntry ? 1 : 0) ||
|
||||
(outAdj.get(b.id)?.length ?? 0) - (outAdj.get(a.id)?.length ?? 0)
|
||||
);
|
||||
|
||||
const flows: Flow[] = [];
|
||||
for (const root of roots) {
|
||||
if (flows.length >= opts.maxFlows) break;
|
||||
|
||||
const included: string[] = [];
|
||||
const seen = new Set<string>([root.id]);
|
||||
const depth = new Map<string, number>([[root.id, 0]]);
|
||||
const queue: string[] = [root.id];
|
||||
|
||||
while (queue.length > 0 && included.length < opts.maxNodes) {
|
||||
const id = queue.shift()!;
|
||||
included.push(id);
|
||||
const d = depth.get(id)!;
|
||||
if (d >= opts.maxDepth) continue;
|
||||
for (const t of outAdj.get(id) ?? []) {
|
||||
if (!seen.has(t)) {
|
||||
seen.add(t);
|
||||
depth.set(t, d + 1);
|
||||
queue.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (included.length < 2) continue; // 只有孤立入口,不成流程
|
||||
|
||||
const includedSet = new Set(included);
|
||||
const flowId = `flow:call:${root.id}`;
|
||||
const stepIdOf = (fid: string): string => `step:${flowId}:${fid}`;
|
||||
|
||||
const steps: FlowStep[] = included.map((fid, i) => {
|
||||
const fn = funcById.get(fid);
|
||||
return {
|
||||
id: stepIdOf(fid),
|
||||
order: i,
|
||||
nodeId: fid,
|
||||
title: fn?.qualifiedName ?? fn?.name ?? fid,
|
||||
nextStepIds: (outAdj.get(fid) ?? []).filter((t) => includedSet.has(t)).map(stepIdOf),
|
||||
};
|
||||
});
|
||||
|
||||
const edgeIds = callEdges
|
||||
.filter((e) => includedSet.has(e.sourceId) && includedSet.has(e.targetId))
|
||||
.map((e) => e.id);
|
||||
|
||||
const rootName = root.qualifiedName ?? root.name;
|
||||
const where = root.location?.file ?? "";
|
||||
flows.push({
|
||||
id: flowId,
|
||||
name: where ? `${rootName} (${where})` : rootName,
|
||||
flowType: "call",
|
||||
provenance: "resolved",
|
||||
entryNodeId: root.id,
|
||||
steps,
|
||||
edgeIds,
|
||||
summary: `以 ${rootName} 为入口的调用链,覆盖 ${included.length} 个函数`,
|
||||
});
|
||||
}
|
||||
|
||||
return flows;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
".turbo",
|
||||
"coverage",
|
||||
".cache",
|
||||
"vendor",
|
||||
]);
|
||||
|
||||
export function toPosix(p: string): string {
|
||||
return p.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
/** 递归收集目标目录下的源码文件(绝对路径) */
|
||||
export function collectSourceFiles(root: string): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name);
|
||||
if (!SOURCE_EXTENSIONS.includes(ext)) continue;
|
||||
if (entry.name.endsWith(".d.ts")) continue;
|
||||
result.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
export function languageOf(file: string): string {
|
||||
const ext = path.extname(file);
|
||||
switch (ext) {
|
||||
case ".ts":
|
||||
return "typescript";
|
||||
case ".tsx":
|
||||
return "tsx";
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
case ".cjs":
|
||||
return "javascript";
|
||||
case ".jsx":
|
||||
return "jsx";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
// 标准化代码报告数据结构(对齐 docs/设计文档.md 第三章)
|
||||
|
||||
export type Id = string;
|
||||
|
||||
export type Provenance = "ast" | "resolved" | "ai" | "manual";
|
||||
|
||||
export interface SourceLocation {
|
||||
file: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
startCol?: number;
|
||||
endCol?: number;
|
||||
}
|
||||
|
||||
export interface BaseEntity {
|
||||
id: Id;
|
||||
name: string;
|
||||
qualifiedName?: string;
|
||||
location?: SourceLocation;
|
||||
language?: string;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Module extends BaseEntity {
|
||||
kind: "module";
|
||||
parentId?: Id;
|
||||
childModuleIds: Id[];
|
||||
fileIds: Id[];
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface CodeFile extends BaseEntity {
|
||||
kind: "file";
|
||||
moduleId: Id;
|
||||
path: string;
|
||||
loc?: number;
|
||||
containerIds: Id[];
|
||||
functionIds: Id[];
|
||||
}
|
||||
|
||||
export interface Container extends BaseEntity {
|
||||
kind: "container";
|
||||
fileId: Id;
|
||||
moduleId?: Id;
|
||||
functionIds: Id[];
|
||||
fieldIds: Id[];
|
||||
}
|
||||
|
||||
export interface Param {
|
||||
name: string;
|
||||
typeRef?: string;
|
||||
optional?: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface Func extends BaseEntity {
|
||||
kind: "function";
|
||||
fileId: Id;
|
||||
ownerId?: Id;
|
||||
moduleId?: Id;
|
||||
signature?: string;
|
||||
params: Param[];
|
||||
returnType?: string;
|
||||
isEntry?: boolean;
|
||||
interactions?: Interaction[];
|
||||
loc?: number;
|
||||
}
|
||||
|
||||
export interface Field extends BaseEntity {
|
||||
kind: "field";
|
||||
ownerId: Id;
|
||||
typeRef?: string;
|
||||
}
|
||||
|
||||
// ---------- 跨边界交互 ----------
|
||||
|
||||
export type InteractionKind = "api" | "db";
|
||||
export type InteractionDirection = "in" | "out";
|
||||
|
||||
export interface InteractionBase {
|
||||
id: Id;
|
||||
direction: InteractionDirection;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ApiInteraction extends InteractionBase {
|
||||
kind: "api";
|
||||
protocol: "http" | "ws" | "rpc" | "graphql";
|
||||
method?: string;
|
||||
path?: string;
|
||||
peer?: string;
|
||||
}
|
||||
|
||||
export interface DbInteraction extends InteractionBase {
|
||||
kind: "db";
|
||||
operation: "read" | "create" | "update" | "delete" | "other";
|
||||
target?: string;
|
||||
engine?: string;
|
||||
}
|
||||
|
||||
export type Interaction = ApiInteraction | DbInteraction;
|
||||
|
||||
// ---------- 关系边 ----------
|
||||
|
||||
export type EdgeKind = "call" | "import" | "reference" | "dataflow" | "trigger";
|
||||
|
||||
export interface Edge {
|
||||
id: Id;
|
||||
kind: EdgeKind;
|
||||
sourceId: Id;
|
||||
targetId: Id;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
label?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------- 流程 ----------
|
||||
|
||||
export interface FlowStep {
|
||||
id: Id;
|
||||
order: number;
|
||||
nodeId?: Id;
|
||||
title: string;
|
||||
description?: string;
|
||||
branchLabel?: string;
|
||||
nextStepIds: Id[];
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
id: Id;
|
||||
name: string;
|
||||
flowType: "call" | "business";
|
||||
summary?: string;
|
||||
provenance: Provenance;
|
||||
entryNodeId?: Id;
|
||||
steps: FlowStep[];
|
||||
edgeIds?: Id[];
|
||||
}
|
||||
|
||||
// ---------- 外部系统(派生) ----------
|
||||
|
||||
export type ExternalSystemKind = "database" | "service";
|
||||
|
||||
export interface ExternalSystemBase {
|
||||
id: Id;
|
||||
kind: ExternalSystemKind;
|
||||
name: string;
|
||||
functionIds: Id[];
|
||||
interactionCount: number;
|
||||
provenance: Provenance;
|
||||
}
|
||||
|
||||
export interface DatabaseSystem extends ExternalSystemBase {
|
||||
kind: "database";
|
||||
engine?: string;
|
||||
tables: string[];
|
||||
}
|
||||
|
||||
export interface ServiceSystem extends ExternalSystemBase {
|
||||
kind: "service";
|
||||
protocol?: string;
|
||||
endpoints: string[];
|
||||
}
|
||||
|
||||
export type ExternalSystem = DatabaseSystem | ServiceSystem;
|
||||
|
||||
// ---------- 顶层报告 ----------
|
||||
|
||||
export interface ProjectMeta {
|
||||
name: string;
|
||||
root: string;
|
||||
languages: string[];
|
||||
generatedAt: string;
|
||||
generator?: string;
|
||||
}
|
||||
|
||||
export interface ReportStats {
|
||||
fileCount: number;
|
||||
containerCount: number;
|
||||
functionCount: number;
|
||||
edgeCount: number;
|
||||
loc?: number;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
schemaVersion: string;
|
||||
project: ProjectMeta;
|
||||
stats: ReportStats;
|
||||
modules: Module[];
|
||||
files: CodeFile[];
|
||||
containers: Container[];
|
||||
functions: Func[];
|
||||
fields: Field[];
|
||||
edges: Edge[];
|
||||
flows: Flow[];
|
||||
externalSystems?: ExternalSystem[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>代码分析可视化</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "code-visualize-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@xyflow/react": "^12.3.5",
|
||||
"mobx": "^6.13.5",
|
||||
"mobx-react-lite": "^4.0.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
+28907
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "./styles.css";
|
||||
import { App } from "./view/App";
|
||||
import { VMContext } from "./view/vmContext";
|
||||
import { GraphViewModel } from "./viewmodel/GraphViewModel";
|
||||
import { HttpReportRepository, type ReportSource } from "./model/reportRepository";
|
||||
|
||||
// 可切换的报告数据源;新增项目只需往这里加一条
|
||||
const sources: ReportSource[] = [
|
||||
{ id: "taskflow", name: "task-flow-manager", url: "/report.json" },
|
||||
{ id: "self", name: "code-visualize(本工具)", url: "/self-report.json" },
|
||||
];
|
||||
|
||||
const viewModel = new GraphViewModel(sources, (url) => new HttpReportRepository(url));
|
||||
void viewModel.load();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<VMContext.Provider value={viewModel}>
|
||||
<App />
|
||||
</VMContext.Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CodeFile, Container, Flow, Func, Interaction } from "./report";
|
||||
|
||||
/** 与渲染技术无关的「中性图」结构。ViewModel 与 View 都基于它工作。 */
|
||||
export interface GraphNode {
|
||||
id: string; // 图节点 id(此处等于 FlowStep.id)
|
||||
label: string; // 函数名
|
||||
qualifier?: string; // 归属:方法所属类名 / 自由函数所属文件名
|
||||
funcId?: string;
|
||||
isEntry?: boolean;
|
||||
interactions?: Interaction[];
|
||||
}
|
||||
|
||||
export interface GraphContext {
|
||||
funcById: Map<string, Func>;
|
||||
containerById: Map<string, Container>;
|
||||
fileById: Map<string, CodeFile>;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
const parts = path.split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
function qualifierOf(fn: Func | undefined, ctx: GraphContext): string | undefined {
|
||||
if (!fn) return undefined;
|
||||
if (fn.ownerId) return ctx.containerById.get(fn.ownerId)?.name;
|
||||
const file = ctx.fileById.get(fn.fileId);
|
||||
return file ? baseName(file.path) : undefined;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
/** 将一条 Flow 转换为中性图(纯函数,无副作用、无框架依赖) */
|
||||
export function flowToGraph(flow: Flow, ctx: GraphContext): GraphData {
|
||||
const nodes: GraphNode[] = flow.steps.map((step) => {
|
||||
const fn = step.nodeId ? ctx.funcById.get(step.nodeId) : undefined;
|
||||
return {
|
||||
id: step.id,
|
||||
label: fn?.name ?? step.title,
|
||||
qualifier: qualifierOf(fn, ctx),
|
||||
funcId: step.nodeId,
|
||||
isEntry: fn?.isEntry,
|
||||
interactions: fn?.interactions,
|
||||
};
|
||||
});
|
||||
|
||||
const validStepIds = new Set(nodes.map((n) => n.id));
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const step of flow.steps) {
|
||||
for (const next of step.nextStepIds) {
|
||||
if (!validStepIds.has(next)) continue;
|
||||
edges.push({ id: `${step.id}->${next}`, source: step.id, target: next });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 标准化代码报告数据结构(与 CLI 端 src/types.ts 对齐)
|
||||
|
||||
export type Id = string;
|
||||
export type Provenance = "ast" | "resolved" | "ai" | "manual";
|
||||
|
||||
export interface SourceLocation {
|
||||
file: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
startCol?: number;
|
||||
endCol?: number;
|
||||
}
|
||||
|
||||
export interface BaseEntity {
|
||||
id: Id;
|
||||
name: string;
|
||||
qualifiedName?: string;
|
||||
location?: SourceLocation;
|
||||
language?: string;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Module extends BaseEntity {
|
||||
kind: "module";
|
||||
parentId?: Id;
|
||||
childModuleIds: Id[];
|
||||
fileIds: Id[];
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface CodeFile extends BaseEntity {
|
||||
kind: "file";
|
||||
moduleId: Id;
|
||||
path: string;
|
||||
loc?: number;
|
||||
containerIds: Id[];
|
||||
functionIds: Id[];
|
||||
}
|
||||
|
||||
export interface Container extends BaseEntity {
|
||||
kind: "container";
|
||||
fileId: Id;
|
||||
moduleId?: Id;
|
||||
functionIds: Id[];
|
||||
fieldIds: Id[];
|
||||
}
|
||||
|
||||
export interface Param {
|
||||
name: string;
|
||||
typeRef?: string;
|
||||
optional?: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface Func extends BaseEntity {
|
||||
kind: "function";
|
||||
fileId: Id;
|
||||
ownerId?: Id;
|
||||
moduleId?: Id;
|
||||
signature?: string;
|
||||
params: Param[];
|
||||
returnType?: string;
|
||||
isEntry?: boolean;
|
||||
interactions?: Interaction[];
|
||||
loc?: number;
|
||||
}
|
||||
|
||||
export interface Field extends BaseEntity {
|
||||
kind: "field";
|
||||
ownerId: Id;
|
||||
typeRef?: string;
|
||||
}
|
||||
|
||||
export type InteractionKind = "api" | "db";
|
||||
export type InteractionDirection = "in" | "out";
|
||||
|
||||
export interface InteractionBase {
|
||||
id: Id;
|
||||
direction: InteractionDirection;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ApiInteraction extends InteractionBase {
|
||||
kind: "api";
|
||||
protocol: "http" | "ws" | "rpc" | "graphql";
|
||||
method?: string;
|
||||
path?: string;
|
||||
peer?: string;
|
||||
}
|
||||
|
||||
export interface DbInteraction extends InteractionBase {
|
||||
kind: "db";
|
||||
operation: "read" | "create" | "update" | "delete" | "other";
|
||||
target?: string;
|
||||
engine?: string;
|
||||
}
|
||||
|
||||
export type Interaction = ApiInteraction | DbInteraction;
|
||||
|
||||
export type EdgeKind = "call" | "import" | "reference" | "dataflow" | "trigger";
|
||||
|
||||
export interface Edge {
|
||||
id: Id;
|
||||
kind: EdgeKind;
|
||||
sourceId: Id;
|
||||
targetId: Id;
|
||||
provenance: Provenance;
|
||||
confidence?: number;
|
||||
label?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FlowStep {
|
||||
id: Id;
|
||||
order: number;
|
||||
nodeId?: Id;
|
||||
title: string;
|
||||
description?: string;
|
||||
branchLabel?: string;
|
||||
nextStepIds: Id[];
|
||||
}
|
||||
|
||||
export interface Flow {
|
||||
id: Id;
|
||||
name: string;
|
||||
flowType: "call" | "business";
|
||||
summary?: string;
|
||||
provenance: Provenance;
|
||||
entryNodeId?: Id;
|
||||
steps: FlowStep[];
|
||||
edgeIds?: Id[];
|
||||
}
|
||||
|
||||
export type ExternalSystemKind = "database" | "service";
|
||||
|
||||
export interface ExternalSystemBase {
|
||||
id: Id;
|
||||
kind: ExternalSystemKind;
|
||||
name: string;
|
||||
functionIds: Id[];
|
||||
interactionCount: number;
|
||||
provenance: Provenance;
|
||||
}
|
||||
|
||||
export interface DatabaseSystem extends ExternalSystemBase {
|
||||
kind: "database";
|
||||
engine?: string;
|
||||
tables: string[];
|
||||
}
|
||||
|
||||
export interface ServiceSystem extends ExternalSystemBase {
|
||||
kind: "service";
|
||||
protocol?: string;
|
||||
endpoints: string[];
|
||||
}
|
||||
|
||||
export type ExternalSystem = DatabaseSystem | ServiceSystem;
|
||||
|
||||
export interface ProjectMeta {
|
||||
name: string;
|
||||
root: string;
|
||||
languages: string[];
|
||||
generatedAt: string;
|
||||
generator?: string;
|
||||
}
|
||||
|
||||
export interface ReportStats {
|
||||
fileCount: number;
|
||||
containerCount: number;
|
||||
functionCount: number;
|
||||
edgeCount: number;
|
||||
loc?: number;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
schemaVersion: string;
|
||||
project: ProjectMeta;
|
||||
stats: ReportStats;
|
||||
modules: Module[];
|
||||
files: CodeFile[];
|
||||
containers: Container[];
|
||||
functions: Func[];
|
||||
fields: Field[];
|
||||
edges: Edge[];
|
||||
flows: Flow[];
|
||||
externalSystems?: ExternalSystem[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Report } from "./report";
|
||||
|
||||
/** 一个可供选择的报告数据源 */
|
||||
export interface ReportSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 报告数据源抽象。切换来源(HTTP / 本地文件 / 上传)只需换实现,不影响上层。 */
|
||||
export interface ReportRepository {
|
||||
load(): Promise<Report>;
|
||||
}
|
||||
|
||||
/** 从 URL 加载报告 JSON */
|
||||
export class HttpReportRepository implements ReportRepository {
|
||||
constructor(private readonly url: string) {}
|
||||
|
||||
async load(): Promise<Report> {
|
||||
const res = await fetch(this.url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`加载报告失败: HTTP ${res.status} (${this.url})`);
|
||||
}
|
||||
return (await res.json()) as Report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import dagre from "@dagrejs/dagre";
|
||||
|
||||
export type LayoutDirection = "LR" | "TB";
|
||||
|
||||
export interface LayoutNodeInput {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LayoutEdgeInput {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction?: LayoutDirection;
|
||||
nodeSep?: number;
|
||||
rankSep?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 dagre 计算有向图的分层布局,返回每个节点左上角坐标。
|
||||
* 纯服务,不依赖任何渲染库。
|
||||
*/
|
||||
export function computeLayout(
|
||||
nodes: LayoutNodeInput[],
|
||||
edges: LayoutEdgeInput[],
|
||||
options: LayoutOptions = {}
|
||||
): Map<string, Position> {
|
||||
const { direction = "LR", nodeSep = 40, rankSep = 90 } = options;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: direction, nodesep: nodeSep, ranksep: rankSep, marginx: 20, marginy: 20 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const n of nodes) {
|
||||
g.setNode(n.id, { width: n.width, height: n.height });
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (e.source !== e.target) g.setEdge(e.source, e.target);
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const result = new Map<string, Position>();
|
||||
for (const n of nodes) {
|
||||
const node = g.node(n.id);
|
||||
if (!node) {
|
||||
result.set(n.id, { x: 0, y: 0 });
|
||||
continue;
|
||||
}
|
||||
// dagre 返回中心点,转换为左上角
|
||||
result.set(n.id, { x: node.x - n.width / 2, y: node.y - n.height / 2 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #1e293b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 18px;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app__report-select {
|
||||
margin-left: 12px;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app__stats {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- 搜索栏 ---------- */
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
max-width: 420px;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.search__input {
|
||||
width: 100%;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search__input:focus {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search__input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.search__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
z-index: 100;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.search__empty {
|
||||
padding: 10px 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.search__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search__item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.search__kind {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.search__kind--flow {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.search__kind--func {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.search__label {
|
||||
flex: 1 1 auto;
|
||||
font-size: 12.5px;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search__sub {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app__body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app__status {
|
||||
margin: auto;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app__status--error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.app__canvas {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- 侧栏面板 ---------- */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panel--groups {
|
||||
width: 210px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel--left {
|
||||
width: 280px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel--right {
|
||||
width: 320px;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.panel__body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* ---------- 流程列表 ---------- */
|
||||
.flow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.flow-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.flow-item.is-active {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.flow-item__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-item__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
/* ---------- 分组列表 ---------- */
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.group-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.group-item.is-active {
|
||||
background: #eef2ff;
|
||||
border-color: #c7d2fe;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.group-item--all {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.group-item__tag {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.group-item__tag--class {
|
||||
background: #ddd6fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.group-item__tag--file {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.group-item__name {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-item__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
/* ---------- 图节点 ---------- */
|
||||
.flow-node {
|
||||
width: 220px;
|
||||
min-height: 68px;
|
||||
background: #fff;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flow-node__qualifier {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-node.is-entry {
|
||||
border-color: #34d399;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.flow-node.is-selected {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.flow-node__title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.flow-node__entry-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.flow-node__badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badge-db {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.badge-api-in {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.badge-api-out {
|
||||
background: #ede9fe;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
/* ---------- 详情面板 ---------- */
|
||||
.detail-empty,
|
||||
.detail__muted {
|
||||
color: #94a3b8;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.detail__name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.detail__qualified {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-top: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail__section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail__label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detail__mono {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail__sig {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f6;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.detail__ix {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ix-line {
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.ix-db {
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.ix-api {
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.detail__flows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-flow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.detail-flow:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.detail-flow.is-active {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.detail-flow__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-flow__count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.detail__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detail__entry {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
border-radius: 6px;
|
||||
padding: 1px 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
margin: auto;
|
||||
color: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGraphVM } from "./vmContext";
|
||||
import { FlowGraph } from "./components/FlowGraph";
|
||||
import { FlowList } from "./components/FlowList";
|
||||
import { GroupList } from "./components/GroupList";
|
||||
import { NodeDetailPanel } from "./components/NodeDetailPanel";
|
||||
import { SearchBar } from "./components/SearchBar";
|
||||
|
||||
export const App = observer(function App() {
|
||||
const vm = useGraphVM();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app__header">
|
||||
<div className="app__title">
|
||||
代码分析可视化
|
||||
<select
|
||||
className="app__report-select"
|
||||
value={vm.selectedSourceId ?? ""}
|
||||
onChange={(e) => vm.selectReport(e.target.value)}
|
||||
>
|
||||
{vm.sources.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{vm.report ? <SearchBar /> : null}
|
||||
{vm.report ? (
|
||||
<div className="app__stats">
|
||||
文件 {vm.report.stats.fileCount} · 函数 {vm.report.stats.functionCount} · 边{" "}
|
||||
{vm.report.stats.edgeCount} · 流程 {vm.report.flows.length}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="app__body">
|
||||
{vm.loading ? <div className="app__status">加载中…</div> : null}
|
||||
{vm.error ? <div className="app__status app__status--error">加载失败: {vm.error}</div> : null}
|
||||
{!vm.loading && !vm.error && vm.report ? (
|
||||
<>
|
||||
<GroupList />
|
||||
<FlowList />
|
||||
<main className="app__canvas">
|
||||
<FlowGraph />
|
||||
</main>
|
||||
<NodeDetailPanel />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
import { FlowNode, type FlowNodeData } from "./FlowNode";
|
||||
|
||||
const nodeTypes = { flowNode: FlowNode };
|
||||
|
||||
export const FlowGraph = observer(function FlowGraph() {
|
||||
const vm = useGraphVM();
|
||||
const { nodes, edges } = 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 rfEdges: Edge[] = useMemo(
|
||||
() =>
|
||||
edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 18, height: 18 },
|
||||
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
|
||||
})),
|
||||
[edges]
|
||||
);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return <div className="graph-empty">该报告暂无可展示的流程</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
key={vm.selectedFlowId ?? "none"}
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onNodeClick={(_, node) => vm.selectNode(node.id)}
|
||||
onPaneClick={() => vm.selectNode(null)}
|
||||
>
|
||||
<Background gap={20} color="#e2e8f0" />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap pannable zoomable nodeStrokeWidth={2} />
|
||||
</ReactFlow>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
|
||||
export const FlowList = observer(function FlowList() {
|
||||
const vm = useGraphVM();
|
||||
|
||||
return (
|
||||
<aside className="panel panel--left">
|
||||
<div className="panel__header">流程 ({vm.visibleFlows.length})</div>
|
||||
<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}
|
||||
>
|
||||
<span className="flow-item__name">{flow.name}</span>
|
||||
<span className="flow-item__count">{flow.steps.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { Interaction } from "../../model/report";
|
||||
|
||||
export interface FlowNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
qualifier?: string;
|
||||
isEntry?: boolean;
|
||||
interactions?: Interaction[];
|
||||
}
|
||||
|
||||
function interactionBadges(interactions?: Interaction[]): { key: string; text: string; cls: string }[] {
|
||||
if (!interactions?.length) return [];
|
||||
const badges: { key: string; text: string; cls: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ix of interactions) {
|
||||
let key: string;
|
||||
let text: string;
|
||||
let cls: string;
|
||||
if (ix.kind === "db") {
|
||||
key = "db";
|
||||
text = "DB";
|
||||
cls = "badge-db";
|
||||
} else if (ix.direction === "in") {
|
||||
key = "api-in";
|
||||
text = "API▸";
|
||||
cls = "badge-api-in";
|
||||
} else {
|
||||
key = "api-out";
|
||||
text = "▸API";
|
||||
cls = "badge-api-out";
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
badges.push({ key, text, cls });
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
|
||||
export const FlowNode = memo(function FlowNode({ data, selected }: NodeProps & { data: FlowNodeData }) {
|
||||
const badges = interactionBadges(data.interactions);
|
||||
return (
|
||||
<div className={`flow-node${selected ? " is-selected" : ""}${data.isEntry ? " is-entry" : ""}`}>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
{data.qualifier ? (
|
||||
<div className="flow-node__qualifier" title={data.qualifier}>
|
||||
{data.qualifier}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flow-node__title" title={data.label}>
|
||||
{data.isEntry ? <span className="flow-node__entry-dot" /> : null}
|
||||
{data.label}
|
||||
</div>
|
||||
{badges.length > 0 ? (
|
||||
<div className="flow-node__badges">
|
||||
{badges.map((b) => (
|
||||
<span key={b.key} className={`badge ${b.cls}`}>
|
||||
{b.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
|
||||
export const GroupList = observer(function GroupList() {
|
||||
const vm = useGraphVM();
|
||||
|
||||
return (
|
||||
<aside className="panel panel--groups">
|
||||
<div className="panel__header">分组 ({vm.groups.length})</div>
|
||||
<div className="panel__body">
|
||||
<button
|
||||
className={`group-item group-item--all${vm.selectedGroupId === null ? " is-active" : ""}`}
|
||||
onClick={() => vm.selectGroup(null)}
|
||||
>
|
||||
<span className="group-item__name">全部</span>
|
||||
<span className="group-item__count">{vm.flows.length}</span>
|
||||
</button>
|
||||
|
||||
{vm.groups.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`group-item${g.id === vm.selectedGroupId ? " is-active" : ""}`}
|
||||
onClick={() => vm.selectGroup(g.id)}
|
||||
title={g.name}
|
||||
>
|
||||
<span className={`group-item__tag group-item__tag--${g.kind}`}>
|
||||
{g.kind === "class" ? "类" : "文件"}
|
||||
</span>
|
||||
<span className="group-item__name">{g.name}</span>
|
||||
<span className="group-item__count">{g.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
import type { Interaction } from "../../model/report";
|
||||
|
||||
function describeInteraction(ix: Interaction): string {
|
||||
if (ix.kind === "db") {
|
||||
return `DB · ${ix.operation}${ix.target ? ` → ${ix.target}` : ""}${ix.engine ? ` (${ix.engine})` : ""}`;
|
||||
}
|
||||
const dir = ix.direction === "in" ? "接收" : "发起";
|
||||
return `API ${dir} · ${ix.method ?? ""} ${ix.path ?? ""}${ix.peer ? ` @${ix.peer}` : ""}`;
|
||||
}
|
||||
|
||||
export const NodeDetailPanel = observer(function NodeDetailPanel() {
|
||||
const vm = useGraphVM();
|
||||
const fn = vm.selectedFunc;
|
||||
|
||||
return (
|
||||
<aside className="panel panel--right">
|
||||
<div className="panel__header">节点详情</div>
|
||||
<div className="panel__body">
|
||||
{!fn ? (
|
||||
<div className="detail-empty">点击图中节点查看函数详情</div>
|
||||
) : (
|
||||
<div className="detail">
|
||||
<div className="detail__name">{fn.name}</div>
|
||||
{fn.qualifiedName && fn.qualifiedName !== fn.name ? (
|
||||
<div className="detail__qualified">{fn.qualifiedName}</div>
|
||||
) : null}
|
||||
|
||||
<div className="detail__section">
|
||||
<div className="detail__label">位置</div>
|
||||
<div className="detail__mono">
|
||||
{fn.location ? `${fn.location.file}:${fn.location.startLine}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fn.signature ? (
|
||||
<div className="detail__section">
|
||||
<div className="detail__label">签名</div>
|
||||
<div className="detail__mono detail__sig">{fn.signature}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="detail__section">
|
||||
<div className="detail__label">跨边界交互 ({fn.interactions?.length ?? 0})</div>
|
||||
{fn.interactions?.length ? (
|
||||
<ul className="detail__ix">
|
||||
{fn.interactions.map((ix) => (
|
||||
<li key={ix.id} className={`ix-line ix-${ix.kind}`}>
|
||||
{describeInteraction(ix)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="detail__muted">无</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail__section">
|
||||
<div className="detail__label">所在流程 ({vm.flowsForSelectedFunc.length})</div>
|
||||
<div className="detail__flows">
|
||||
{vm.flowsForSelectedFunc.map((flow) => (
|
||||
<button
|
||||
key={flow.id}
|
||||
className={`detail-flow${flow.id === vm.selectedFlowId ? " is-active" : ""}`}
|
||||
onClick={() => vm.openFlowForFunc(flow.id, fn.id)}
|
||||
title={flow.name}
|
||||
>
|
||||
<span className="detail-flow__name">{flow.name}</span>
|
||||
<span className="detail-flow__count">{flow.steps.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail__section detail__meta">
|
||||
<span>来源: {fn.provenance}</span>
|
||||
{fn.isEntry ? <span className="detail__entry">入口</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGraphVM } from "../vmContext";
|
||||
import type { SearchResult } from "../../viewmodel/GraphViewModel";
|
||||
|
||||
export const SearchBar = observer(function SearchBar() {
|
||||
const vm = useGraphVM();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const results = vm.searchResults;
|
||||
const open = focused && vm.searchQuery.trim().length > 0;
|
||||
|
||||
const activate = (r: SearchResult): void => {
|
||||
vm.activateSearchResult(r);
|
||||
setFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<input
|
||||
className="search__input"
|
||||
placeholder="搜索流程 / 函数…"
|
||||
value={vm.searchQuery}
|
||||
onChange={(e) => vm.setSearchQuery(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) activate(results[0]);
|
||||
if (e.key === "Escape") vm.setSearchQuery("");
|
||||
}}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="search__dropdown">
|
||||
{results.length === 0 ? (
|
||||
<div className="search__empty">无匹配结果</div>
|
||||
) : (
|
||||
results.map((r) => (
|
||||
<button
|
||||
key={`${r.kind}:${r.id}`}
|
||||
className="search__item"
|
||||
// onMouseDown 先于 input 的 blur 触发,保证点击生效
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
activate(r);
|
||||
}}
|
||||
title={r.label}
|
||||
>
|
||||
<span className={`search__kind search__kind--${r.kind}`}>
|
||||
{r.kind === "flow" ? "流程" : "函数"}
|
||||
</span>
|
||||
<span className="search__label">{r.label}</span>
|
||||
<span className="search__sub">{r.sublabel}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { GraphViewModel } from "../viewmodel/GraphViewModel";
|
||||
|
||||
export const VMContext = createContext<GraphViewModel | null>(null);
|
||||
|
||||
export function useGraphVM(): GraphViewModel {
|
||||
const vm = useContext(VMContext);
|
||||
if (!vm) throw new Error("GraphViewModel 未在 VMContext 中提供");
|
||||
return vm;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { makeAutoObservable, 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";
|
||||
|
||||
export const NODE_WIDTH = 220;
|
||||
export const NODE_HEIGHT = 68;
|
||||
|
||||
export interface PositionedNode extends GraphNode, Position {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PositionedGraph {
|
||||
nodes: PositionedNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface FlowGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "class" | "file";
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
kind: "flow" | "func";
|
||||
label: string;
|
||||
sublabel: string;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
const parts = path.split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图视图的 ViewModel:编排数据加载、流程选择、布局计算与选中状态。
|
||||
* 不依赖任何 React / 渲染库,仅暴露可观察状态与命令。
|
||||
*/
|
||||
export class GraphViewModel {
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
report: Report | null = null;
|
||||
|
||||
selectedSourceId: string | null = null;
|
||||
selectedGroupId: string | null = null; // null = 全部
|
||||
selectedFlowId: string | null = null;
|
||||
selectedNodeId: string | null = null;
|
||||
searchQuery = "";
|
||||
|
||||
private funcById = new Map<string, Func>();
|
||||
private containerById = new Map<string, Container>();
|
||||
private fileById = new Map<string, CodeFile>();
|
||||
|
||||
constructor(
|
||||
private readonly sourceList: ReportSource[],
|
||||
private readonly makeRepository: (url: string) => ReportRepository
|
||||
) {
|
||||
this.selectedSourceId = sourceList[0]?.id ?? null;
|
||||
makeAutoObservable(this, {}, { autoBind: true });
|
||||
}
|
||||
|
||||
get sources(): ReportSource[] {
|
||||
return this.sourceList;
|
||||
}
|
||||
|
||||
get currentSource(): ReportSource | null {
|
||||
return this.sourceList.find((s) => s.id === this.selectedSourceId) ?? null;
|
||||
}
|
||||
|
||||
selectReport(sourceId: string): void {
|
||||
if (sourceId === this.selectedSourceId) return;
|
||||
this.selectedSourceId = sourceId;
|
||||
this.report = null;
|
||||
this.selectedGroupId = null;
|
||||
this.selectedFlowId = null;
|
||||
this.selectedNodeId = null;
|
||||
this.searchQuery = "";
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** 计算一条流程入口所属的分组(方法→类,自由函数→文件) */
|
||||
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 };
|
||||
}
|
||||
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: "ungrouped", name: "未归类", kind: "file", count: 0 };
|
||||
}
|
||||
|
||||
/** 分组列表:按包含流程数从多到少排序 */
|
||||
get groups(): FlowGroup[] {
|
||||
const map = new Map<string, FlowGroup>();
|
||||
for (const flow of this.flows) {
|
||||
const g = this.groupOfFlow(flow);
|
||||
const existing = map.get(g.id);
|
||||
if (existing) existing.count += 1;
|
||||
else map.set(g.id, { ...g, count: 1 });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** 当前分组过滤后的流程列表 */
|
||||
get visibleFlows(): Flow[] {
|
||||
if (!this.selectedGroupId) return this.flows;
|
||||
return this.flows.filter((f) => this.groupOfFlow(f).id === this.selectedGroupId);
|
||||
}
|
||||
|
||||
selectGroup(groupId: string | null): void {
|
||||
this.selectedGroupId = groupId;
|
||||
const visible = this.visibleFlows;
|
||||
if (!visible.some((f) => f.id === this.selectedFlowId)) {
|
||||
this.selectedFlowId = visible[0]?.id ?? null;
|
||||
this.selectedNodeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const source = this.currentSource;
|
||||
if (!source) return;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const report = await this.makeRepository(source.url).load();
|
||||
const funcIndex = new Map<string, Func>();
|
||||
for (const fn of report.functions) funcIndex.set(fn.id, fn);
|
||||
const containerIndex = new Map<string, Container>();
|
||||
for (const c of report.containers) containerIndex.set(c.id, c);
|
||||
const fileIndex = new Map<string, CodeFile>();
|
||||
for (const f of report.files) fileIndex.set(f.id, f);
|
||||
runInAction(() => {
|
||||
this.report = report;
|
||||
this.funcById = funcIndex;
|
||||
this.containerById = containerIndex;
|
||||
this.fileById = fileIndex;
|
||||
this.selectedGroupId = null;
|
||||
this.selectedFlowId = this.flows[0]?.id ?? null;
|
||||
this.selectedNodeId = null;
|
||||
});
|
||||
} catch (e) {
|
||||
runInAction(() => {
|
||||
this.error = e instanceof Error ? e.message : String(e);
|
||||
});
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 流程列表,按步骤数从多到少排序,信息量大的排前面 */
|
||||
get flows(): Flow[] {
|
||||
if (!this.report) return [];
|
||||
return [...this.report.flows].sort((a, b) => b.steps.length - a.steps.length);
|
||||
}
|
||||
|
||||
get currentFlow(): Flow | null {
|
||||
if (!this.report || !this.selectedFlowId) return null;
|
||||
return this.report.flows.find((f) => f.id === this.selectedFlowId) ?? null;
|
||||
}
|
||||
|
||||
/** 计算好坐标的中性图,供 View 适配到具体渲染库 */
|
||||
get positionedGraph(): PositionedGraph {
|
||||
const flow = this.currentFlow;
|
||||
if (!flow) return { nodes: [], edges: [] };
|
||||
|
||||
const graph = flowToGraph(flow, {
|
||||
funcById: this.funcById,
|
||||
containerById: this.containerById,
|
||||
fileById: this.fileById,
|
||||
});
|
||||
const positions = computeLayout(
|
||||
graph.nodes.map((n) => ({ id: n.id, width: NODE_WIDTH, height: NODE_HEIGHT })),
|
||||
graph.edges.map((e) => ({ source: e.source, target: e.target })),
|
||||
{ direction: "LR" }
|
||||
);
|
||||
|
||||
const nodes: PositionedNode[] = graph.nodes.map((n) => {
|
||||
const pos = positions.get(n.id) ?? { x: 0, y: 0 };
|
||||
return { ...n, x: pos.x, y: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT };
|
||||
});
|
||||
|
||||
return { nodes, edges: graph.edges };
|
||||
}
|
||||
|
||||
get selectedFunc(): Func | null {
|
||||
if (!this.selectedNodeId) return null;
|
||||
const node = this.positionedGraph.nodes.find((n) => n.id === this.selectedNodeId);
|
||||
if (!node?.funcId) return null;
|
||||
return this.funcById.get(node.funcId) ?? null;
|
||||
}
|
||||
|
||||
/** 当前选中函数参与(出现在其中)的所有流程 */
|
||||
get flowsForSelectedFunc(): Flow[] {
|
||||
const fn = this.selectedFunc;
|
||||
if (!fn || !this.report) return [];
|
||||
return this.report.flows
|
||||
.filter((flow) => flow.steps.some((s) => s.nodeId === fn.id))
|
||||
.sort((a, b) => b.steps.length - a.steps.length);
|
||||
}
|
||||
|
||||
/** 跳转到指定流程,并定位到其中承载该函数的节点 */
|
||||
openFlowForFunc(flowId: string, funcId: string): void {
|
||||
this.selectedGroupId = null; // 清除分组过滤,确保目标流程在列表中可见
|
||||
this.selectedFlowId = flowId;
|
||||
const flow = this.report?.flows.find((f) => f.id === flowId);
|
||||
const step = flow?.steps.find((s) => s.nodeId === funcId);
|
||||
this.selectedNodeId = step?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------- 搜索 ----------
|
||||
|
||||
setSearchQuery(q: string): void {
|
||||
this.searchQuery = q;
|
||||
}
|
||||
|
||||
private get funcIdsInFlows(): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const flow of this.report?.flows ?? []) {
|
||||
for (const step of flow.steps) if (step.nodeId) set.add(step.nodeId);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private biggestFlowForFunc(funcId: string): Flow | null {
|
||||
const matched = (this.report?.flows ?? [])
|
||||
.filter((f) => f.steps.some((s) => s.nodeId === funcId))
|
||||
.sort((a, b) => b.steps.length - a.steps.length);
|
||||
return matched[0] ?? null;
|
||||
}
|
||||
|
||||
get searchResults(): SearchResult[] {
|
||||
const q = this.searchQuery.trim().toLowerCase();
|
||||
if (!this.report || q.length < 1) return [];
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
for (const flow of this.flows) {
|
||||
if (flow.name.toLowerCase().includes(q)) {
|
||||
results.push({ id: flow.id, kind: "flow", label: flow.name, sublabel: `流程 · ${flow.steps.length} 步` });
|
||||
}
|
||||
if (results.length >= 20) break;
|
||||
}
|
||||
|
||||
const inFlows = this.funcIdsInFlows;
|
||||
for (const fn of this.report.functions) {
|
||||
if (results.length >= 40) break;
|
||||
if (!inFlows.has(fn.id)) continue; // 只搜可在图上定位的函数
|
||||
const name = (fn.qualifiedName ?? fn.name).toLowerCase();
|
||||
if (!name.includes(q)) continue;
|
||||
results.push({
|
||||
id: fn.id,
|
||||
kind: "func",
|
||||
label: fn.qualifiedName ?? fn.name,
|
||||
sublabel: fn.location?.file ?? "",
|
||||
});
|
||||
}
|
||||
return results.slice(0, 40);
|
||||
}
|
||||
|
||||
activateSearchResult(result: SearchResult): void {
|
||||
if (result.kind === "flow") {
|
||||
this.selectedGroupId = null;
|
||||
this.selectedFlowId = result.id;
|
||||
this.selectedNodeId = null;
|
||||
} else {
|
||||
const flow = this.biggestFlowForFunc(result.id);
|
||||
if (flow) this.openFlowForFunc(flow.id, result.id);
|
||||
}
|
||||
this.searchQuery = "";
|
||||
}
|
||||
|
||||
selectFlow(flowId: string): void {
|
||||
if (this.selectedFlowId === flowId) return;
|
||||
this.selectedFlowId = flowId;
|
||||
this.selectedNodeId = null;
|
||||
}
|
||||
|
||||
selectNode(nodeId: string | null): void {
|
||||
this.selectedNodeId = nodeId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"experimentalDecorators": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5300,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user