@yss123/cage-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/bin/cage.js +6 -0
  4. package/bin/create-cage-app.js +6 -0
  5. package/dist/analyze.d.ts +18 -0
  6. package/dist/analyze.js +26 -0
  7. package/dist/analyze.js.map +1 -0
  8. package/dist/baseline.d.ts +5 -0
  9. package/dist/baseline.js +50 -0
  10. package/dist/baseline.js.map +1 -0
  11. package/dist/budget.d.ts +7 -0
  12. package/dist/budget.js +52 -0
  13. package/dist/budget.js.map +1 -0
  14. package/dist/config.d.ts +30 -0
  15. package/dist/config.js +47 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/create.d.ts +1 -0
  18. package/dist/create.js +47 -0
  19. package/dist/create.js.map +1 -0
  20. package/dist/database.d.ts +5 -0
  21. package/dist/database.js +70 -0
  22. package/dist/database.js.map +1 -0
  23. package/dist/derive.d.ts +14 -0
  24. package/dist/derive.js +163 -0
  25. package/dist/derive.js.map +1 -0
  26. package/dist/dev.d.ts +2 -0
  27. package/dist/dev.js +155 -0
  28. package/dist/dev.js.map +1 -0
  29. package/dist/env.d.ts +1 -0
  30. package/dist/env.js +15 -0
  31. package/dist/env.js.map +1 -0
  32. package/dist/findings.d.ts +11 -0
  33. package/dist/findings.js +13 -0
  34. package/dist/findings.js.map +1 -0
  35. package/dist/gen/contracts.d.ts +6 -0
  36. package/dist/gen/contracts.js +167 -0
  37. package/dist/gen/contracts.js.map +1 -0
  38. package/dist/gen/index.d.ts +19 -0
  39. package/dist/gen/index.js +137 -0
  40. package/dist/gen/index.js.map +1 -0
  41. package/dist/gen/zod-code.d.ts +7 -0
  42. package/dist/gen/zod-code.js +161 -0
  43. package/dist/gen/zod-code.js.map +1 -0
  44. package/dist/graph-snapshot.d.ts +13 -0
  45. package/dist/graph-snapshot.js +46 -0
  46. package/dist/graph-snapshot.js.map +1 -0
  47. package/dist/graph.d.ts +11 -0
  48. package/dist/graph.js +87 -0
  49. package/dist/graph.js.map +1 -0
  50. package/dist/impact.d.ts +28 -0
  51. package/dist/impact.js +223 -0
  52. package/dist/impact.js.map +1 -0
  53. package/dist/index.d.ts +16 -0
  54. package/dist/index.js +18 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/load.d.ts +6 -0
  57. package/dist/load.js +17 -0
  58. package/dist/load.js.map +1 -0
  59. package/dist/main.d.ts +1 -0
  60. package/dist/main.js +176 -0
  61. package/dist/main.js.map +1 -0
  62. package/dist/migrate.d.ts +7 -0
  63. package/dist/migrate.js +69 -0
  64. package/dist/migrate.js.map +1 -0
  65. package/dist/ontology.d.ts +41 -0
  66. package/dist/ontology.js +270 -0
  67. package/dist/ontology.js.map +1 -0
  68. package/dist/process.d.ts +6 -0
  69. package/dist/process.js +39 -0
  70. package/dist/process.js.map +1 -0
  71. package/dist/rules.d.ts +23 -0
  72. package/dist/rules.js +251 -0
  73. package/dist/rules.js.map +1 -0
  74. package/dist/static-check.d.ts +23 -0
  75. package/dist/static-check.js +225 -0
  76. package/dist/static-check.js.map +1 -0
  77. package/dist/sync.d.ts +5 -0
  78. package/dist/sync.js +46 -0
  79. package/dist/sync.js.map +1 -0
  80. package/dist/upgrade.d.ts +10 -0
  81. package/dist/upgrade.js +81 -0
  82. package/dist/upgrade.js.map +1 -0
  83. package/dist/verify.d.ts +7 -0
  84. package/dist/verify.js +88 -0
  85. package/dist/verify.js.map +1 -0
  86. package/dist/version.d.ts +4 -0
  87. package/dist/version.js +6 -0
  88. package/dist/version.js.map +1 -0
  89. package/graph-ui/app.js +1065 -0
  90. package/graph-ui/index.html +197 -0
  91. package/graph-ui/model.js +141 -0
  92. package/graph-ui/relations.js +125 -0
  93. package/graph-ui/structure.js +74 -0
  94. package/graph-ui/style.css +1698 -0
  95. package/managed/errors/CAGE-APPR-001.md +32 -0
  96. package/managed/errors/CAGE-APPR-002.md +32 -0
  97. package/managed/errors/CAGE-APPR-003.md +32 -0
  98. package/managed/errors/CAGE-APPR-004.md +32 -0
  99. package/managed/errors/CAGE-APPR-005.md +32 -0
  100. package/managed/errors/CAGE-APPR-006.md +32 -0
  101. package/managed/errors/CAGE-APPR-007.md +32 -0
  102. package/managed/errors/CAGE-APPR-010.md +32 -0
  103. package/managed/errors/CAGE-AUTH-001.md +32 -0
  104. package/managed/errors/CAGE-BUDGET-001.md +32 -0
  105. package/managed/errors/CAGE-BUDGET-002.md +32 -0
  106. package/managed/errors/CAGE-BUDGET-003.md +32 -0
  107. package/managed/errors/CAGE-CMD-002.md +32 -0
  108. package/managed/errors/CAGE-CMD-003.md +34 -0
  109. package/managed/errors/CAGE-CONN-001.md +32 -0
  110. package/managed/errors/CAGE-CONN-002.md +32 -0
  111. package/managed/errors/CAGE-CONN-003.md +32 -0
  112. package/managed/errors/CAGE-CONN-004.md +32 -0
  113. package/managed/errors/CAGE-CONN-005.md +32 -0
  114. package/managed/errors/CAGE-CONN-006.md +32 -0
  115. package/managed/errors/CAGE-CONN-007.md +32 -0
  116. package/managed/errors/CAGE-CONN-008.md +32 -0
  117. package/managed/errors/CAGE-CONN-009.md +32 -0
  118. package/managed/errors/CAGE-CONN-010.md +32 -0
  119. package/managed/errors/CAGE-CONN-011.md +32 -0
  120. package/managed/errors/CAGE-CONN-012.md +32 -0
  121. package/managed/errors/CAGE-CREATE-001.md +32 -0
  122. package/managed/errors/CAGE-DECL-001.md +34 -0
  123. package/managed/errors/CAGE-DECL-002.md +34 -0
  124. package/managed/errors/CAGE-DECL-003.md +34 -0
  125. package/managed/errors/CAGE-DECL-004.md +34 -0
  126. package/managed/errors/CAGE-DEP-001.md +34 -0
  127. package/managed/errors/CAGE-DEP-002.md +34 -0
  128. package/managed/errors/CAGE-DEP-003.md +34 -0
  129. package/managed/errors/CAGE-DEP-004.md +34 -0
  130. package/managed/errors/CAGE-DEP-005.md +34 -0
  131. package/managed/errors/CAGE-DLQ-001.md +32 -0
  132. package/managed/errors/CAGE-ENV-001.md +32 -0
  133. package/managed/errors/CAGE-EVENT-001.md +32 -0
  134. package/managed/errors/CAGE-EVENT-002.md +32 -0
  135. package/managed/errors/CAGE-FIELD-001.md +32 -0
  136. package/managed/errors/CAGE-FIELD-002.md +32 -0
  137. package/managed/errors/CAGE-FIELD-003.md +32 -0
  138. package/managed/errors/CAGE-FIELD-004.md +32 -0
  139. package/managed/errors/CAGE-GEN-001.md +33 -0
  140. package/managed/errors/CAGE-GEN-002.md +33 -0
  141. package/managed/errors/CAGE-GRANT-001.md +32 -0
  142. package/managed/errors/CAGE-GRANT-002.md +32 -0
  143. package/managed/errors/CAGE-GRANT-003.md +32 -0
  144. package/managed/errors/CAGE-IMPACT-001.md +32 -0
  145. package/managed/errors/CAGE-JOB-001.md +32 -0
  146. package/managed/errors/CAGE-JOB-002.md +32 -0
  147. package/managed/errors/CAGE-JOB-003.md +32 -0
  148. package/managed/errors/CAGE-LINT-001.md +32 -0
  149. package/managed/errors/CAGE-MIG-001.md +32 -0
  150. package/managed/errors/CAGE-MIG-002.md +32 -0
  151. package/managed/errors/CAGE-MIG-003.md +32 -0
  152. package/managed/errors/CAGE-MIG-004.md +32 -0
  153. package/managed/errors/CAGE-MIG-005.md +32 -0
  154. package/managed/errors/CAGE-NAME-001.md +32 -0
  155. package/managed/errors/CAGE-NAME-002.md +32 -0
  156. package/managed/errors/CAGE-NAME-003.md +32 -0
  157. package/managed/errors/CAGE-NAME-004.md +32 -0
  158. package/managed/errors/CAGE-NAME-005.md +32 -0
  159. package/managed/errors/CAGE-NAME-006.md +32 -0
  160. package/managed/errors/CAGE-ORG-001.md +32 -0
  161. package/managed/errors/CAGE-PAIR-002.md +33 -0
  162. package/managed/errors/CAGE-QUERY-001.md +32 -0
  163. package/managed/errors/CAGE-QUERY-002.md +32 -0
  164. package/managed/errors/CAGE-QUERY-003.md +32 -0
  165. package/managed/errors/CAGE-REACT-001.md +32 -0
  166. package/managed/errors/CAGE-REACT-002.md +32 -0
  167. package/managed/errors/CAGE-READS-001.md +32 -0
  168. package/managed/errors/CAGE-REG-001.md +32 -0
  169. package/managed/errors/CAGE-REG-002.md +32 -0
  170. package/managed/errors/CAGE-REG-003.md +32 -0
  171. package/managed/errors/CAGE-ROUTE-001.md +32 -0
  172. package/managed/errors/CAGE-SCOPE-001.md +32 -0
  173. package/managed/errors/CAGE-SCOPE-002.md +32 -0
  174. package/managed/errors/CAGE-SECRET-001.md +32 -0
  175. package/managed/errors/CAGE-SECRET-002.md +32 -0
  176. package/managed/errors/CAGE-SENS-001.md +32 -0
  177. package/managed/errors/CAGE-SQL-001.md +32 -0
  178. package/managed/errors/CAGE-SQL-002.md +32 -0
  179. package/managed/errors/CAGE-STATE-001.md +32 -0
  180. package/managed/errors/CAGE-STATE-002.md +32 -0
  181. package/managed/errors/CAGE-STATE-003.md +32 -0
  182. package/managed/errors/CAGE-STATE-004.md +32 -0
  183. package/managed/errors/CAGE-STATE-005.md +32 -0
  184. package/managed/errors/CAGE-STATE-010.md +32 -0
  185. package/managed/errors/CAGE-STATE-011.md +32 -0
  186. package/managed/errors/CAGE-SYNC-001.md +35 -0
  187. package/managed/errors/CAGE-TEST-001.md +33 -0
  188. package/managed/errors/CAGE-UNSAFE-001.md +32 -0
  189. package/managed/errors/CAGE-UPGRADE-001.md +32 -0
  190. package/managed/errors/README.md +99 -0
  191. package/package.json +60 -3
  192. package/templates/app/.dockerignore +10 -0
  193. package/templates/app/.env.example +17 -0
  194. package/templates/app/.github/workflows/cage.yml +44 -0
  195. package/templates/app/AGENTS.md +13 -0
  196. package/templates/app/Dockerfile +23 -0
  197. package/templates/app/README.md +58 -0
  198. package/templates/app/apps/api/package.json +16 -0
  199. package/templates/app/apps/api/src/main.ts +10 -0
  200. package/templates/app/apps/api/src/worker.ts +14 -0
  201. package/templates/app/apps/api/tsconfig.json +5 -0
  202. package/templates/app/apps/web/index.html +13 -0
  203. package/templates/app/apps/web/package.json +22 -0
  204. package/templates/app/apps/web/src/main.tsx +552 -0
  205. package/templates/app/apps/web/src/style.css +663 -0
  206. package/templates/app/apps/web/tsconfig.json +11 -0
  207. package/templates/app/apps/web/vite.config.ts +16 -0
  208. package/templates/app/cage.config.json +114 -0
  209. package/templates/app/compose.yaml +39 -0
  210. package/templates/app/db/migrations/0001_init.sql +112 -0
  211. package/templates/app/package.json +25 -0
  212. package/templates/app/packages/contracts/package.json +16 -0
  213. package/templates/app/packages/contracts/src/dept.ts +74 -0
  214. package/templates/app/packages/contracts/src/index.ts +81 -0
  215. package/templates/app/packages/contracts/src/inventory-lock.ts +52 -0
  216. package/templates/app/packages/contracts/src/inventory.ts +57 -0
  217. package/templates/app/packages/contracts/src/purchase-order.ts +72 -0
  218. package/templates/app/packages/contracts/src/queries.ts +17 -0
  219. package/templates/app/packages/contracts/src/role.ts +85 -0
  220. package/templates/app/packages/contracts/src/user.ts +89 -0
  221. package/templates/app/packages/contracts/tsconfig.json +5 -0
  222. package/templates/app/packages/domain/package.json +23 -0
  223. package/templates/app/packages/domain/src/approvals.ts +18 -0
  224. package/templates/app/packages/domain/src/erp.ts +36 -0
  225. package/templates/app/packages/domain/src/index.ts +9 -0
  226. package/templates/app/packages/domain/src/inventory.ts +43 -0
  227. package/templates/app/packages/domain/src/jobs.ts +13 -0
  228. package/templates/app/packages/domain/src/purchase-order.test.ts +137 -0
  229. package/templates/app/packages/domain/src/purchase-order.ts +122 -0
  230. package/templates/app/packages/domain/src/queries.ts +10 -0
  231. package/templates/app/packages/domain/src/reactions.ts +24 -0
  232. package/templates/app/packages/domain/tsconfig.json +5 -0
  233. package/templates/app/pnpm-workspace.yaml +10 -0
  234. package/templates/app/renovate.json +5 -0
  235. package/templates/app/tsconfig.base.json +8 -0
