初始提交:代码可视化分析工具(CLI 分析器 + Web 可视化 + 设计文档)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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