初始提交:代码可视化分析工具(CLI 分析器 + Web 可视化 + 设计文档)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
朱葛
2026-07-22 15:19:31 +08:00
commit cb23f814c7
35 changed files with 74071 additions and 0 deletions
+12
View File
@@ -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>
+2119
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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>
);
+66
View File
@@ -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 };
}
+193
View File
@@ -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[];
}
+26
View File
@@ -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;
}
}
+62
View File
@@ -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;
}
+547
View File
@@ -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;
}
+54
View File
@@ -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>
);
});
+72
View File
@@ -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>
);
});
+25
View File
@@ -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>
);
});
+66
View File
@@ -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>
);
});
+36
View File
@@ -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>
);
});
+59
View File
@@ -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>
);
});
+10
View File
@@ -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;
}
+289
View File
@@ -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;
}
}
+20
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5300,
open: true,
},
});