@@ -0,0 +1,1065 @@
1
+ import { kinds, nodeLabel, matchesNode, projectGraph, neighborhood, isSystem, layoutGraph, edgePath, NODE_WIDTH, NODE_HEIGHT, } from './model.js';
2
+ import { explainEdge } from './relations.js';
3
+ import { tableCards, tableOwner, layoutTables, tableEdgePath, fieldType, TABLE_WIDTH, TABLE_HEADER, TABLE_ROW, } from './structure.js';
4
+ // 页面不引入图形框架或远程 CDN。百级声明图使用 SVG,即可保留清晰缩放、
5
+ // 键盘选择和可检查的节点语义;所有数据都由本地 CLI 抽取服务提供。
6
+ const element = (id) => document.getElementById(id);
7
+ const svg = document.getElementById('graph');
8
+ const viewport = document.getElementById('viewport');
9
+ const edgeLayer = document.getElementById('edges');
10
+ const nodeLayer = document.getElementById('nodes');
11
+ const ns = 'http://www.w3.org/2000/svg';
12
+ let snapshot;
13
+ let model = { nodes: [], edges: [] };
14
+ let visibleNodes = [];
15
+ let visibleEdges = [];
16
+ let positions = new Map();
17
+ let selected = null;
18
+ let selectedEdge = null;
19
+ let emphasizeSelection = false;
20
+ let aggregate = null;
21
+ let focus = null;
22
+ let mode = 'structure';
23
+ let cards = [];
24
+ let visibleCards = [];
25
+ let searchTarget = null;
26
+ let showSystem = false;
27
+ let camera = { x: 0, y: 0, scale: 1 };
28
+ const businessKinds = Object.keys(kinds).filter((k) => !['field', 'state', 'capability', 'entry'].includes(k));
29
+ let enabled = new Set(businessKinds);
30
+ const nodeElements = new Map();
31
+ const edgeElements = new Map();
32
+ function html(tag, className = '', text = '') {
33
+ const node = document.createElement(tag);
34
+ node.className = className;
35
+ node.textContent = text;
36
+ return node;
37
+ }
38
+ function vector(tag, attrs = {}, text = '') {
39
+ const node = document.createElementNS(ns, tag);
40
+ for (const [key, value] of Object.entries(attrs))
41
+ node.setAttribute(key, String(value));
42
+ node.textContent = text;
43
+ return node;
44
+ }
45
+ const byKey = (key) => model.nodes.find((n) => n.key === key);
46
+ const displayName = (node) => nodeLabel(node, snapshot.presentation.labels);
47
+ // 中文字符约占两个半角字宽,不能沿用英文按字符数截断,否则标题会溢出卡片。
48
+ function shortName(text, width = 23) {
49
+ let result = '', used = 0;
50
+ for (const char of text) {
51
+ used += /[\u0000-\u00ff]/.test(char) ? 1 : 2;
52
+ if (used > width)
53
+ return `${result}…`;
54
+ result += char;
55
+ }
56
+ return result;
57
+ }
58
+ const named = (id, kind) => {
59
+ const node = model.nodes.find((n) => n.id === id && (!kind || n.kind === kind));
60
+ return node
61
+ ? displayName(node)
62
+ : ((kind ? snapshot.presentation.labels[`${kind}:${id}`] : undefined) ?? String(id));
63
+ };
64
+ function notify(message) {
65
+ element('notice').textContent = message;
66
+ element('notice').hidden = !message;
67
+ }
68
+ async function load(refresh = false) {
69
+ const button = element('refresh');
70
+ button.disabled = true;
71
+ button.textContent = refresh ? '正在重新抽取…' : '读取中…';
72
+ notify('');
73
+ try {
74
+ const response = await fetch(refresh ? '/api/refresh' : '/api/graph', {
75
+ method: refresh ? 'POST' : 'GET',
76
+ });
77
+ const data = await response.json();
78
+ if (!response.ok)
79
+ throw new Error(data.error);
80
+ snapshot = data;
81
+ model = projectGraph(snapshot.graph);
82
+ cards = tableCards(model);
83
+ // 边的序号可能因重新抽取而变化,清除旧边,避免把解释套到另一条关系上。
84
+ selectedEdge = null;
85
+ // 刷新保留用户筛选与有效选中项,已删除节点不继续显示旧详情。
86
+ if (selected && !model.nodes.some((n) => n.key === selected))
87
+ selected = null;
88
+ if (focus && !model.nodes.some((n) => n.key === focus))
89
+ focus = null;
90
+ if (aggregate && !model.nodes.some((n) => n.kind === 'aggregate' && n.id === aggregate))
91
+ aggregate = null;
92
+ if (!selected) {
93
+ const aggregates = model.nodes.filter((n) => n.kind === 'aggregate' && !isSystem(n));
94
+ const commands = (a) => model.nodes.filter((n) => n.kind === 'command' && n.id.startsWith(`${a.id}.`)).length;
95
+ selected = aggregates.sort((a, b) => commands(b) - commands(a))[0]?.key ?? model.nodes[0]?.key ?? null;
96
+ }
97
+ renderSummary();
98
+ renderFilters();
99
+ renderGraph();
100
+ renderInspector();
101
+ renderFindings();
102
+ }
103
+ catch (error) {
104
+ // 抽取失败时保留上次成功的视图,并明确告诉用户它是旧数据。
105
+ notify(`${snapshot ? '重新抽取失败,当前保留上次成功的图谱。' : '图谱加载失败。'}\n${error.message}`);
106
+ if (!snapshot) {
107
+ element('health').textContent = '尚未取得图谱';
108
+ element('updated').textContent = '修复问题后点击重新抽取';
109
+ }
110
+ }
111
+ finally {
112
+ button.disabled = false;
113
+ button.textContent = '↻ 重新抽取';
114
+ element('loading').hidden = true;
115
+ }
116
+ }
117
+ function renderSummary() {
118
+ element('project-name').textContent = snapshot.presentation.title;
119
+ element('project-name').title = snapshot.project;
120
+ document.title = `${snapshot.presentation.title} · Cage 本体图谱`;
121
+ element('domain-path').textContent = snapshot.domain;
122
+ element('metric-aggregates').textContent = String(snapshot.graph.nodes.filter((n) => n.kind === 'aggregate').length);
123
+ element('metric-nodes').textContent = String(snapshot.graph.nodes.length);
124
+ element('metric-edges').textContent = String(snapshot.graph.edges.length);
125
+ const errors = snapshot.findings.filter((f) => f.severity !== 'warning').length;
126
+ const warnings = snapshot.findings.length - errors;
127
+ element('health').textContent = errors
128
+ ? `${errors} 个声明问题`
129
+ : warnings
130
+ ? `${warnings} 个检查提醒`
131
+ : '声明与图规则通过';
132
+ element('health-dot').classList.toggle('warning', snapshot.findings.length > 0);
133
+ element('show-findings').hidden = !snapshot.findings.length;
134
+ const time = new Date(snapshot.generatedAt).toLocaleTimeString('zh-CN', { hour12: false });
135
+ element('updated').textContent = `最近抽取 ${time}`;
136
+ }
137
+ function renderFilters() {
138
+ element('types-heading').hidden = mode === 'structure';
139
+ element('type-filters').hidden = mode === 'structure';
140
+ element('structure-note').hidden = mode !== 'structure';
141
+ const aggregates = model.nodes.filter((n) => n.kind === 'aggregate' && (showSystem || !isSystem(n)));
142
+ element('aggregate-total').textContent = String(aggregates.length);
143
+ const list = element('aggregate-list');
144
+ list.replaceChildren();
145
+ const addAggregate = (id, label, count) => {
146
+ const button = html('button', `aggregate-button${aggregate === id ? ' active' : ''}`);
147
+ button.setAttribute('aria-pressed', String(aggregate === id));
148
+ button.append(html('span', '', id ? '◇' : '⊞'), html('span', '', label), html('b', '', String(count)));
149
+ button.onclick = () => {
150
+ aggregate = id;
151
+ focus = null;
152
+ renderFilters();
153
+ renderGraph();
154
+ };
155
+ list.append(button);
156
+ };
157
+ addAggregate(null, '全部业务', aggregates.length);
158
+ for (const a of aggregates)
159
+ addAggregate(a.id, displayName(a), model.nodes.filter((n) => n.id.startsWith(`${a.id}.`)).length);
160
+ const filters = element('type-filters');
161
+ filters.replaceChildren();
162
+ for (const [kind, meta] of Object.entries(kinds)) {
163
+ if (kind === 'entry')
164
+ continue;
165
+ const count = model.nodes.filter((n) => n.kind === kind && (showSystem || !isSystem(n))).length;
166
+ if (!count)
167
+ continue;
168
+ const label = html('label', 'type-filter');
169
+ const input = html('input');
170
+ input.type = 'checkbox';
171
+ input.checked = enabled.has(kind);
172
+ input.setAttribute('aria-label', `显示${meta.label}`);
173
+ input.onchange = () => {
174
+ input.checked ? enabled.add(kind) : enabled.delete(kind);
175
+ if (kind === 'state')
176
+ input.checked ? enabled.add('entry') : enabled.delete('entry');
177
+ renderGraph();
178
+ };
179
+ const dot = html('span', 'type-dot');
180
+ dot.style.backgroundColor = meta.color;
181
+ label.append(input, dot, html('span', '', meta.label), html('small', '', String(count)));
182
+ filters.append(label);
183
+ }
184
+ element('system-toggle').checked = showSystem;
185
+ document.querySelectorAll('[data-mode]').forEach((button) => {
186
+ button.classList.toggle('active', button.dataset.mode === mode);
187
+ button.setAttribute('aria-pressed', String(button.dataset.mode === mode));
188
+ });
189
+ }
190
+ /** 选一个聚合时,先纳入它自己的声明,再沿真实关系扩展业务链。
191
+ * 不沿其他聚合的归属边扩散,避免选采购单却展开所有组织管理命令。 */
192
+ function aggregateNodes(id) {
193
+ const keys = new Set(model.nodes.filter((n) => n.id === id || n.id.startsWith(`${id}.`)).map((n) => n.key));
194
+ for (let hop = 0; hop < 3; hop++) {
195
+ const previous = new Set(keys);
196
+ for (const edge of model.edges.filter((e) => !e.derived)) {
197
+ if (previous.has(edge.from))
198
+ keys.add(edge.to);
199
+ if (previous.has(edge.to) && ['uses', 'reads', 'queries', 'triggers'].includes(edge.kind))
200
+ keys.add(edge.from);
201
+ }
202
+ }
203
+ return keys;
204
+ }
205
+ function renderGraph(resetLayout = true) {
206
+ searchTarget = null;
207
+ const scope = aggregate ? aggregateNodes(aggregate) : null;
208
+ const focused = focus ? neighborhood(model, focus) : null;
209
+ visibleNodes = model.nodes.filter((n) => enabled.has(n.kind) &&
210
+ (showSystem || !isSystem(n)) &&
211
+ (!scope || scope.has(n.key)) &&
212
+ (!focused || focused.has(n.key)));
213
+ const visible = new Set(visibleNodes.map((n) => n.key));
214
+ visibleEdges = model.edges.filter((e) => visible.has(e.from) && visible.has(e.to) && (!focus || e.from === focus || e.to === focus));
215
+ if (mode === 'structure') {
216
+ const center = focus ? tableOwner(cards, focus) : cards.find((c) => c.node.id === aggregate);
217
+ const focusedField = focus && byKey(focus).kind === 'field' ? focus : null;
218
+ const refs = model.edges.filter((e) => e.kind === 'refs' &&
219
+ (focusedField
220
+ ? e.from === focusedField
221
+ : !center || e.to === center.node.key || tableOwner(cards, e.from)?.node.key === center.node.key));
222
+ const related = center ? new Set([center.node.key]) : null;
223
+ // 选对象后只展开直接引用它、或被它引用的对象;不沿命令与事件扩散。
224
+ if (center)
225
+ for (const edge of refs) {
226
+ const source = tableOwner(cards, edge.from);
227
+ if (source.node.key === center.node.key)
228
+ related.add(edge.to);
229
+ if (edge.to === center.node.key)
230
+ related.add(source.node.key);
231
+ }
232
+ visibleCards = cards.filter((card) => (showSystem || !isSystem(card.node)) && (!related || related.has(card.node.key)));
233
+ const keys = new Set(visibleCards.flatMap((card) => [card.node.key, ...card.fields.map((f) => f.key)]));
234
+ visibleNodes = visibleCards.map((card) => card.node);
235
+ visibleEdges = refs.filter((edge) => keys.has(edge.from) && keys.has(edge.to));
236
+ }
237
+ if (selectedEdge && !visibleEdges.some((e) => e.key === selectedEdge.key)) {
238
+ selectedEdge = null;
239
+ renderInspector();
240
+ }
241
+ if (resetLayout)
242
+ positions =
243
+ mode === 'structure'
244
+ ? layoutTables(visibleCards, visibleEdges, svg.clientWidth < 680 ? 1 : 2)
245
+ : layoutGraph(visibleNodes, visibleEdges, focus);
246
+ nodeLayer.replaceChildren();
247
+ edgeLayer.replaceChildren();
248
+ nodeElements.clear();
249
+ edgeElements.clear();
250
+ for (const edge of visibleEdges) {
251
+ const explanation = explainEdge(edge, model, snapshot.presentation.labels);
252
+ const group = vector('g', {
253
+ class: `edge-group${edge.derived ? ' derived' : ''}`,
254
+ tabindex: 0,
255
+ role: 'button',
256
+ 'data-key': edge.key,
257
+ 'aria-label': `${displayName(byKey(edge.from))} → ${displayName(byKey(edge.to))}:${explanation.label},查看关系说明`,
258
+ });
259
+ const path = vector('path', {
260
+ class: 'edge',
261
+ 'marker-end': 'url(#arrow)',
262
+ });
263
+ // 透明描边扩大点击范围;文字牌与线属于同一按钮,鼠标和键盘看到同一解释。
264
+ const hit = vector('path', { class: 'edge-hit' });
265
+ const label = vector('g', { class: 'edge-label' });
266
+ const width = explanation.label.length * 14 + 20;
267
+ label.append(vector('rect', { x: -width / 2, y: -12, width, height: 24, rx: 12 }), vector('text', { 'text-anchor': 'middle', y: 4 }, explanation.label));
268
+ group.append(vector('title', {}, `${explanation.why}\n点击查看用途与声明依据`), hit, path, label);
269
+ group.onclick = () => selectEdge(edge);
270
+ group.onkeydown = (event) => {
271
+ if (event.key === 'Enter' || event.key === ' ') {
272
+ event.preventDefault();
273
+ selectEdge(edge);
274
+ }
275
+ };
276
+ edgeLayer.append(group);
277
+ edgeElements.set(edge.key, { group, path, hit, label, width });
278
+ }
279
+ for (const node of visibleNodes) {
280
+ if (mode === 'structure') {
281
+ renderTable(visibleCards.find((card) => card.node.key === node.key));
282
+ continue;
283
+ }
284
+ const meta = kinds[node.kind];
285
+ const g = vector('g', {
286
+ class: 'node',
287
+ tabindex: 0,
288
+ role: 'button',
289
+ 'aria-label': `${meta.label} ${displayName(node)} ${node.id}`,
290
+ 'data-key': node.key,
291
+ });
292
+ g.append(vector('title', {}, `${meta.label} · ${displayName(node)}\n${node.id}`));
293
+ g.append(vector('rect', {
294
+ class: 'selected-ring',
295
+ x: -5,
296
+ y: -5,
297
+ width: NODE_WIDTH + 10,
298
+ height: NODE_HEIGHT + 10,
299
+ rx: 12,
300
+ }));
301
+ g.append(vector('rect', { class: 'node-box', width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8 }));
302
+ g.append(vector('rect', { x: 10, y: 14, width: 27, height: 27, rx: 6, fill: meta.color, 'fill-opacity': 0.1 }));
303
+ g.append(vector('text', { class: 'node-code', x: 23.5, y: 31, 'text-anchor': 'middle', fill: meta.color }, meta.short));
304
+ g.append(vector('text', { class: 'node-title', x: 46, y: 25 }, shortName(displayName(node), 18)));
305
+ g.append(vector('text', { class: 'node-subtitle', x: 46, y: 40 }, shortName(node.id, 23)));
306
+ g.onkeydown = (event) => {
307
+ if (event.key === 'Enter' || event.key === ' ') {
308
+ event.preventDefault();
309
+ selectNode(node.key);
310
+ }
311
+ };
312
+ nodeLayer.append(g);
313
+ nodeElements.set(node.key, g);
314
+ }
315
+ updatePositions();
316
+ highlight();
317
+ element('empty').hidden = visibleNodes.length > 0;
318
+ element('view-title').textContent = focus
319
+ ? `聚焦 · ${displayName(byKey(focus))}`
320
+ : aggregate
321
+ ? named(aggregate, 'aggregate')
322
+ : '全部业务';
323
+ element('view-count').textContent =
324
+ mode === 'structure'
325
+ ? ` / ${visibleCards.length} 个对象 · ${visibleCards.reduce((sum, card) => sum + card.fields.length, 0)} 个字段`
326
+ : ` / ${visibleNodes.length} 个节点`;
327
+ element('exit-focus').hidden = !focus;
328
+ element('canvas-status').textContent = `${visibleEdges.filter((e) => !e.derived).length} 条声明关系`;
329
+ element('relation-guide').textContent =
330
+ mode === 'structure'
331
+ ? '字段收在对象卡片内;点击字段看详情,点击引用线看连接原因。'
332
+ : visibleEdges.length > 18
333
+ ? '概览标注选中节点的关系;悬停或点击其他连线查看说明。'
334
+ : '按“起点 → 关系名称 → 终点”阅读;点击连线查看为什么连接、有什么用。';
335
+ if (mode === 'structure') {
336
+ const hiddenRefs = model.edges.filter((e) => e.kind === 'refs' && tableOwner(visibleCards, e.from) && !tableOwner(visibleCards, e.to)).length;
337
+ element('canvas-status').textContent =
338
+ `${visibleEdges.length} 条对象引用${hiddenRefs ? ` · ${hiddenRefs} 条指向隐藏对象` : ''}`;
339
+ element('edge-legend').textContent = '↗ 字段引用 * 必填 卡片展示声明字段';
340
+ }
341
+ else {
342
+ element('edge-legend').replaceChildren(html('span', 'legend-line'), document.createTextNode(' 声明关系 '), html('span', 'legend-line dashed'), document.createTextNode(' 视图归属'));
343
+ }
344
+ if (resetLayout)
345
+ requestAnimationFrame(fit);
346
+ }
347
+ /** 绿色表头、逐行字段和表名保持在同一 SVG 卡片里。字段是可选择的原始节点,
348
+ * 表头负责拖动卡片,字段点击不会误触画布拖动或把数据结构重新拆散。 */
349
+ function renderTable(card) {
350
+ const { node, fields, height } = card;
351
+ const group = vector('g', {
352
+ class: 'node table-card',
353
+ 'data-key': node.key,
354
+ role: 'group',
355
+ 'aria-label': `业务对象 ${displayName(node)},${fields.length} 个字段`,
356
+ });
357
+ group.append(vector('rect', {
358
+ class: 'table-shadow',
359
+ x: -5,
360
+ y: -5,
361
+ width: TABLE_WIDTH + 10,
362
+ height: height + 10,
363
+ rx: 10,
364
+ }));
365
+ group.append(vector('rect', { class: 'table-body', width: TABLE_WIDTH, height, rx: 6 }));
366
+ const header = vector('g', {
367
+ class: 'table-header',
368
+ role: 'button',
369
+ tabindex: 0,
370
+ 'aria-label': `查看业务对象 ${displayName(node)} ${node.id}`,
371
+ });
372
+ header.append(vector('path', {
373
+ d: `M6,0H${TABLE_WIDTH - 6}Q${TABLE_WIDTH},0 ${TABLE_WIDTH},6V${TABLE_HEADER}H0V6Q0,0 6,0Z`,
374
+ }));
375
+ header.append(vector('text', { x: 16, y: 34, class: 'table-icon' }, '▦'), vector('text', { x: 39, y: 34, class: 'table-name' }, shortName(displayName(node), 18)), vector('text', { x: TABLE_WIDTH - 15, y: 33, 'text-anchor': 'end', class: 'table-id' }, shortName(node.id, 20)));
376
+ header.append(vector('title', {}, `${displayName(node)} · ${node.id}\n拖动表头移动;点击查看业务对象详情`));
377
+ header.onkeydown = (event) => {
378
+ if (event.key === 'Enter' || event.key === ' ') {
379
+ event.preventDefault();
380
+ selectNode(node.key);
381
+ }
382
+ };
383
+ group.append(header);
384
+ for (const [index, field] of fields.entries()) {
385
+ const code = field.id.slice(node.id.length + 1);
386
+ const name = displayName(field);
387
+ const row = vector('g', {
388
+ class: 'table-field',
389
+ transform: `translate(0,${TABLE_HEADER + index * TABLE_ROW})`,
390
+ role: 'button',
391
+ tabindex: 0,
392
+ 'aria-label': `字段 ${name} ${field.id},${fieldType(field)}${field.attrs.optional ? ',可选' : ',必填'}`,
393
+ });
394
+ row.append(vector('rect', { width: TABLE_WIDTH, height: TABLE_ROW, class: index % 2 ? 'alternate' : '' }));
395
+ row.append(vector('text', { x: 14, y: 24, class: field.attrs.ref ? 'field-ref' : 'field-dot' }, field.attrs.ref ? '↗' : '·'));
396
+ row.append(vector('text', { x: 34, y: 24, class: 'field-code' }, shortName(code, 16)));
397
+ if (!field.attrs.optional)
398
+ row.append(vector('text', { x: 150, y: 24, class: 'field-required' }, '*'));
399
+ row.append(vector('text', { x: 166, y: 24, class: 'field-name' }, shortName(name, 14)), vector('text', { x: TABLE_WIDTH - 13, y: 24, 'text-anchor': 'end', class: 'field-type' }, fieldType(field)));
400
+ row.append(vector('title', {}, `${name} · ${field.id}\n${fieldType(field)}${field.attrs.optional ? ' · 可选' : ' · 必填'}${field.attrs.ref ? ` · 引用 ${named(field.attrs.ref, 'aggregate')}` : ''}`));
401
+ row.onclick = (event) => {
402
+ event.stopPropagation();
403
+ selectNode(field.key);
404
+ };
405
+ row.onkeydown = (event) => {
406
+ if (event.key === 'Enter' || event.key === ' ') {
407
+ event.preventDefault();
408
+ event.stopPropagation();
409
+ selectNode(field.key);
410
+ }
411
+ };
412
+ group.append(row);
413
+ nodeElements.set(field.key, row);
414
+ }
415
+ const footerY = height - 34;
416
+ group.append(vector('line', { x1: 0, x2: TABLE_WIDTH, y1: footerY, y2: footerY, class: 'table-divider' }));
417
+ const states = model.nodes.filter((n) => n.kind === 'state' && n.id.startsWith(`${node.id}.`)).length;
418
+ group.append(vector('text', { x: 14, y: footerY + 21, class: 'table-footer' }, `${fields.length} 字段 · ${states} 状态`));
419
+ group.append(vector('text', { x: TABLE_WIDTH - 13, y: footerY + 21, 'text-anchor': 'end', class: 'table-sql' }, shortName(String(node.attrs.table), 28)));
420
+ nodeLayer.append(group);
421
+ nodeElements.set(node.key, group);
422
+ }
423
+ const nodeSize = (key) => mode === 'structure'
424
+ ? { width: TABLE_WIDTH, height: tableOwner(visibleCards, key).height }
425
+ : { width: NODE_WIDTH, height: NODE_HEIGHT };
426
+ function updatePositions() {
427
+ for (const n of visibleNodes) {
428
+ const p = positions.get(n.key);
429
+ nodeElements.get(n.key).setAttribute('transform', `translate(${p.x},${p.y})`);
430
+ }
431
+ // 在曲线上选标签落点,优先避开节点和已放置标签。文字始终贴在线上,
432
+ // 不为避让随意挪到空白区域,免得用户认错对应关系。
433
+ const occupied = visibleNodes.map((node) => ({
434
+ x: positions.get(node.key).x - 8,
435
+ y: positions.get(node.key).y - 8,
436
+ width: nodeSize(node.key).width + 16,
437
+ height: nodeSize(node.key).height + 16,
438
+ }));
439
+ for (const e of visibleEdges) {
440
+ const { path, hit, label, width } = edgeElements.get(e.key);
441
+ const d = mode === 'structure'
442
+ ? tableEdgePath(e, visibleCards, positions)
443
+ : edgePath(positions.get(e.from), positions.get(e.to), e.from === e.to);
444
+ path.setAttribute('d', d);
445
+ hit.setAttribute('d', d);
446
+ const length = path.getTotalLength();
447
+ const candidates = [0.5, 0.35, 0.65, 0.23, 0.77].map((t) => {
448
+ const p = path.getPointAtLength(length * t);
449
+ return { x: p.x - width / 2, y: p.y - 12, width, height: 24 };
450
+ });
451
+ const collisions = (a) => occupied.filter((b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y).length;
452
+ candidates.sort((a, b) => collisions(a) - collisions(b));
453
+ const box = candidates[0];
454
+ label.setAttribute('transform', `translate(${box.x + width / 2},${box.y + 12})`);
455
+ occupied.push(box);
456
+ }
457
+ }
458
+ function highlight() {
459
+ const connected = selectedEdge
460
+ ? new Set([selectedEdge.from, selectedEdge.to])
461
+ : emphasizeSelection && selected && nodeElements.has(selected)
462
+ ? neighborhood(model, selected)
463
+ : null;
464
+ for (const [key, g] of nodeElements) {
465
+ if (mode === 'structure') {
466
+ const owner = tableOwner(visibleCards, key);
467
+ const related = selectedEdge
468
+ ? [selectedEdge.from, selectedEdge.to].some((end) => tableOwner(visibleCards, end)?.node.key === owner.node.key)
469
+ : tableOwner(visibleCards, selected ?? '')?.node.key === owner.node.key;
470
+ g.classList.toggle('selected', g.classList.contains('table-card') ? related : selectedEdge ? connected.has(key) : key === selected);
471
+ // 保留其他表和字段的文字对比度,选中态用边框表达,不把整张数据字典褪成背景。
472
+ g.classList.remove('dim');
473
+ g.setAttribute('aria-pressed', String(!selectedEdge && key === selected));
474
+ continue;
475
+ }
476
+ g.classList.toggle('selected', selectedEdge ? connected.has(key) : key === selected);
477
+ g.classList.toggle('dim', !!connected && !connected.has(key));
478
+ g.setAttribute('aria-pressed', String(!selectedEdge && key === selected));
479
+ }
480
+ for (const e of visibleEdges) {
481
+ const active = selectedEdge
482
+ ? e.key === selectedEdge.key
483
+ : e.from === selected ||
484
+ e.to === selected ||
485
+ (mode === 'structure' && tableOwner(cards, e.from)?.node.key === selected);
486
+ const { group, path } = edgeElements.get(e.key);
487
+ group.classList.toggle('active', active);
488
+ group.classList.toggle('chosen', e.key === selectedEdge?.key);
489
+ group.classList.toggle('dim', !!connected && !active);
490
+ group.classList.toggle('show-label', visibleEdges.length <= 18 || active);
491
+ group.setAttribute('aria-pressed', String(e.key === selectedEdge?.key));
492
+ path.setAttribute('marker-end', active ? 'url(#arrow-active)' : 'url(#arrow)');
493
+ }
494
+ }
495
+ function selectNode(key, reveal = false) {
496
+ searchTarget = null;
497
+ selectedEdge = null;
498
+ selected = key;
499
+ emphasizeSelection = true;
500
+ if (mode === 'structure' && !tableOwner(cards, key)) {
501
+ mode = ['state', 'capability', 'entry'].includes(byKey(key).kind) ? 'all' : 'business';
502
+ enabled = new Set(mode === 'all' ? Object.keys(kinds) : businessKinds);
503
+ aggregate = null;
504
+ focus = null;
505
+ renderFilters();
506
+ renderGraph();
507
+ }
508
+ if (reveal && !nodeElements.has(key)) {
509
+ // 搜索覆盖整张图:命中隐藏类型时显式取消作用域并开启该类型,不能点击后无反应。
510
+ const node = byKey(key);
511
+ enabled.add(node.kind);
512
+ showSystem ||= isSystem(node);
513
+ aggregate = null;
514
+ focus = null;
515
+ renderFilters();
516
+ renderGraph();
517
+ }
518
+ highlight();
519
+ renderInspector();
520
+ if (reveal && mode === 'structure') {
521
+ searchTarget = key;
522
+ requestAnimationFrame(centerSearchResult);
523
+ }
524
+ }
525
+ /** 搜索到长画布下方的字段时将该行移入视口。窄屏详情高度变化可能再次触发
526
+ * ResizeObserver,适应画布后仍需保留搜索位置,否则字段会瞬间跳回屏幕外。 */
527
+ function centerSearchResult() {
528
+ // 等待绘制期间切换视图会清除目标,这一帧就不再执行旧的搜索定位。
529
+ if (!searchTarget)
530
+ return;
531
+ const card = tableOwner(visibleCards, searchTarget);
532
+ const position = positions.get(card.node.key);
533
+ const row = card.fields.findIndex((field) => field.key === searchTarget);
534
+ const offset = row >= 0
535
+ ? TABLE_HEADER + (row + 0.5) * TABLE_ROW
536
+ : Math.min(card.height / 2, svg.clientHeight / (2 * camera.scale));
537
+ camera.x = svg.clientWidth / 2 - (position.x + TABLE_WIDTH / 2) * camera.scale;
538
+ camera.y = svg.clientHeight / 2 - (position.y + offset) * camera.scale;
539
+ applyCamera();
540
+ }
541
+ function selectEdge(edge) {
542
+ searchTarget = null;
543
+ selectedEdge = edge;
544
+ if (mode === 'structure' && edge.kind !== 'refs') {
545
+ // 卡片中包含字段的关系已经用行表达;显式查看其他关系时进入完整本体,
546
+ // 保留原始端点和上一次实现的关系解释,而不是伪造一条表间连线。
547
+ mode = 'all';
548
+ enabled = new Set(Object.keys(kinds));
549
+ aggregate = null;
550
+ focus = null;
551
+ renderFilters();
552
+ }
553
+ // 右侧关系列表也包含当前筛选隐藏的邻居;选中后显式展示两端,解释有图可对照。
554
+ if (!edgeElements.has(edge.key)) {
555
+ for (const key of [edge.from, edge.to]) {
556
+ const node = byKey(key);
557
+ enabled.add(node.kind);
558
+ showSystem ||= isSystem(node);
559
+ }
560
+ aggregate = null;
561
+ focus = null;
562
+ renderFilters();
563
+ renderGraph();
564
+ }
565
+ highlight();
566
+ renderInspector();
567
+ element('inspector').scrollTop = 0;
568
+ }
569
+ function renderEdgeInspector(panel, edge) {
570
+ const explanation = explainEdge(edge, model, snapshot.presentation.labels);
571
+ const top = html('div', 'inspector-top');
572
+ top.append(html('span', '', '关系说明'), html('span', '', edge.derived ? '虚线 · 视图推导' : '实线 · 声明抽取'));
573
+ panel.append(top, html('span', 'relation-category', explanation.category), html('h2', '', explanation.label));
574
+ const route = html('div', 'relation-route');
575
+ for (const [index, key] of [edge.from, edge.to].entries()) {
576
+ if (index)
577
+ route.append(html('span', 'relation-arrow', `↓ ${explanation.label}`));
578
+ const node = byKey(key);
579
+ const button = html('button', 'relation-endpoint');
580
+ button.title = `查看${displayName(node)}的节点详情`;
581
+ button.append(html('small', '', `${index ? '终点' : '起点'} · ${kinds[node.kind].label}`), html('strong', '', displayName(node)), html('code', '', node.id));
582
+ button.onclick = () => selectNode(key, true);
583
+ route.append(button);
584
+ }
585
+ panel.append(route);
586
+ const section = (title, text) => {
587
+ const box = html('section', 'relation-explanation');
588
+ box.append(html('h3', '', title), html('p', '', text));
589
+ panel.append(box);
590
+ return box;
591
+ };
592
+ section('为什么连接', explanation.why);
593
+ section('连接有什么用', explanation.purpose);
594
+ section('箭头怎么读', explanation.direction);
595
+ const source = section('声明依据', '以下是抽取到的声明属性,可与源码核对。');
596
+ for (const evidence of explanation.evidence)
597
+ source.append(html('div', 'source-location', evidence));
598
+ const node = explanation.source;
599
+ const owner = model.nodes.find((n) => n.kind === 'aggregate' && node.id.startsWith(`${n.id}.`));
600
+ const location = node.file ? node : owner?.file ? owner : null;
601
+ source.append(html('p', 'source-note', location
602
+ ? `${location.file}:${location.line ?? 1}${location !== node ? '(所属业务对象声明)' : ''}`
603
+ : '来自框架包,当前项目未记录源码位置。'));
604
+ }
605
+ function renderInspector() {
606
+ const panel = element('inspector');
607
+ panel.replaceChildren();
608
+ if (selectedEdge) {
609
+ renderEdgeInspector(panel, selectedEdge);
610
+ return;
611
+ }
612
+ if (!selected) {
613
+ panel.append(html('p', 'inspector-description', '选择节点查看属性,或点击连线了解为什么连接、连接有什么用。'));
614
+ return;
615
+ }
616
+ const node = byKey(selected), meta = kinds[node.kind];
617
+ const top = html('div', 'inspector-top');
618
+ top.append(html('span', '', '节点详情'), html('span', '', '↗'));
619
+ panel.append(top);
620
+ const icon = html('div', 'inspector-icon', meta.short);
621
+ icon.style.color = meta.color;
622
+ icon.style.backgroundColor = `${meta.color}12`;
623
+ panel.append(icon);
624
+ panel.append(html('h2', '', displayName(node)), html('div', 'node-identifier', node.id));
625
+ const pill = html('span', 'kind-pill', meta.label);
626
+ pill.style.color = meta.color;
627
+ pill.style.backgroundColor = `${meta.color}12`;
628
+ panel.append(pill);
629
+ panel.append(html('p', 'inspector-description', String(node.attrs.description ?? descriptions[node.kind])));
630
+ if (node.kind === 'aggregate') {
631
+ const note = html('div', 'aggregate-explainer');
632
+ note.append(html('strong', '', '什么是业务对象(聚合)?'), html('p', '', '框架把一组需要一起遵守业务规则的数据和操作作为一个整体管理,技术上称为“聚合”。它包含字段、状态和命令,不一定只对应一张数据库表。'));
633
+ panel.append(note);
634
+ }
635
+ const actions = html('div', 'inspector-actions');
636
+ const focusButton = html('button', 'button secondary', '◎ 聚焦关联');
637
+ focusButton.onclick = () => {
638
+ focus = node.key;
639
+ aggregate = null;
640
+ if (mode === 'structure') {
641
+ const card = tableOwner(cards, node.key);
642
+ const refs = model.edges.filter((edge) => edge.kind === 'refs' &&
643
+ (edge.to === card.node.key || card.fields.some((field) => field.key === edge.from)));
644
+ showSystem ||= refs.some((edge) => isSystem(byKey(edge.to)) || isSystem(byKey(edge.from)));
645
+ renderFilters();
646
+ renderGraph();
647
+ return;
648
+ }
649
+ const adjacent = neighborhood(model, node.key);
650
+ for (const n of model.nodes.filter((n) => adjacent.has(n.key))) {
651
+ enabled.add(n.kind);
652
+ showSystem ||= isSystem(n);
653
+ }
654
+ renderFilters();
655
+ renderGraph();
656
+ };
657
+ const copy = html('button', 'button secondary', '复制标识');
658
+ copy.onclick = async () => {
659
+ try {
660
+ await navigator.clipboard.writeText(node.id);
661
+ copy.textContent = '已复制';
662
+ }
663
+ catch (error) {
664
+ notify(`复制失败:${error.message}`);
665
+ }
666
+ };
667
+ actions.append(focusButton, copy);
668
+ panel.append(actions);
669
+ const section = (title, count) => {
670
+ const div = html('section', 'detail-section'), h = html('h3', '', title);
671
+ if (count !== undefined)
672
+ h.append(html('span', '', String(count)));
673
+ div.append(h);
674
+ panel.append(div);
675
+ return div;
676
+ };
677
+ const info = section('声明属性');
678
+ const row = (label, value) => {
679
+ if (value === undefined || value === null)
680
+ return;
681
+ const div = html('div', 'detail-row');
682
+ div.append(html('span', '', label), html('span', '', typeof value === 'object' ? JSON.stringify(value) : String(value)));
683
+ info.append(div);
684
+ };
685
+ row('类型', meta.label);
686
+ if (node.kind === 'aggregate') {
687
+ row('数据表', node.attrs.table);
688
+ const scope = node.attrs.scope;
689
+ row('部门范围', scope?.dept ? named(`${node.id}.${scope.dept}`, 'field') : undefined);
690
+ row('归属字段', scope?.owner ? named(`${node.id}.${scope.owner}`, 'field') : undefined);
691
+ row('状态数量', model.nodes.filter((n) => n.kind === 'state' && n.id.startsWith(`${node.id}.`)).length);
692
+ row('命令数量', model.nodes.filter((n) => n.kind === 'command' && n.id.startsWith(`${node.id}.`)).length);
693
+ }
694
+ else if (node.kind === 'command') {
695
+ const agg = node.id.split('.')[0];
696
+ const stateName = (state) => (state === 'new' ? '新建' : named(`${agg}.${state}`, 'state'));
697
+ row('起始状态', Array.isArray(node.attrs.from)
698
+ ? node.attrs.from.map(stateName).join('、')
699
+ : stateName(String(node.attrs.from)));
700
+ row('目标状态', node.attrs.to ? stateName(String(node.attrs.to)) : '保持当前状态');
701
+ const actors = {
702
+ user: '用户',
703
+ process: '审批流程',
704
+ external: '外部系统',
705
+ job: '定时任务',
706
+ reaction: '事件反应',
707
+ system: '系统',
708
+ };
709
+ row('调用身份', node.attrs.invokedBy.map((kind) => actors[kind] ?? kind).join('、'));
710
+ row('约束', node.attrs.constraints.map((name) => constraintLabel(name, agg)).join(';') || '无');
711
+ }
712
+ else {
713
+ for (const [key, label] of Object.entries({
714
+ event: '事件',
715
+ command: '调用命令',
716
+ process: '审批流程',
717
+ cron: '调度表达式',
718
+ connector: '连接器',
719
+ operation: '操作',
720
+ sensitive: '敏感类型',
721
+ kind: '字段类型',
722
+ ref: '引用对象',
723
+ optional: '可选字段',
724
+ acquires: '获取资源',
725
+ releases: '释放资源',
726
+ terminal: '终态',
727
+ initial: '初态',
728
+ })) {
729
+ const value = node.attrs[key];
730
+ if (value === undefined || value === null)
731
+ continue;
732
+ row(label, detailValue(key, value));
733
+ }
734
+ }
735
+ const source = section('声明位置');
736
+ // 字段、状态没有独立源码位置时只引用所属聚合,并在文案上说明来源。
737
+ const owner = model.nodes.find((n) => n.kind === 'aggregate' && node.id.startsWith(`${n.id}.`));
738
+ const location = node.file ? node : owner?.file ? owner : null;
739
+ if (location) {
740
+ source.append(html('div', 'source-location', `${location.file}:${location.line ?? 1}`));
741
+ if (location !== node)
742
+ source.append(html('p', 'source-note', '位置来自所属业务对象声明。'));
743
+ }
744
+ else
745
+ source.append(html('p', 'source-note', node.kind === 'entry' ? '由新建命令的迁移关系推导。' : '来自框架包,当前项目未记录源码位置。'));
746
+ const relations = model.edges.filter((e) => e.from === node.key || e.to === node.key);
747
+ const relationSection = section('关联关系', relations.length);
748
+ if (relations.length)
749
+ relationSection.append(html('p', 'source-note', '点击一项,查看连接原因与用途。'));
750
+ if (!relations.length)
751
+ relationSection.append(html('p', 'source-note', '当前声明没有关联关系。'));
752
+ for (const edge of relations) {
753
+ const explanation = explainEdge(edge, model, snapshot.presentation.labels);
754
+ const outgoing = edge.from === node.key;
755
+ const target = byKey(outgoing ? edge.to : edge.from);
756
+ const button = html('button', 'relation-button');
757
+ const dot = html('span', 'relation-dot');
758
+ dot.style.backgroundColor = kinds[target.kind].color;
759
+ const text = html('div');
760
+ text.append(html('strong', '', displayName(target)), html('small', '', `${outgoing ? '→' : '←'} ${explanation.label}`), html('small', 'relation-code', target.id));
761
+ button.append(dot, text, html('span', '', '解释 ›'));
762
+ button.onclick = () => selectEdge(edge);
763
+ relationSection.append(button);
764
+ }
765
+ const details = html('details');
766
+ details.append(html('summary', '', '查看声明元数据'), html('pre', 'detail-json', JSON.stringify(node.attrs, null, 2)));
767
+ panel.append(details);
768
+ }
769
+ /** 把会直接影响阅读理解的属性值翻译为业务名称;SQL 表名、cron 和折叠的
770
+ * 原始元数据仍保持技术原文,方便开发者核对实现。 */
771
+ function detailValue(key, value) {
772
+ if (typeof value === 'boolean')
773
+ return value ? '是' : '否';
774
+ if (key === 'event' || key === 'command')
775
+ return named(value, key);
776
+ if (key === 'ref')
777
+ return named(value, 'aggregate');
778
+ if (key === 'process') {
779
+ const binding = model.nodes.find((n) => n.kind === 'approval' && n.attrs.process === value);
780
+ return binding ? displayName(binding) : String(value);
781
+ }
782
+ if (key === 'connector')
783
+ return named(`connector:${value}`, 'connector');
784
+ if (key === 'acquires' || key === 'releases')
785
+ return snapshot.presentation.labels[`resource:${value}`] ?? String(value);
786
+ if (key === 'operation')
787
+ return snapshot.presentation.labels[`operation:${value}`] ?? String(value);
788
+ const types = {
789
+ money: '金额',
790
+ text: '文本',
791
+ int: '整数',
792
+ decimal: '小数',
793
+ float: '浮点数',
794
+ bool: '布尔值',
795
+ boolean: '布尔值',
796
+ id: '唯一标识',
797
+ ref: '关联引用',
798
+ list: '列表',
799
+ json: '结构化数据',
800
+ date: '日期',
801
+ datetime: '日期时间',
802
+ enum: '枚举',
803
+ phone: '电话号码',
804
+ email: '电子邮箱',
805
+ name: '姓名',
806
+ };
807
+ return ['kind', 'sensitive'].includes(key) ? (types[String(value)] ?? String(value)) : String(value);
808
+ }
809
+ function constraintLabel(name, aggregateId) {
810
+ const ceiling = /^ceiling\((.+)\)$/.exec(name);
811
+ if (ceiling)
812
+ return `${named(`${aggregateId}.${ceiling[1]}`, 'field')}不超过审批额度`;
813
+ const sod = /^sod\.notSameAs\((.+)\)$/.exec(name);
814
+ if (sod)
815
+ return `审批人与${named(`${aggregateId}.${sod[1]}`, 'field')}不同`;
816
+ const builtins = {
817
+ stepUp: '需要二次认证',
818
+ knownCapability: '能力点必须已声明',
819
+ noEscalation: '不能授予超出自身的能力',
820
+ noWiderScope: '不能授予超出自身的数据范围',
821
+ noEscalationViaRoles: '不能通过分配角色提权',
822
+ };
823
+ return builtins[name] ?? name;
824
+ }
825
+ const descriptions = {
826
+ aggregate: '统一管理字段、状态、业务操作和数据范围的业务对象。',
827
+ command: '受权限和状态约束的业务写入入口。',
828
+ event: '命令产生的领域事件,用于连接后续业务协作。',
829
+ reaction: '订阅领域事件并调用命令,可声明资源获取与释放。',
830
+ query: '通过已声明的读依赖查询安全视图。',
831
+ approval: '把业务事件、审批流程与结论命令连接起来。',
832
+ route: '代码中声明的审批步骤与分支条件。',
833
+ job: '按调度表达式触发、显式声明读取与调用范围的任务。',
834
+ connector: '声明第三方地址及可调用的操作。',
835
+ outbound: '将领域事件映射为第三方调用。',
836
+ inbound: '将验签后的外部回调映射为业务命令。',
837
+ field: '业务字段的类型、范围与敏感属性。',
838
+ state: '业务对象生命周期中的一个状态。',
839
+ capability: '从聚合声明派生的访问能力点。',
840
+ entry: '新建命令进入初始业务状态的虚拟起点。',
841
+ };
842
+ function renderFindings() {
843
+ const list = element('findings-list');
844
+ list.replaceChildren();
845
+ for (const f of snapshot.findings) {
846
+ const item = html('article', 'finding');
847
+ item.append(html('code', '', f.code), html('p', '', f.reason));
848
+ if (f.fix)
849
+ item.append(html('p', '', `修复:${f.fix}`));
850
+ if (f.at)
851
+ item.append(html('small', '', f.at));
852
+ list.append(item);
853
+ }
854
+ }
855
+ function searchNodes() {
856
+ const query = element('search').value.trim().toLowerCase();
857
+ const results = element('search-results');
858
+ results.replaceChildren();
859
+ results.hidden = !query;
860
+ if (!query)
861
+ return;
862
+ const matches = model.nodes.filter((n) => matchesNode(n, query, snapshot.presentation.labels));
863
+ for (const n of matches.slice(0, 30)) {
864
+ const button = html('button', 'search-result');
865
+ const names = html('span', 'search-names');
866
+ names.append(html('strong', '', displayName(n)), html('span', '', n.id));
867
+ button.append(html('small', '', kinds[n.kind].label), names);
868
+ button.onclick = () => {
869
+ selectNode(n.key, true);
870
+ results.hidden = true;
871
+ element('search').value = '';
872
+ };
873
+ results.append(button);
874
+ }
875
+ if (!matches.length)
876
+ results.append(html('div', 'search-empty', '没有匹配的声明,试试命令名或字段名。'));
877
+ }
878
+ function applyCamera() {
879
+ viewport.setAttribute('transform', `translate(${camera.x},${camera.y}) scale(${camera.scale})`);
880
+ element('zoom-level').textContent = `${Math.round(camera.scale * 100)}%`;
881
+ }
882
+ function fit() {
883
+ if (!visibleNodes.length)
884
+ return;
885
+ const bounds = svg.getBoundingClientRect();
886
+ const xs = [...positions.values()].map((p) => p.x), ys = [...positions.values()].map((p) => p.y);
887
+ const minX = Math.min(...xs), minY = Math.min(...ys), width = Math.max(...visibleNodes.map((node) => positions.get(node.key).x + nodeSize(node.key).width)) - minX, height = Math.max(...visibleNodes.map((node) => positions.get(node.key).y + nodeSize(node.key).height)) - minY;
888
+ camera.scale = Math.min((bounds.width - 65) / width, (bounds.height - 85) / height, mode === 'structure' ? 1 : 1.2);
889
+ // 表字段是正文,默认不把它们缩成难读的小字。长表可纵向平移,适当留白
890
+ // 比为了容纳全图而把所有字段缩到 35% 更适合阅读。
891
+ if (mode === 'structure')
892
+ camera.scale = Math.max(Math.min(0.8, (bounds.width - 40) / TABLE_WIDTH), camera.scale);
893
+ camera.x = (bounds.width - width * camera.scale) / 2 - minX * camera.scale;
894
+ camera.y =
895
+ (mode === 'structure'
896
+ ? Math.max(30, (bounds.height - height * camera.scale) / 2)
897
+ : (bounds.height - height * camera.scale) / 2 - 7) -
898
+ minY * camera.scale;
899
+ applyCamera();
900
+ if (mode === 'structure' && searchTarget)
901
+ centerSearchResult();
902
+ }
903
+ function zoom(factor, x = svg.clientWidth / 2, y = svg.clientHeight / 2) {
904
+ searchTarget = null;
905
+ const scale = Math.min(2.5, Math.max(0.15, camera.scale * factor));
906
+ camera.x = x - ((x - camera.x) * scale) / camera.scale;
907
+ camera.y = y - ((y - camera.y) * scale) / camera.scale;
908
+ camera.scale = scale;
909
+ applyCamera();
910
+ }
911
+ // 指针捕获同时支持鼠标和触控。节点拖动修改布局坐标,空白拖动修改视口;
912
+ // 拖动结束不触发选择,避免移动节点时不断切换右侧详情。
913
+ let drag = null;
914
+ svg.addEventListener('pointerdown', (event) => {
915
+ if (event.button !== 0)
916
+ return;
917
+ searchTarget = null;
918
+ // 连线由自身 click/键盘处理;不进入画布平移,否则 pointer capture 会吞掉点击。
919
+ if (event.target.closest('.edge-group'))
920
+ return;
921
+ if (event.target.closest('.table-field'))
922
+ return;
923
+ const target = event.target.closest('.node');
924
+ const key = target?.dataset.key;
925
+ drag = {
926
+ id: event.pointerId,
927
+ key,
928
+ x: event.clientX,
929
+ y: event.clientY,
930
+ initial: key ? { ...positions.get(key) } : { ...camera },
931
+ moved: false,
932
+ };
933
+ svg.setPointerCapture(event.pointerId);
934
+ svg.classList.add('dragging');
935
+ });
936
+ svg.addEventListener('pointermove', (event) => {
937
+ if (!drag || drag.id !== event.pointerId)
938
+ return;
939
+ const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
940
+ drag.moved ||= Math.abs(dx) + Math.abs(dy) > 4;
941
+ if (drag.key) {
942
+ positions.set(drag.key, { x: drag.initial.x + dx / camera.scale, y: drag.initial.y + dy / camera.scale });
943
+ updatePositions();
944
+ }
945
+ else {
946
+ camera.x = drag.initial.x + dx;
947
+ camera.y = drag.initial.y + dy;
948
+ applyCamera();
949
+ }
950
+ });
951
+ const endDrag = (event) => {
952
+ if (!drag || drag.id !== event.pointerId)
953
+ return;
954
+ if (event.type === 'pointerup' && !drag.moved) {
955
+ if (drag.key)
956
+ selectNode(drag.key);
957
+ else {
958
+ selected = null;
959
+ selectedEdge = null;
960
+ highlight();
961
+ renderInspector();
962
+ }
963
+ }
964
+ drag = null;
965
+ svg.classList.remove('dragging');
966
+ };
967
+ svg.addEventListener('pointerup', endDrag);
968
+ svg.addEventListener('pointercancel', endDrag);
969
+ svg.addEventListener('wheel', (event) => {
970
+ event.preventDefault();
971
+ const rect = svg.getBoundingClientRect();
972
+ zoom(Math.exp(-event.deltaY * 0.0015), event.clientX - rect.left, event.clientY - rect.top);
973
+ }, { passive: false });
974
+ new ResizeObserver(() => {
975
+ if (snapshot) {
976
+ if (mode === 'structure') {
977
+ positions = layoutTables(visibleCards, visibleEdges, svg.clientWidth < 680 ? 1 : 2);
978
+ updatePositions();
979
+ }
980
+ fit();
981
+ }
982
+ }).observe(svg);
983
+ function resetView() {
984
+ mode = 'structure';
985
+ aggregate = null;
986
+ focus = null;
987
+ showSystem = false;
988
+ enabled = new Set(businessKinds);
989
+ renderFilters();
990
+ renderGraph();
991
+ }
992
+ element('reset-view').onclick = resetView;
993
+ element('reset-types').onclick = () => {
994
+ enabled = new Set(mode === 'business' ? businessKinds : Object.keys(kinds));
995
+ renderFilters();
996
+ renderGraph();
997
+ };
998
+ element('exit-focus').onclick = () => {
999
+ focus = null;
1000
+ renderGraph();
1001
+ };
1002
+ element('system-toggle').onchange = (event) => {
1003
+ showSystem = event.target.checked;
1004
+ aggregate = null;
1005
+ focus = null;
1006
+ renderFilters();
1007
+ renderGraph();
1008
+ };
1009
+ document.querySelectorAll('[data-mode]').forEach((button) => {
1010
+ button.onclick = () => {
1011
+ mode = button.dataset.mode;
1012
+ selectedEdge = null;
1013
+ enabled = new Set(mode === 'business' ? businessKinds : Object.keys(kinds));
1014
+ focus = null;
1015
+ if (mode === 'structure' && selected)
1016
+ selected =
1017
+ tableOwner(cards, selected)?.node.key ?? cards.find((card) => !isSystem(card.node))?.node.key ?? null;
1018
+ renderFilters();
1019
+ renderGraph();
1020
+ renderInspector();
1021
+ };
1022
+ });
1023
+ element('refresh').onclick = () => void load(true);
1024
+ element('search').addEventListener('input', searchNodes);
1025
+ element('search').addEventListener('focus', searchNodes);
1026
+ element('search').addEventListener('keydown', (event) => {
1027
+ if (event.key === 'Enter')
1028
+ element('search-results').querySelector('button')?.click();
1029
+ if (event.key === 'Escape')
1030
+ element('search-results').hidden = true;
1031
+ });
1032
+ document.addEventListener('pointerdown', (event) => {
1033
+ if (!event.target.closest('.search-wrap'))
1034
+ element('search-results').hidden = true;
1035
+ });
1036
+ document.addEventListener('keydown', (event) => {
1037
+ if (event.key === '/' &&
1038
+ !(event.target instanceof HTMLInputElement) &&
1039
+ !element('findings-dialog').open) {
1040
+ event.preventDefault();
1041
+ element('search').focus();
1042
+ }
1043
+ });
1044
+ element('zoom-in').onclick = () => zoom(1.2);
1045
+ element('zoom-out').onclick = () => zoom(1 / 1.2);
1046
+ element('fit').onclick = () => {
1047
+ searchTarget = null;
1048
+ fit();
1049
+ };
1050
+ element('relayout').onclick = () => renderGraph();
1051
+ element('show-findings').onclick = () => element('findings-dialog').showModal();
1052
+ element('close-findings').onclick = () => element('findings-dialog').close();
1053
+ element('toggle-sidebar').onclick = () => {
1054
+ const open = element('sidebar').classList.toggle('open');
1055
+ element('toggle-sidebar').setAttribute('aria-expanded', String(open));
1056
+ };
1057
+ element('download').onclick = () => {
1058
+ const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot.graph, null, 2)], { type: 'application/json' }));
1059
+ const link = html('a');
1060
+ link.href = url;
1061
+ link.download = 'ontology.json';
1062
+ link.click();
1063
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1064
+ };
1065
+ void load();