Commit 1946deb75138f5ea9f58f53cd3cec8c236c1f78f

Authored by zichun
1 parent dddde8da

feat: 模型可视化 + 结构化/源码编辑工作台

从 onto-app 迁移「模型可视化 + 模型编辑器」。onto-app 的 Python 侧 IR 构建改为前端
TypeScript 实现(读现有 /api/meta/model 动态合成本体 IR),只读可视化不改后端;编辑器
新增一个引擎侧保存接口。

前端 web/src:
- ontology/{types,build,bpmn}.ts:从 M1/M2/ME/MetaRule/M4 合成统一本体 IR + BPMN 骨架
- viz/:ERDiagram(xyflow)、NetworkGraph(cytoscape)、ProcessDiagram(bpmn-js+elk)、OntologyCanvas 宿主
- model/ModelEditor.tsx + editSession.ts:结构预览 +(对象/属性/约束/规则/事件)结构化编辑
  (yaml 按路径改、保留注释)+ 逐层源码编辑;保存即热重载
- App.tsx 顶部视图切换;重量级视图懒加载;api.ts 增 saveModel
- vite dev 代理 8091→8080(对齐引擎实际端口)

引擎 engine:
- MetaController:新增 PUT /api/meta/model/{code} 保存(管理员校验 + YAML 解析校验 + 热重载);
  GET /model/{code} 增返回 raw 源文件原文
- ModelRepository:writeRawLayer / rawFileText
engine/src/main/java/com/onto/engine/model/ModelRepository.java
@@ -96,6 +96,42 @@ public class ModelRepository { @@ -96,6 +96,42 @@ public class ModelRepository {
96 /** 返回某层解析后的原始对象(供模型查看器/前端"运行原理"面板展示)。 */ 96 /** 返回某层解析后的原始对象(供模型查看器/前端"运行原理"面板展示)。 */
97 public Object rawLayer(String code) { return layers.get(code); } 97 public Object rawLayer(String code) { return layers.get(code); }
98 98
  99 + /** 返回某层 YAML 源文件的原始文本(保留注释/顺序,供模型编辑器编辑)。 */
  100 + public String rawFileText(String code) {
  101 + String fileName = LAYER_FILES.get(code);
  102 + if (fileName == null) return null;
  103 + Path dir = resolvedDir != null ? resolvedDir : resolveModelsDir();
  104 + try {
  105 + return Files.readString(dir.resolve(fileName));
  106 + } catch (Exception ex) {
  107 + log.warn("读取模型源文件失败: {} -> {}", code, ex.getMessage());
  108 + return null;
  109 + }
  110 + }
  111 +
  112 + /**
  113 + * 覆盖写入某层 YAML 源文件(模型编辑器保存用)。先校验能被解析,避免把源文件写坏、
  114 + * 导致后续 reload 全线失败。写入后需由调用方触发 reload 才会生效。返回写入的文件路径。
  115 + */
  116 + public synchronized Path writeRawLayer(String code, String yamlText) {
  117 + String fileName = LAYER_FILES.get(code);
  118 + if (fileName == null) throw new IllegalArgumentException("未知模型层: " + code);
  119 + try {
  120 + new Yaml(new LoaderOptions()).load(yamlText); // 解析校验:不通过则拒绝写入
  121 + } catch (Exception ex) {
  122 + throw new IllegalArgumentException("YAML 解析失败,未写入: " + ex.getMessage(), ex);
  123 + }
  124 + Path dir = resolvedDir != null ? resolvedDir : resolveModelsDir();
  125 + Path f = dir.resolve(fileName);
  126 + try {
  127 + Files.writeString(f, yamlText);
  128 + } catch (Exception ex) {
  129 + throw new IllegalStateException("写入模型文件失败: " + f + " -> " + ex.getMessage(), ex);
  130 + }
  131 + log.info("模型层已保存: {} -> {}", code, f.toAbsolutePath());
  132 + return f;
  133 + }
  134 +
99 @SuppressWarnings("unchecked") 135 @SuppressWarnings("unchecked")
100 static Map<String, Object> asMap(Object o) { 136 static Map<String, Object> asMap(Object o) {
101 return o instanceof Map ? (Map<String, Object>) o : Collections.emptyMap(); 137 return o instanceof Map ? (Map<String, Object>) o : Collections.emptyMap();
engine/src/main/java/com/onto/engine/web/MetaController.java
@@ -90,6 +90,35 @@ public class MetaController { @@ -90,6 +90,35 @@ public class MetaController {
90 out.put("code", code); 90 out.put("code", code);
91 out.put("fileName", models.fileNameOf(code)); 91 out.put("fileName", models.fileNameOf(code));
92 out.put("content", models.rawLayer(code)); 92 out.put("content", models.rawLayer(code));
  93 + out.put("raw", models.rawFileText(code)); // 源文件原文(保留注释),供模型编辑器编辑
  94 + return out;
  95 + }
  96 +
  97 + /**
  98 + * 保存某一层模型 YAML 源文件(模型编辑器用),写入后立即热重载生效。
  99 + * 请求体:{ "content": "<完整 YAML 文本>" }。与热重载同级:仅管理员可保存。
  100 + */
  101 + @PutMapping("/model/{code}")
  102 + public Map<String, Object> saveModel(@PathVariable String code,
  103 + @RequestBody Map<String, Object> body,
  104 + @RequestHeader(value = "X-Principal", required = false) String principal) {
  105 + requireAdmin(principal, "保存模型");
  106 + Object content = body == null ? null : body.get("content");
  107 + if (!(content instanceof String yaml) || yaml.isBlank()) {
  108 + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "缺少 content(YAML 文本)");
  109 + }
  110 + try {
  111 + models.writeRawLayer(code, yaml);
  112 + } catch (IllegalArgumentException ex) {
  113 + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage());
  114 + }
  115 + models.reload(); // 写入后立即生效(含 M1/M3 变更 → 补表)
  116 + schemaInitializer.refresh();
  117 + Map<String, Object> out = new LinkedHashMap<>();
  118 + out.put("saved", true);
  119 + out.put("code", code);
  120 + out.put("fileName", models.fileNameOf(code));
  121 + out.put("message", "模型 " + code + " 已保存并热重载生效");
93 return out; 122 return out;
94 } 123 }
95 124
@@ -99,11 +128,7 @@ public class MetaController { @@ -99,11 +128,7 @@ public class MetaController {
99 */ 128 */
100 @PostMapping("/reload") 129 @PostMapping("/reload")
101 public Map<String, Object> reload(@RequestHeader(value = "X-Principal", required = false) String principal) { 130 public Map<String, Object> reload(@RequestHeader(value = "X-Principal", required = false) String principal) {
102 - List<Object> admins = asList(models.frontGlobalConfig().get("dragRolePermission"));  
103 - if (!admins.isEmpty() && !admins.contains(principal)) {  
104 - throw new ResponseStatusException(HttpStatus.FORBIDDEN,  
105 - "仅管理员 " + admins + " 可热重载模型(当前主体:" + principal + ")");  
106 - } 131 + requireAdmin(principal, "热重载模型");
107 models.reload(); 132 models.reload();
108 schemaInitializer.refresh(); 133 schemaInitializer.refresh();
109 Map<String, Object> out = new LinkedHashMap<>(); 134 Map<String, Object> out = new LinkedHashMap<>();
@@ -113,6 +138,15 @@ public class MetaController { @@ -113,6 +138,15 @@ public class MetaController {
113 return out; 138 return out;
114 } 139 }
115 140
  141 + /** 仅 M8 dragRolePermission 声明的管理员可执行敏感操作(保存/热重载)。 */
  142 + private void requireAdmin(String principal, String action) {
  143 + List<Object> admins = asList(models.frontGlobalConfig().get("dragRolePermission"));
  144 + if (!admins.isEmpty() && !admins.contains(principal)) {
  145 + throw new ResponseStatusException(HttpStatus.FORBIDDEN,
  146 + "仅管理员 " + admins + " 可" + action + "(当前主体:" + principal + ")");
  147 + }
  148 + }
  149 +
116 @SuppressWarnings("unchecked") 150 @SuppressWarnings("unchecked")
117 private static List<Object> asList(Object o) { 151 private static List<Object> asList(Object o) {
118 return o instanceof List ? (List<Object>) o : java.util.Collections.emptyList(); 152 return o instanceof List ? (List<Object>) o : java.util.Collections.emptyList();
web/package-lock.json
@@ -9,10 +9,18 @@ @@ -9,10 +9,18 @@
9 "version": "2.0.0", 9 "version": "2.0.0",
10 "dependencies": { 10 "dependencies": {
11 "@ant-design/icons": "^5.5.1", 11 "@ant-design/icons": "^5.5.1",
  12 + "@dagrejs/dagre": "^3.0.0",
  13 + "@xyflow/react": "^12.11.2",
12 "antd": "^5.21.6", 14 "antd": "^5.21.6",
  15 + "bpmn-elk-layout": "^1.4.0",
  16 + "bpmn-js": "^18.22.0",
  17 + "cytoscape": "^3.34.0",
  18 + "cytoscape-fcose": "^2.2.0",
13 "dayjs": "^1.11.13", 19 "dayjs": "^1.11.13",
  20 + "js-yaml": "^4.3.0",
14 "react": "^18.3.1", 21 "react": "^18.3.1",
15 - "react-dom": "^18.3.1" 22 + "react-dom": "^18.3.1",
  23 + "yaml": "^2.9.0"
16 }, 24 },
17 "devDependencies": { 25 "devDependencies": {
18 "@types/react": "^18.3.12", 26 "@types/react": "^18.3.12",
@@ -410,6 +418,31 @@ @@ -410,6 +418,31 @@
410 "node": ">=6.9.0" 418 "node": ">=6.9.0"
411 } 419 }
412 }, 420 },
  421 + "node_modules/@bpmn-io/diagram-js-ui": {
  422 + "version": "0.2.4",
  423 + "resolved": "https://registry.npmjs.org/@bpmn-io/diagram-js-ui/-/diagram-js-ui-0.2.4.tgz",
  424 + "integrity": "sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==",
  425 + "license": "MIT",
  426 + "dependencies": {
  427 + "htm": "^3.1.1",
  428 + "preact": "^10.29.2"
  429 + }
  430 + },
  431 + "node_modules/@dagrejs/dagre": {
  432 + "version": "3.0.0",
  433 + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
  434 + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
  435 + "license": "MIT",
  436 + "dependencies": {
  437 + "@dagrejs/graphlib": "4.0.1"
  438 + }
  439 + },
  440 + "node_modules/@dagrejs/graphlib": {
  441 + "version": "4.0.1",
  442 + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
  443 + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
  444 + "license": "MIT"
  445 + },
413 "node_modules/@emotion/hash": { 446 "node_modules/@emotion/hash": {
414 "version": "0.8.0", 447 "version": "0.8.0",
415 "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", 448 "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
@@ -1451,6 +1484,55 @@ @@ -1451,6 +1484,55 @@
1451 "@babel/types": "^7.28.2" 1484 "@babel/types": "^7.28.2"
1452 } 1485 }
1453 }, 1486 },
  1487 + "node_modules/@types/d3-color": {
  1488 + "version": "3.1.3",
  1489 + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
  1490 + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
  1491 + "license": "MIT"
  1492 + },
  1493 + "node_modules/@types/d3-drag": {
  1494 + "version": "3.0.7",
  1495 + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
  1496 + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
  1497 + "license": "MIT",
  1498 + "dependencies": {
  1499 + "@types/d3-selection": "*"
  1500 + }
  1501 + },
  1502 + "node_modules/@types/d3-interpolate": {
  1503 + "version": "3.0.4",
  1504 + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
  1505 + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
  1506 + "license": "MIT",
  1507 + "dependencies": {
  1508 + "@types/d3-color": "*"
  1509 + }
  1510 + },
  1511 + "node_modules/@types/d3-selection": {
  1512 + "version": "3.0.11",
  1513 + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
  1514 + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
  1515 + "license": "MIT"
  1516 + },
  1517 + "node_modules/@types/d3-transition": {
  1518 + "version": "3.0.9",
  1519 + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
  1520 + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
  1521 + "license": "MIT",
  1522 + "dependencies": {
  1523 + "@types/d3-selection": "*"
  1524 + }
  1525 + },
  1526 + "node_modules/@types/d3-zoom": {
  1527 + "version": "3.0.8",
  1528 + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
  1529 + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
  1530 + "license": "MIT",
  1531 + "dependencies": {
  1532 + "@types/d3-interpolate": "*",
  1533 + "@types/d3-selection": "*"
  1534 + }
  1535 + },
1454 "node_modules/@types/estree": { 1536 "node_modules/@types/estree": {
1455 "version": "1.0.9", 1537 "version": "1.0.9",
1456 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", 1538 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1462,14 +1544,14 @@ @@ -1462,14 +1544,14 @@
1462 "version": "15.7.15", 1544 "version": "15.7.15",
1463 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", 1545 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1464 "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", 1546 "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1465 - "dev": true, 1547 + "devOptional": true,
1466 "license": "MIT" 1548 "license": "MIT"
1467 }, 1549 },
1468 "node_modules/@types/react": { 1550 "node_modules/@types/react": {
1469 "version": "18.3.31", 1551 "version": "18.3.31",
1470 "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", 1552 "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1471 "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", 1553 "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1472 - "dev": true, 1554 + "devOptional": true,
1473 "license": "MIT", 1555 "license": "MIT",
1474 "dependencies": { 1556 "dependencies": {
1475 "@types/prop-types": "*", 1557 "@types/prop-types": "*",
@@ -1480,7 +1562,7 @@ @@ -1480,7 +1562,7 @@
1480 "version": "18.3.7", 1562 "version": "18.3.7",
1481 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", 1563 "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1482 "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", 1564 "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1483 - "dev": true, 1565 + "devOptional": true,
1484 "license": "MIT", 1566 "license": "MIT",
1485 "peerDependencies": { 1567 "peerDependencies": {
1486 "@types/react": "^18.0.0" 1568 "@types/react": "^18.0.0"
@@ -1507,6 +1589,48 @@ @@ -1507,6 +1589,48 @@
1507 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" 1589 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1508 } 1590 }
1509 }, 1591 },
  1592 + "node_modules/@xyflow/react": {
  1593 + "version": "12.11.2",
  1594 + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
  1595 + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
  1596 + "license": "MIT",
  1597 + "dependencies": {
  1598 + "@xyflow/system": "0.0.79",
  1599 + "classcat": "^5.0.3",
  1600 + "zustand": "^4.4.0"
  1601 + },
  1602 + "peerDependencies": {
  1603 + "@types/react": ">=17",
  1604 + "@types/react-dom": ">=17",
  1605 + "react": ">=17",
  1606 + "react-dom": ">=17"
  1607 + },
  1608 + "peerDependenciesMeta": {
  1609 + "@types/react": {
  1610 + "optional": true
  1611 + },
  1612 + "@types/react-dom": {
  1613 + "optional": true
  1614 + }
  1615 + }
  1616 + },
  1617 + "node_modules/@xyflow/system": {
  1618 + "version": "0.0.79",
  1619 + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
  1620 + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
  1621 + "license": "MIT",
  1622 + "dependencies": {
  1623 + "@types/d3-drag": "^3.0.7",
  1624 + "@types/d3-interpolate": "^3.0.4",
  1625 + "@types/d3-selection": "^3.0.10",
  1626 + "@types/d3-transition": "^3.0.8",
  1627 + "@types/d3-zoom": "^3.0.8",
  1628 + "d3-drag": "^3.0.0",
  1629 + "d3-interpolate": "^3.0.1",
  1630 + "d3-selection": "^3.0.0",
  1631 + "d3-zoom": "^3.0.0"
  1632 + }
  1633 + },
1510 "node_modules/antd": { 1634 "node_modules/antd": {
1511 "version": "5.29.3", 1635 "version": "5.29.3",
1512 "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", 1636 "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1572,6 +1696,12 @@ @@ -1572,6 +1696,12 @@
1572 "react-dom": ">=16.9.0" 1696 "react-dom": ">=16.9.0"
1573 } 1697 }
1574 }, 1698 },
  1699 + "node_modules/argparse": {
  1700 + "version": "2.0.1",
  1701 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
  1702 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
  1703 + "license": "Python-2.0"
  1704 + },
1575 "node_modules/baseline-browser-mapping": { 1705 "node_modules/baseline-browser-mapping": {
1576 "version": "2.10.43", 1706 "version": "2.10.43",
1577 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", 1707 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -1585,6 +1715,113 @@ @@ -1585,6 +1715,113 @@
1585 "node": ">=6.0.0" 1715 "node": ">=6.0.0"
1586 } 1716 }
1587 }, 1717 },
  1718 + "node_modules/bpmn-elk-layout": {
  1719 + "version": "1.4.0",
  1720 + "resolved": "https://registry.npmjs.org/bpmn-elk-layout/-/bpmn-elk-layout-1.4.0.tgz",
  1721 + "integrity": "sha512-FKcZIbSa2PPCV/KbLiSCg4fqvHf2BUBPVp2nX3+dC5+gWpKIg2ICQZ4xdkIdi+IV9cwzQQ4M7d+7hZsZRPAT+Q==",
  1722 + "license": "MIT",
  1723 + "dependencies": {
  1724 + "bpmn-moddle": "^9.0.1",
  1725 + "commander": "^12.1.0",
  1726 + "elkjs": "^0.9.3",
  1727 + "kiwi.js": "1.1.3",
  1728 + "pathfinding": "0.4.18",
  1729 + "web-worker": "1.5.0"
  1730 + },
  1731 + "bin": {
  1732 + "bpmn-elk-layout": "dist/bin/bpmn-elk-layout.js"
  1733 + },
  1734 + "engines": {
  1735 + "node": ">=18.0.0"
  1736 + }
  1737 + },
  1738 + "node_modules/bpmn-js": {
  1739 + "version": "18.22.0",
  1740 + "resolved": "https://registry.npmjs.org/bpmn-js/-/bpmn-js-18.22.0.tgz",
  1741 + "integrity": "sha512-hM5aOiyLVdVGA80nveJLXFVQMpsaST7RilErjeKfXMhpZQJTnzU+yLDaenIJsq2f90+9inyktVPqsS1Ersik2A==",
  1742 + "license": "SEE LICENSE IN LICENSE",
  1743 + "dependencies": {
  1744 + "bpmn-moddle": "^10.0.0",
  1745 + "diagram-js": "^15.23.0",
  1746 + "diagram-js-direct-editing": "^3.5.1",
  1747 + "ids": "^3.0.2",
  1748 + "inherits-browser": "^0.1.0",
  1749 + "min-dash": "^5.1.0",
  1750 + "min-dom": "^5.3.0",
  1751 + "tiny-svg": "^4.1.4"
  1752 + },
  1753 + "engines": {
  1754 + "node": "*"
  1755 + }
  1756 + },
  1757 + "node_modules/bpmn-js/node_modules/bpmn-moddle": {
  1758 + "version": "10.0.0",
  1759 + "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.0.0.tgz",
  1760 + "integrity": "sha512-vXePD5jkatcILmM3zwJG/m6IIHIghTGB7WvgcdEraEw8E8VdJHrTgrvBUhbzqaXJpnsGQz15QS936xeBY6l9aA==",
  1761 + "license": "MIT",
  1762 + "dependencies": {
  1763 + "min-dash": "^5.0.0",
  1764 + "moddle": "^8.0.0",
  1765 + "moddle-xml": "^12.0.0"
  1766 + },
  1767 + "engines": {
  1768 + "node": ">= 20.12"
  1769 + }
  1770 + },
  1771 + "node_modules/bpmn-js/node_modules/moddle": {
  1772 + "version": "8.2.0",
  1773 + "resolved": "https://registry.npmjs.org/moddle/-/moddle-8.2.0.tgz",
  1774 + "integrity": "sha512-okP9kfyOJrUxCVkwy9xpTQ54UmrEkMkjNWsyZNU9+uhdjYO43BLftUrgmaOBBBGZ7hUbJSevJFfRamxiCzaQlw==",
  1775 + "license": "MIT",
  1776 + "dependencies": {
  1777 + "min-dash": "^5.1.0"
  1778 + }
  1779 + },
  1780 + "node_modules/bpmn-js/node_modules/moddle-xml": {
  1781 + "version": "12.1.0",
  1782 + "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-12.1.0.tgz",
  1783 + "integrity": "sha512-+m50j9/MFGhvWbLJoKSSjQj3uv7SYm2w5i2lfv6goWarlWW1Nd0A31NYUXmKuWnEZ8Mm/bH1RZYtuf4x8ptY7g==",
  1784 + "license": "MIT",
  1785 + "dependencies": {
  1786 + "min-dash": "^5.1.0",
  1787 + "saxen": "^11.1.0"
  1788 + },
  1789 + "engines": {
  1790 + "node": ">= 18"
  1791 + },
  1792 + "peerDependencies": {
  1793 + "moddle": ">= 6.2.0"
  1794 + }
  1795 + },
  1796 + "node_modules/bpmn-js/node_modules/saxen": {
  1797 + "version": "11.1.0",
  1798 + "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.0.tgz",
  1799 + "integrity": "sha512-GOxBOAmiWVAytOHBuMlgFMZ4MAk+2Ny5QJpzM9I4IozfPLXq3FsDdIHNviTQGZGIx7G2mBkA+uySiJlYmSU1Gg==",
  1800 + "license": "MIT",
  1801 + "engines": {
  1802 + "node": ">= 20.12"
  1803 + }
  1804 + },
  1805 + "node_modules/bpmn-moddle": {
  1806 + "version": "9.0.4",
  1807 + "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-9.0.4.tgz",
  1808 + "integrity": "sha512-dr5s3vtOG8NkVSwa8CC55XBIKKwajomSZRb0RiMOOOF6TpqZBZvtbDjpzWICvdd/plDF6uOtaRfSgblPQLAioQ==",
  1809 + "license": "MIT",
  1810 + "dependencies": {
  1811 + "min-dash": "^4.2.1",
  1812 + "moddle": "^7.0.0",
  1813 + "moddle-xml": "^11.0.0"
  1814 + },
  1815 + "engines": {
  1816 + "node": ">= 18"
  1817 + }
  1818 + },
  1819 + "node_modules/bpmn-moddle/node_modules/min-dash": {
  1820 + "version": "4.2.3",
  1821 + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.3.tgz",
  1822 + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==",
  1823 + "license": "MIT"
  1824 + },
1588 "node_modules/browserslist": { 1825 "node_modules/browserslist": {
1589 "version": "4.28.6", 1826 "version": "4.28.6",
1590 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", 1827 "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
@@ -1640,12 +1877,36 @@ @@ -1640,12 +1877,36 @@
1640 ], 1877 ],
1641 "license": "CC-BY-4.0" 1878 "license": "CC-BY-4.0"
1642 }, 1879 },
  1880 + "node_modules/classcat": {
  1881 + "version": "5.0.5",
  1882 + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
  1883 + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
  1884 + "license": "MIT"
  1885 + },
1643 "node_modules/classnames": { 1886 "node_modules/classnames": {
1644 "version": "2.5.1", 1887 "version": "2.5.1",
1645 "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", 1888 "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
1646 "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", 1889 "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
1647 "license": "MIT" 1890 "license": "MIT"
1648 }, 1891 },
  1892 + "node_modules/clsx": {
  1893 + "version": "2.1.1",
  1894 + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
  1895 + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
  1896 + "license": "MIT",
  1897 + "engines": {
  1898 + "node": ">=6"
  1899 + }
  1900 + },
  1901 + "node_modules/commander": {
  1902 + "version": "12.1.0",
  1903 + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
  1904 + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
  1905 + "license": "MIT",
  1906 + "engines": {
  1907 + "node": ">=18"
  1908 + }
  1909 + },
1649 "node_modules/compute-scroll-into-view": { 1910 "node_modules/compute-scroll-into-view": {
1650 "version": "3.1.1", 1911 "version": "3.1.1",
1651 "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", 1912 "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
@@ -1668,12 +1929,147 @@ @@ -1668,12 +1929,147 @@
1668 "toggle-selection": "^1.0.6" 1929 "toggle-selection": "^1.0.6"
1669 } 1930 }
1670 }, 1931 },
  1932 + "node_modules/cose-base": {
  1933 + "version": "2.2.0",
  1934 + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
  1935 + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
  1936 + "license": "MIT",
  1937 + "dependencies": {
  1938 + "layout-base": "^2.0.0"
  1939 + }
  1940 + },
1671 "node_modules/csstype": { 1941 "node_modules/csstype": {
1672 "version": "3.2.3", 1942 "version": "3.2.3",
1673 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 1943 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1674 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", 1944 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1675 "license": "MIT" 1945 "license": "MIT"
1676 }, 1946 },
  1947 + "node_modules/cytoscape": {
  1948 + "version": "3.34.0",
  1949 + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
  1950 + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
  1951 + "license": "MIT",
  1952 + "engines": {
  1953 + "node": ">=0.10"
  1954 + }
  1955 + },
  1956 + "node_modules/cytoscape-fcose": {
  1957 + "version": "2.2.0",
  1958 + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
  1959 + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
  1960 + "license": "MIT",
  1961 + "dependencies": {
  1962 + "cose-base": "^2.2.0"
  1963 + },
  1964 + "peerDependencies": {
  1965 + "cytoscape": "^3.2.0"
  1966 + }
  1967 + },
  1968 + "node_modules/d3-color": {
  1969 + "version": "3.1.0",
  1970 + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
  1971 + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
  1972 + "license": "ISC",
  1973 + "engines": {
  1974 + "node": ">=12"
  1975 + }
  1976 + },
  1977 + "node_modules/d3-dispatch": {
  1978 + "version": "3.0.1",
  1979 + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
  1980 + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
  1981 + "license": "ISC",
  1982 + "engines": {
  1983 + "node": ">=12"
  1984 + }
  1985 + },
  1986 + "node_modules/d3-drag": {
  1987 + "version": "3.0.0",
  1988 + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
  1989 + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
  1990 + "license": "ISC",
  1991 + "dependencies": {
  1992 + "d3-dispatch": "1 - 3",
  1993 + "d3-selection": "3"
  1994 + },
  1995 + "engines": {
  1996 + "node": ">=12"
  1997 + }
  1998 + },
  1999 + "node_modules/d3-ease": {
  2000 + "version": "3.0.1",
  2001 + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
  2002 + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
  2003 + "license": "BSD-3-Clause",
  2004 + "engines": {
  2005 + "node": ">=12"
  2006 + }
  2007 + },
  2008 + "node_modules/d3-interpolate": {
  2009 + "version": "3.0.1",
  2010 + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
  2011 + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
  2012 + "license": "ISC",
  2013 + "dependencies": {
  2014 + "d3-color": "1 - 3"
  2015 + },
  2016 + "engines": {
  2017 + "node": ">=12"
  2018 + }
  2019 + },
  2020 + "node_modules/d3-selection": {
  2021 + "version": "3.0.0",
  2022 + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
  2023 + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
  2024 + "license": "ISC",
  2025 + "engines": {
  2026 + "node": ">=12"
  2027 + }
  2028 + },
  2029 + "node_modules/d3-timer": {
  2030 + "version": "3.0.1",
  2031 + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
  2032 + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
  2033 + "license": "ISC",
  2034 + "engines": {
  2035 + "node": ">=12"
  2036 + }
  2037 + },
  2038 + "node_modules/d3-transition": {
  2039 + "version": "3.0.1",
  2040 + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
  2041 + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
  2042 + "license": "ISC",
  2043 + "dependencies": {
  2044 + "d3-color": "1 - 3",
  2045 + "d3-dispatch": "1 - 3",
  2046 + "d3-ease": "1 - 3",
  2047 + "d3-interpolate": "1 - 3",
  2048 + "d3-timer": "1 - 3"
  2049 + },
  2050 + "engines": {
  2051 + "node": ">=12"
  2052 + },
  2053 + "peerDependencies": {
  2054 + "d3-selection": "2 - 3"
  2055 + }
  2056 + },
  2057 + "node_modules/d3-zoom": {
  2058 + "version": "3.0.0",
  2059 + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
  2060 + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
  2061 + "license": "ISC",
  2062 + "dependencies": {
  2063 + "d3-dispatch": "1 - 3",
  2064 + "d3-drag": "2 - 3",
  2065 + "d3-interpolate": "1 - 3",
  2066 + "d3-selection": "2 - 3",
  2067 + "d3-transition": "2 - 3"
  2068 + },
  2069 + "engines": {
  2070 + "node": ">=12"
  2071 + }
  2072 + },
1677 "node_modules/dayjs": { 2073 "node_modules/dayjs": {
1678 "version": "1.11.21", 2074 "version": "1.11.21",
1679 "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", 2075 "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
@@ -1698,6 +2094,63 @@ @@ -1698,6 +2094,63 @@
1698 } 2094 }
1699 } 2095 }
1700 }, 2096 },
  2097 + "node_modules/diagram-js": {
  2098 + "version": "15.23.0",
  2099 + "resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.23.0.tgz",
  2100 + "integrity": "sha512-cCgL+4Ep0xrLe6TtdvRVgo6bWYjUbrQg+ES/9rpltqmTD2YaPoC+NYcRsKYw/fDO+1RN0q16NzErP1svVZhNQg==",
  2101 + "license": "MIT",
  2102 + "dependencies": {
  2103 + "@bpmn-io/diagram-js-ui": "^0.2.4",
  2104 + "clsx": "^2.1.1",
  2105 + "didi": "^11.0.0",
  2106 + "inherits-browser": "^0.1.0",
  2107 + "min-dash": "^5.1.0",
  2108 + "min-dom": "^5.3.0",
  2109 + "object-refs": "^0.4.0",
  2110 + "path-intersection": "^4.1.0",
  2111 + "tiny-svg": "^4.1.4"
  2112 + },
  2113 + "engines": {
  2114 + "node": "*"
  2115 + }
  2116 + },
  2117 + "node_modules/diagram-js-direct-editing": {
  2118 + "version": "3.5.1",
  2119 + "resolved": "https://registry.npmjs.org/diagram-js-direct-editing/-/diagram-js-direct-editing-3.5.1.tgz",
  2120 + "integrity": "sha512-fEQCQ/x5oKVV0m7Nmgv7i85Vhw+kJkKNXF8zcV5Vm0KwF8x/8e3Ax0aJgdXt8/fDPymEkY8HmZweXIipCeeh+g==",
  2121 + "license": "MIT",
  2122 + "dependencies": {
  2123 + "min-dash": "^5.0.0",
  2124 + "min-dom": "^5.3.0"
  2125 + },
  2126 + "engines": {
  2127 + "node": "*"
  2128 + },
  2129 + "peerDependencies": {
  2130 + "diagram-js": "*"
  2131 + }
  2132 + },
  2133 + "node_modules/didi": {
  2134 + "version": "11.0.0",
  2135 + "resolved": "https://registry.npmjs.org/didi/-/didi-11.0.0.tgz",
  2136 + "integrity": "sha512-PzCfRzQttvFpVcYMbSF7h8EsWjeJpVjWH4qDhB5LkMi1ILvHq4Ob0vhM2wLFziPkbUBi+PAo7ODbe2sacR7nJQ==",
  2137 + "license": "MIT",
  2138 + "engines": {
  2139 + "node": ">= 20.12"
  2140 + }
  2141 + },
  2142 + "node_modules/domify": {
  2143 + "version": "3.0.0",
  2144 + "resolved": "https://registry.npmjs.org/domify/-/domify-3.0.0.tgz",
  2145 + "integrity": "sha512-bs2yO68JDFOm6rKv8f0EnrM2cENduhRkpqOtt/s5l5JBA/eqGBZCzLPmdYoHtJ6utgLGgcBajFsEQbl12pT0lQ==",
  2146 + "license": "MIT",
  2147 + "engines": {
  2148 + "node": ">=20"
  2149 + },
  2150 + "funding": {
  2151 + "url": "https://github.com/sponsors/sindresorhus"
  2152 + }
  2153 + },
1701 "node_modules/electron-to-chromium": { 2154 "node_modules/electron-to-chromium": {
1702 "version": "1.5.393", 2155 "version": "1.5.393",
1703 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", 2156 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
@@ -1705,6 +2158,12 @@ @@ -1705,6 +2158,12 @@
1705 "dev": true, 2158 "dev": true,
1706 "license": "ISC" 2159 "license": "ISC"
1707 }, 2160 },
  2161 + "node_modules/elkjs": {
  2162 + "version": "0.9.3",
  2163 + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
  2164 + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
  2165 + "license": "EPL-2.0"
  2166 + },
1708 "node_modules/esbuild": { 2167 "node_modules/esbuild": {
1709 "version": "0.21.5", 2168 "version": "0.21.5",
1710 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 2169 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1779,12 +2238,60 @@ @@ -1779,12 +2238,60 @@
1779 "node": ">=6.9.0" 2238 "node": ">=6.9.0"
1780 } 2239 }
1781 }, 2240 },
  2241 + "node_modules/heap": {
  2242 + "version": "0.2.5",
  2243 + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz",
  2244 + "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg=="
  2245 + },
  2246 + "node_modules/htm": {
  2247 + "version": "3.1.1",
  2248 + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
  2249 + "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
  2250 + "license": "Apache-2.0"
  2251 + },
  2252 + "node_modules/ids": {
  2253 + "version": "3.0.2",
  2254 + "resolved": "https://registry.npmjs.org/ids/-/ids-3.0.2.tgz",
  2255 + "integrity": "sha512-t6YJP4mdC+GHF96Nbis/4FEANhP/8VWmYMvUuYpXvSdrhg5hpIVbq2XZlOA3UWTbtdwPCi0q7jEXOdHkAnqOnw==",
  2256 + "license": "MIT",
  2257 + "engines": {
  2258 + "node": ">= 20.12"
  2259 + }
  2260 + },
  2261 + "node_modules/inherits-browser": {
  2262 + "version": "0.1.0",
  2263 + "resolved": "https://registry.npmjs.org/inherits-browser/-/inherits-browser-0.1.0.tgz",
  2264 + "integrity": "sha512-CJHHvW3jQ6q7lzsXPpapLdMx5hDpSF3FSh45pwsj6bKxJJ8Nl8v43i5yXnr3BdfOimGHKyniewQtnAIp3vyJJw==",
  2265 + "license": "ISC"
  2266 + },
1782 "node_modules/js-tokens": { 2267 "node_modules/js-tokens": {
1783 "version": "4.0.0", 2268 "version": "4.0.0",
1784 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 2269 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1785 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 2270 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1786 "license": "MIT" 2271 "license": "MIT"
1787 }, 2272 },
  2273 + "node_modules/js-yaml": {
  2274 + "version": "4.3.0",
  2275 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
  2276 + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
  2277 + "funding": [
  2278 + {
  2279 + "type": "github",
  2280 + "url": "https://github.com/sponsors/puzrin"
  2281 + },
  2282 + {
  2283 + "type": "github",
  2284 + "url": "https://github.com/sponsors/nodeca"
  2285 + }
  2286 + ],
  2287 + "license": "MIT",
  2288 + "dependencies": {
  2289 + "argparse": "^2.0.1"
  2290 + },
  2291 + "bin": {
  2292 + "js-yaml": "bin/js-yaml.js"
  2293 + }
  2294 + },
1788 "node_modules/jsesc": { 2295 "node_modules/jsesc": {
1789 "version": "3.1.0", 2296 "version": "3.1.0",
1790 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", 2297 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -1820,6 +2327,18 @@ @@ -1820,6 +2327,18 @@
1820 "node": ">=6" 2327 "node": ">=6"
1821 } 2328 }
1822 }, 2329 },
  2330 + "node_modules/kiwi.js": {
  2331 + "version": "1.1.3",
  2332 + "resolved": "https://registry.npmjs.org/kiwi.js/-/kiwi.js-1.1.3.tgz",
  2333 + "integrity": "sha512-aBA32N0LjuF0RIeV+OVhT8wnGAwdLYynKxkHbRvatI7yajOBM3OUjzTuzt3w9UnY9TYt7U65oe7U8EXlUHuTrQ==",
  2334 + "license": "BSD-3-Clause"
  2335 + },
  2336 + "node_modules/layout-base": {
  2337 + "version": "2.0.1",
  2338 + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
  2339 + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
  2340 + "license": "MIT"
  2341 + },
1823 "node_modules/loose-envify": { 2342 "node_modules/loose-envify": {
1824 "version": "1.4.0", 2343 "version": "1.4.0",
1825 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 2344 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1842,6 +2361,59 @@ @@ -1842,6 +2361,59 @@
1842 "yallist": "^3.0.2" 2361 "yallist": "^3.0.2"
1843 } 2362 }
1844 }, 2363 },
  2364 + "node_modules/min-dash": {
  2365 + "version": "5.1.0",
  2366 + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.1.0.tgz",
  2367 + "integrity": "sha512-HAvN6XzmCQj+js43G9sJRRw8kj/7DvoxN7qhoIN9Xo5ZN1NMljtpyKs/NunXmw0QSWwlgZzNv6tLeEa4xaZ0tg==",
  2368 + "license": "MIT"
  2369 + },
  2370 + "node_modules/min-dom": {
  2371 + "version": "5.3.0",
  2372 + "resolved": "https://registry.npmjs.org/min-dom/-/min-dom-5.3.0.tgz",
  2373 + "integrity": "sha512-0w5FEBgPAyHhmFojW3zxd7we3D+m5XYS3E/06OyvxmbHJoiQVa4Nagj6RWvoAKYRw5xth6cP5TMePc5cR1M9hA==",
  2374 + "license": "MIT",
  2375 + "dependencies": {
  2376 + "domify": "^3.0.0",
  2377 + "min-dash": "^5.0.0"
  2378 + }
  2379 + },
  2380 + "node_modules/moddle": {
  2381 + "version": "7.2.0",
  2382 + "resolved": "https://registry.npmjs.org/moddle/-/moddle-7.2.0.tgz",
  2383 + "integrity": "sha512-x1+JREThy7JBOBR3g2hbOnOfrlC/YAWXX9RzrSZS5HhqeuBly9H/PCtOBtcQs+Y2sjRAXF+WTNSgHvn8Uq+6Yw==",
  2384 + "license": "MIT",
  2385 + "dependencies": {
  2386 + "min-dash": "^4.2.1"
  2387 + }
  2388 + },
  2389 + "node_modules/moddle-xml": {
  2390 + "version": "11.0.0",
  2391 + "resolved": "https://registry.npmjs.org/moddle-xml/-/moddle-xml-11.0.0.tgz",
  2392 + "integrity": "sha512-L3Sseepfcq9Uy0iIfqEDTXSoYLva1Y/JGbN/4AMOeQ6cqbu8Ma/SDJIdOFm7smsAa64j2z3SwCGG3FIilQVnUg==",
  2393 + "license": "MIT",
  2394 + "dependencies": {
  2395 + "min-dash": "^4.0.0",
  2396 + "saxen": "^10.0.0"
  2397 + },
  2398 + "engines": {
  2399 + "node": ">= 18"
  2400 + },
  2401 + "peerDependencies": {
  2402 + "moddle": ">= 6.2.0"
  2403 + }
  2404 + },
  2405 + "node_modules/moddle-xml/node_modules/min-dash": {
  2406 + "version": "4.2.3",
  2407 + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.3.tgz",
  2408 + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==",
  2409 + "license": "MIT"
  2410 + },
  2411 + "node_modules/moddle/node_modules/min-dash": {
  2412 + "version": "4.2.3",
  2413 + "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-4.2.3.tgz",
  2414 + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==",
  2415 + "license": "MIT"
  2416 + },
1845 "node_modules/ms": { 2417 "node_modules/ms": {
1846 "version": "2.1.3", 2418 "version": "2.1.3",
1847 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 2419 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1878,6 +2450,32 @@ @@ -1878,6 +2450,32 @@
1878 "node": ">=18" 2450 "node": ">=18"
1879 } 2451 }
1880 }, 2452 },
  2453 + "node_modules/object-refs": {
  2454 + "version": "0.4.0",
  2455 + "resolved": "https://registry.npmjs.org/object-refs/-/object-refs-0.4.0.tgz",
  2456 + "integrity": "sha512-6kJqKWryKZmtte6QYvouas0/EIJKPI1/MMIuRsiBlNuhIMfqYTggzX2F1AJ2+cDs288xyi9GL7FyasHINR98BQ==",
  2457 + "license": "MIT",
  2458 + "engines": {
  2459 + "node": "*"
  2460 + }
  2461 + },
  2462 + "node_modules/path-intersection": {
  2463 + "version": "4.1.0",
  2464 + "resolved": "https://registry.npmjs.org/path-intersection/-/path-intersection-4.1.0.tgz",
  2465 + "integrity": "sha512-urUP6WvhnxbHPdHYl6L7Yrc6+1ny6uOFKPCzPxTSUSYGHG0o94RmI7SvMMaScNAM5RtTf08bg4skc6/kjfne3A==",
  2466 + "license": "MIT",
  2467 + "engines": {
  2468 + "node": ">= 14.20"
  2469 + }
  2470 + },
  2471 + "node_modules/pathfinding": {
  2472 + "version": "0.4.18",
  2473 + "resolved": "https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz",
  2474 + "integrity": "sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==",
  2475 + "dependencies": {
  2476 + "heap": "0.2.5"
  2477 + }
  2478 + },
1881 "node_modules/picocolors": { 2479 "node_modules/picocolors": {
1882 "version": "1.1.1", 2480 "version": "1.1.1",
1883 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 2481 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1914,6 +2512,24 @@ @@ -1914,6 +2512,24 @@
1914 "node": "^10 || ^12 || >=14" 2512 "node": "^10 || ^12 || >=14"
1915 } 2513 }
1916 }, 2514 },
  2515 + "node_modules/preact": {
  2516 + "version": "10.29.7",
  2517 + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
  2518 + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
  2519 + "license": "MIT",
  2520 + "funding": {
  2521 + "type": "opencollective",
  2522 + "url": "https://opencollective.com/preact"
  2523 + },
  2524 + "peerDependencies": {
  2525 + "preact-render-to-string": ">=5"
  2526 + },
  2527 + "peerDependenciesMeta": {
  2528 + "preact-render-to-string": {
  2529 + "optional": true
  2530 + }
  2531 + }
  2532 + },
1917 "node_modules/rc-cascader": { 2533 "node_modules/rc-cascader": {
1918 "version": "3.34.0", 2534 "version": "3.34.0",
1919 "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", 2535 "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
@@ -2612,6 +3228,15 @@ @@ -2612,6 +3228,15 @@
2612 "fsevents": "~2.3.2" 3228 "fsevents": "~2.3.2"
2613 } 3229 }
2614 }, 3230 },
  3231 + "node_modules/saxen": {
  3232 + "version": "10.0.0",
  3233 + "resolved": "https://registry.npmjs.org/saxen/-/saxen-10.0.0.tgz",
  3234 + "integrity": "sha512-RXsmWok/SAWqOG/f5ADEz51DN9WtZEzqih3e08ranldcaXekxjx8NBKjGh/y5hlowjo0JH/LekBu6gtPFD1G6g==",
  3235 + "license": "MIT",
  3236 + "engines": {
  3237 + "node": ">= 18"
  3238 + }
  3239 + },
2615 "node_modules/scheduler": { 3240 "node_modules/scheduler": {
2616 "version": "0.23.2", 3241 "version": "0.23.2",
2617 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", 3242 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -2671,6 +3296,15 @@ @@ -2671,6 +3296,15 @@
2671 "node": ">=12.22" 3296 "node": ">=12.22"
2672 } 3297 }
2673 }, 3298 },
  3299 + "node_modules/tiny-svg": {
  3300 + "version": "4.1.4",
  3301 + "resolved": "https://registry.npmjs.org/tiny-svg/-/tiny-svg-4.1.4.tgz",
  3302 + "integrity": "sha512-cBaEACCbouYrQc9RG+eTXnPYosX1Ijqty/I6DdXovwDd89Pwu4jcmpOR7BuFEF9YCcd7/AWwasE0207WMK7hdw==",
  3303 + "license": "MIT",
  3304 + "engines": {
  3305 + "node": ">= 20"
  3306 + }
  3307 + },
2674 "node_modules/toggle-selection": { 3308 "node_modules/toggle-selection": {
2675 "version": "1.0.6", 3309 "version": "1.0.6",
2676 "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", 3310 "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
@@ -2722,6 +3356,15 @@ @@ -2722,6 +3356,15 @@
2722 "browserslist": ">= 4.21.0" 3356 "browserslist": ">= 4.21.0"
2723 } 3357 }
2724 }, 3358 },
  3359 + "node_modules/use-sync-external-store": {
  3360 + "version": "1.6.0",
  3361 + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
  3362 + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
  3363 + "license": "MIT",
  3364 + "peerDependencies": {
  3365 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
  3366 + }
  3367 + },
2725 "node_modules/vite": { 3368 "node_modules/vite": {
2726 "version": "5.4.21", 3369 "version": "5.4.21",
2727 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", 3370 "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2782,12 +3425,61 @@ @@ -2782,12 +3425,61 @@
2782 } 3425 }
2783 } 3426 }
2784 }, 3427 },
  3428 + "node_modules/web-worker": {
  3429 + "version": "1.5.0",
  3430 + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
  3431 + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
  3432 + "license": "Apache-2.0"
  3433 + },
2785 "node_modules/yallist": { 3434 "node_modules/yallist": {
2786 "version": "3.1.1", 3435 "version": "3.1.1",
2787 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", 3436 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2788 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", 3437 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2789 "dev": true, 3438 "dev": true,
2790 "license": "ISC" 3439 "license": "ISC"
  3440 + },
  3441 + "node_modules/yaml": {
  3442 + "version": "2.9.0",
  3443 + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
  3444 + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
  3445 + "license": "ISC",
  3446 + "bin": {
  3447 + "yaml": "bin.mjs"
  3448 + },
  3449 + "engines": {
  3450 + "node": ">= 14.6"
  3451 + },
  3452 + "funding": {
  3453 + "url": "https://github.com/sponsors/eemeli"
  3454 + }
  3455 + },
  3456 + "node_modules/zustand": {
  3457 + "version": "4.5.7",
  3458 + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
  3459 + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
  3460 + "license": "MIT",
  3461 + "dependencies": {
  3462 + "use-sync-external-store": "^1.2.2"
  3463 + },
  3464 + "engines": {
  3465 + "node": ">=12.7.0"
  3466 + },
  3467 + "peerDependencies": {
  3468 + "@types/react": ">=16.8",
  3469 + "immer": ">=9.0.6",
  3470 + "react": ">=16.8"
  3471 + },
  3472 + "peerDependenciesMeta": {
  3473 + "@types/react": {
  3474 + "optional": true
  3475 + },
  3476 + "immer": {
  3477 + "optional": true
  3478 + },
  3479 + "react": {
  3480 + "optional": true
  3481 + }
  3482 + }
2791 } 3483 }
2792 } 3484 }
2793 } 3485 }
web/package.json
@@ -11,10 +11,18 @@ @@ -11,10 +11,18 @@
11 }, 11 },
12 "dependencies": { 12 "dependencies": {
13 "@ant-design/icons": "^5.5.1", 13 "@ant-design/icons": "^5.5.1",
  14 + "@dagrejs/dagre": "^3.0.0",
  15 + "@xyflow/react": "^12.11.2",
14 "antd": "^5.21.6", 16 "antd": "^5.21.6",
  17 + "bpmn-elk-layout": "^1.4.0",
  18 + "bpmn-js": "^18.22.0",
  19 + "cytoscape": "^3.34.0",
  20 + "cytoscape-fcose": "^2.2.0",
15 "dayjs": "^1.11.13", 21 "dayjs": "^1.11.13",
  22 + "js-yaml": "^4.3.0",
16 "react": "^18.3.1", 23 "react": "^18.3.1",
17 - "react-dom": "^18.3.1" 24 + "react-dom": "^18.3.1",
  25 + "yaml": "^2.9.0"
18 }, 26 },
19 "devDependencies": { 27 "devDependencies": {
20 "@types/react": "^18.3.12", 28 "@types/react": "^18.3.12",
web/src/App.tsx
1 -import { useEffect, useState } from 'react'  
2 -import { Layout, Card, Tabs, Button, Space, Typography, Tag, message, Spin, Modal, Select } from 'antd' 1 +import { useEffect, useState, lazy, Suspense } from 'react'
  2 +import { Layout, Card, Tabs, Button, Space, Typography, Tag, message, Spin, Modal, Select, Menu } from 'antd'
3 import { ReloadOutlined, ThunderboltOutlined, BranchesOutlined, UserOutlined } from '@ant-design/icons' 3 import { ReloadOutlined, ThunderboltOutlined, BranchesOutlined, UserOutlined } from '@ant-design/icons'
4 import { api } from './api' 4 import { api } from './api'
5 import type { SceneSchema } from './types' 5 import type { SceneSchema } from './types'
6 import { SceneForm } from './engine/SceneForm' 6 import { SceneForm } from './engine/SceneForm'
7 import { TracePanel, ProvenancePanel, ModelViewer, OrdersEventsPanel } from './panels' 7 import { TracePanel, ProvenancePanel, ModelViewer, OrdersEventsPanel } from './panels'
8 8
  9 +// 重量级视图(模型编辑器 + 三张可视化图,含 cytoscape/xyflow/bpmn-js)按需懒加载,
  10 +// 让默认「场景运行台」的首屏包保持轻量。
  11 +const ModelEditor = lazy(() => import('./model/ModelEditor').then((m) => ({ default: m.ModelEditor })))
  12 +const OntologyCanvas = lazy(() => import('./viz/OntologyCanvas').then((m) => ({ default: m.OntologyCanvas })))
  13 +
  14 +const LazyFallback = () => (
  15 + <div style={{ padding: 60, textAlign: 'center' }}><Spin tip="加载视图…"><div style={{ width: 120, height: 60 }} /></Spin></div>
  16 +)
  17 +
9 const { Header, Sider, Content } = Layout 18 const { Header, Sider, Content } = Layout
10 const { Title, Text, Paragraph } = Typography 19 const { Title, Text, Paragraph } = Typography
11 20
  21 +type View = 'runner' | 'editor' | 'er' | 'network' | 'process'
  22 +
  23 +const VIEW_ITEMS = [
  24 + { key: 'runner', label: '场景运行台' },
  25 + { key: 'editor', label: '模型编辑器' },
  26 + { key: 'er', label: '数据结构图' },
  27 + { key: 'network', label: '本体网络图' },
  28 + { key: 'process', label: '场景流程图' },
  29 +]
  30 +
12 export default function App() { 31 export default function App() {
  32 + const [view, setView] = useState<View>('runner')
13 const [scenes, setScenes] = useState<any[]>([]) 33 const [scenes, setScenes] = useState<any[]>([])
14 const [sceneId, setSceneId] = useState<string>('') // 初始不硬编码,待场景列表返回后取首个可渲染场景 34 const [sceneId, setSceneId] = useState<string>('') // 初始不硬编码,待场景列表返回后取首个可渲染场景
15 const [schema, setSchema] = useState<SceneSchema | null>(null) 35 const [schema, setSchema] = useState<SceneSchema | null>(null)
@@ -17,7 +37,7 @@ export default function App() { @@ -17,7 +37,7 @@ export default function App() {
17 const [result, setResult] = useState<any>(null) 37 const [result, setResult] = useState<any>(null)
18 const [refreshKey, setRefreshKey] = useState(0) 38 const [refreshKey, setRefreshKey] = useState(0)
19 const [formKey, setFormKey] = useState(0) 39 const [formKey, setFormKey] = useState(0)
20 - // 演示态主体(M5 principal):随手切换以观察权限"通过/拒绝"分支;管理员方可热重载/新增客户 40 + // 演示态主体(M5 principal):随手切换以观察权限"通过/拒绝"分支;管理员方可热重载/保存模型/新增客户
21 const [principal, setPrincipal] = useState<string>('backend_admin') 41 const [principal, setPrincipal] = useState<string>('backend_admin')
22 42
23 // 拉取场景列表(首个可渲染场景兜底选中;已有选择则保留,以便热重载后停留在当前场景) 43 // 拉取场景列表(首个可渲染场景兜底选中;已有选择则保留,以便热重载后停留在当前场景)
@@ -35,12 +55,17 @@ export default function App() { @@ -35,12 +55,17 @@ export default function App() {
35 api.sceneSchema(sceneId).then(setSchema).catch((e) => message.error('加载场景失败:' + e.message)).finally(() => setLoading(false)) 55 api.sceneSchema(sceneId).then(setSchema).catch((e) => message.error('加载场景失败:' + e.message)).finally(() => setLoading(false))
36 }, [sceneId, formKey]) 56 }, [sceneId, formKey])
37 57
  58 + // 模型(含热重载/保存)变化后:重拉场景列表 + 重挂当前表单,让改动即时反映
  59 + const afterModelChange = async () => {
  60 + await loadScenes()
  61 + setFormKey((k) => k + 1)
  62 + }
  63 +
38 async function reloadModels() { 64 async function reloadModels() {
39 try { 65 try {
40 const r = await api.reload(principal) 66 const r = await api.reload(principal)
41 message.success(r.message) 67 message.success(r.message)
42 - await loadScenes() // 重新拉取场景列表:模板增删 / hasTemplate 变化即时反映到侧栏  
43 - setFormKey((k) => k + 1) // 强制重新拉取当前场景 schema + 重挂载表单 68 + await afterModelChange()
44 } catch (e: any) { message.error('热重载失败:' + e.message) } 69 } catch (e: any) { message.error('热重载失败:' + e.message) }
45 } 70 }
46 71
@@ -48,79 +73,99 @@ export default function App() { @@ -48,79 +73,99 @@ export default function App() {
48 73
49 return ( 74 return (
50 <Layout style={{ minHeight: '100vh' }}> 75 <Layout style={{ minHeight: '100vh' }}>
51 - <Header style={{ background: '#001529', display: 'flex', alignItems: 'center', gap: 16 }}> 76 + <Header style={{ background: '#001529', display: 'flex', alignItems: 'center', gap: 16, paddingInline: 20 }}>
52 <BranchesOutlined style={{ color: '#69b1ff', fontSize: 22 }} /> 77 <BranchesOutlined style={{ color: '#69b1ff', fontSize: 22 }} />
53 - <Title level={4} style={{ color: '#fff', margin: 0 }}>本体驱动 DDD+EDA · 元数据引擎 Demo</Title>  
54 - <Tag color="blue">改 YAML → 页面与行为随之变</Tag>  
55 - <div style={{ flex: 1 }} /> 78 + <Title level={4} style={{ color: '#fff', margin: 0, whiteSpace: 'nowrap' }}>本体驱动 DDD+EDA · 元数据引擎</Title>
  79 + <Menu
  80 + mode="horizontal"
  81 + theme="dark"
  82 + selectedKeys={[view]}
  83 + onClick={(e) => setView(e.key as View)}
  84 + items={VIEW_ITEMS}
  85 + style={{ flex: 1, minWidth: 0, background: 'transparent', borderBottom: 'none' }}
  86 + />
56 <Space> 87 <Space>
57 <UserOutlined style={{ color: '#69b1ff' }} /> 88 <UserOutlined style={{ color: '#69b1ff' }} />
58 - <Select size="small" value={principal} onChange={setPrincipal} style={{ width: 140 }} 89 + <Select size="small" value={principal} onChange={setPrincipal} style={{ width: 130 }}
59 options={[{ value: 'backend_admin', label: '后台管理员' }, { value: 'normal_user', label: '普通用户' }]} /> 90 options={[{ value: 'backend_admin', label: '后台管理员' }, { value: 'normal_user', label: '普通用户' }]} />
60 </Space> 91 </Space>
61 - <Button icon={<ReloadOutlined />} onClick={reloadModels} ghost>热重载模型</Button> 92 + <Button icon={<ReloadOutlined />} onClick={reloadModels} ghost>热重载</Button>
62 <HelpButton /> 93 <HelpButton />
63 </Header> 94 </Header>
64 - <Layout>  
65 - <Sider width={260} theme="light" style={{ padding: 12 }}>  
66 - <Text type="secondary">业务场景(M4)</Text>  
67 - <div style={{ marginTop: 8 }}>  
68 - {scenes.map((s) => {  
69 - const active = sceneId === s.sceneId  
70 - return (  
71 - <div  
72 - key={s.sceneId}  
73 - onClick={() => setSceneId(s.sceneId)}  
74 - style={{  
75 - padding: '10px 12px',  
76 - marginBottom: 8,  
77 - borderRadius: 8,  
78 - cursor: 'pointer',  
79 - background: active ? '#e6f4ff' : '#fff',  
80 - border: `1px solid ${active ? '#91caff' : '#f0f0f0'}`,  
81 - transition: 'all .2s',  
82 - }}  
83 - >  
84 - <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>  
85 - <ThunderboltOutlined style={{ color: active ? '#1677ff' : '#999' }} />  
86 - <span style={{ fontWeight: 500, color: active ? '#1677ff' : 'inherit' }}>{s.sceneName}</span>  
87 - </div>  
88 - <div style={{ fontSize: 11, color: '#999', marginTop: 4, marginLeft: 22 }}>  
89 - {s.hasTemplate ? s.templateName : '无表单 · 直接运行 flowSteps'} 95 +
  96 + {view === 'runner' && (
  97 + <Layout>
  98 + <Sider width={260} theme="light" style={{ padding: 12 }}>
  99 + <Text type="secondary">业务场景(M4)</Text>
  100 + <div style={{ marginTop: 8 }}>
  101 + {scenes.map((s) => {
  102 + const active = sceneId === s.sceneId
  103 + return (
  104 + <div
  105 + key={s.sceneId}
  106 + onClick={() => setSceneId(s.sceneId)}
  107 + style={{
  108 + padding: '10px 12px', marginBottom: 8, borderRadius: 8, cursor: 'pointer',
  109 + background: active ? '#e6f4ff' : '#fff',
  110 + border: `1px solid ${active ? '#91caff' : '#f0f0f0'}`, transition: 'all .2s',
  111 + }}
  112 + >
  113 + <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  114 + <ThunderboltOutlined style={{ color: active ? '#1677ff' : '#999' }} />
  115 + <span style={{ fontWeight: 500, color: active ? '#1677ff' : 'inherit' }}>{s.sceneName}</span>
  116 + </div>
  117 + <div style={{ fontSize: 11, color: '#999', marginTop: 4, marginLeft: 22 }}>
  118 + {s.hasTemplate ? s.templateName : '无表单 · 直接运行 flowSteps'}
  119 + </div>
90 </div> 120 </div>
91 - </div>  
92 - )  
93 - })}  
94 - </div>  
95 - <Paragraph type="secondary" style={{ fontSize: 12, marginTop: 16 }}>  
96 - 业务场景均由 M8 模板动态渲染并端到端执行(订单/客户/商品/审核…)。  
97 - 末位「演示:全部 stepType」无表单,点开可直接运行、观察引擎逐类步骤的通用解释。  
98 - 部分场景需管理员主体(M5 权限,可在右上切换)。  
99 - </Paragraph>  
100 - </Sider> 121 + )
  122 + })}
  123 + </div>
  124 + <Paragraph type="secondary" style={{ fontSize: 12, marginTop: 16 }}>
  125 + 业务场景均由 M8 模板动态渲染并端到端执行。「模型编辑器」可视化查看/编辑本体;
  126 + 「数据结构图 / 本体网络图 / 场景流程图」由 M0–M8 本体动态合成渲染。
  127 + </Paragraph>
  128 + </Sider>
  129 +
  130 + <Content style={{ padding: 16 }}>
  131 + <Spin spinning={loading}>
  132 + <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
  133 + <Card style={{ flex: '1 1 62%' }} title={<Space><b>{schema?.sceneName || '加载中'}</b><Tag color="green">{sceneId}</Tag></Space>}>
  134 + {schema?.template ? <SceneForm key={formKey + sceneId} schema={schema} onExecuted={onExecuted} principal={principal} />
  135 + : schema ? <RunOnlyScene key={sceneId} schema={schema} onExecuted={onExecuted} principal={principal} />
  136 + : null}
  137 + </Card>
  138 +
  139 + <Card style={{ flex: '1 1 38%', position: 'sticky', top: 16 }} styles={{ body: { paddingTop: 8 } }}>
  140 + <Tabs
  141 + items={[
  142 + { key: 'trace', label: '执行追踪', children: <TracePanel result={result} /> },
  143 + { key: 'prov', label: '溯源/规则', children: schema ? <ProvenancePanel schema={schema} /> : null },
  144 + { key: 'orders', label: '数据/事件', children: <OrdersEventsPanel refreshKey={refreshKey} aggregateId={schema?.template?.masterAggregate || schema?.aggregateScope?.[0]} /> },
  145 + { key: 'model', label: '模型查看器', children: <ModelViewer /> },
  146 + ]} />
  147 + </Card>
  148 + </div>
  149 + </Spin>
  150 + </Content>
  151 + </Layout>
  152 + )}
101 153
  154 + {view === 'editor' && (
102 <Content style={{ padding: 16 }}> 155 <Content style={{ padding: 16 }}>
103 - <Spin spinning={loading}>  
104 - <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>  
105 - <Card style={{ flex: '1 1 62%' }} title={<Space><b>{schema?.sceneName || '加载中'}</b><Tag color="green">{sceneId}</Tag></Space>}>  
106 - {schema?.template ? <SceneForm key={formKey + sceneId} schema={schema} onExecuted={onExecuted} principal={principal} />  
107 - : schema ? <RunOnlyScene key={sceneId} schema={schema} onExecuted={onExecuted} principal={principal} />  
108 - : null}  
109 - </Card>  
110 -  
111 - <Card style={{ flex: '1 1 38%', position: 'sticky', top: 16 }} styles={{ body: { paddingTop: 8 } }}>  
112 - <Tabs  
113 - items={[  
114 - { key: 'trace', label: '执行追踪', children: <TracePanel result={result} /> },  
115 - { key: 'prov', label: '溯源/规则', children: schema ? <ProvenancePanel schema={schema} /> : null },  
116 - { key: 'orders', label: '数据/事件', children: <OrdersEventsPanel refreshKey={refreshKey} aggregateId={schema?.template?.masterAggregate || schema?.aggregateScope?.[0]} /> },  
117 - { key: 'model', label: '模型查看器', children: <ModelViewer /> },  
118 - ]} />  
119 - </Card>  
120 - </div>  
121 - </Spin> 156 + <Suspense fallback={<LazyFallback />}>
  157 + <ModelEditor principal={principal} onReloaded={afterModelChange} />
  158 + </Suspense>
  159 + </Content>
  160 + )}
  161 +
  162 + {(view === 'er' || view === 'network' || view === 'process') && (
  163 + <Content>
  164 + <Suspense fallback={<LazyFallback />}>
  165 + <OntologyCanvas which={view} />
  166 + </Suspense>
122 </Content> 167 </Content>
123 - </Layout> 168 + )}
124 </Layout> 169 </Layout>
125 ) 170 )
126 } 171 }
@@ -170,12 +215,12 @@ function HelpButton() { @@ -170,12 +215,12 @@ function HelpButton() {
170 <Modal open={open} onCancel={() => setOpen(false)} footer={null} width={720} title="如何验证:改了模型文件,操作定义同时改变"> 215 <Modal open={open} onCancel={() => setOpen(false)} footer={null} width={720} title="如何验证:改了模型文件,操作定义同时改变">
171 <Paragraph>本 Demo 的核心是<b>元数据驱动</b>:前端表单与后端行为都由 <Text code>models/*.yaml</Text> 实时解释,改模型即改行为,无需改任何代码。试试:</Paragraph> 216 <Paragraph>本 Demo 的核心是<b>元数据驱动</b>:前端表单与后端行为都由 <Text code>models/*.yaml</Text> 实时解释,改模型即改行为,无需改任何代码。试试:</Paragraph>
172 <ol style={{ lineHeight: 2 }}> 217 <ol style={{ lineHeight: 2 }}>
173 - <li>编辑 <Text code>models/M8-front-schema.yaml</Text>:给订单主信息卡片再加一个绑定 M1 字段的组件(如 totalAmount 旁加一个 input 绑 totalCurrency 的副本),或删掉某字段 → 点顶部<b>「热重载模型」</b> → 表单立即多/少一个字段。</li>  
174 - <li>编辑 <Text code>models/MetaRule-business.yaml</Text>:把 <Text code>single_order_max_amount</Text> 从 50000 改为 100 → 热重载 → 普通用户下单超 100 即被 <Tag color="red">REJECT</Tag>。</li>  
175 - <li>编辑 <Text code>models/M1-domain.yaml</Text>:把某字段 type 由 string 改为 int → 热重载 → 该组件输入类型随之改变;配合 M3 增列后可落库。</li>  
176 - <li>编辑 <Text code>models/M5-security.yaml</Text> 的 maskRules → 热重载 → 脱敏展示规则随之变化。</li> 218 + <li>进入<b>「模型编辑器 · 源码编辑」</b>,选 <Text code>M8-front-schema.yaml</Text> 给订单主信息卡片加/删一个绑定 M1 字段的组件 → <b>保存并热重载</b> → 回「场景运行台」,表单立即多/少一个字段。</li>
  219 + <li>编辑 <Text code>MetaRule-business.yaml</Text>:把 <Text code>single_order_max_amount</Text> 从 50000 改为 100 → 保存 → 普通用户下单超 100 即被 <Tag color="red">REJECT</Tag>。</li>
  220 + <li>编辑 <Text code>M1-domain.yaml</Text>:把某字段 type 由 string 改为 int → 保存 → 组件输入类型随之改变;<b>「数据结构图」</b>同步更新。</li>
  221 + <li><b>「本体网络图 / 场景流程图」</b>亦由本体动态合成,改模型后点视图内「刷新」即见变化。</li>
177 </ol> 222 </ol>
178 - <Paragraph type="secondary">「模型查看器」标签页可实时查看引擎当前加载的各层模型内容。</Paragraph> 223 + <Paragraph type="secondary">保存需管理员主体(右上切换为「后台管理员」)。</Paragraph>
179 </Modal> 224 </Modal>
180 </> 225 </>
181 ) 226 )
web/src/api.ts
@@ -9,11 +9,15 @@ async function get&lt;T = any&gt;(path: string): Promise&lt;T&gt; { @@ -9,11 +9,15 @@ async function get&lt;T = any&gt;(path: string): Promise&lt;T&gt; {
9 } 9 }
10 10
11 async function post<T = any>(path: string, body: any, principal?: string): Promise<T> { 11 async function post<T = any>(path: string, body: any, principal?: string): Promise<T> {
  12 + return send('POST', path, body, principal)
  13 +}
  14 +
  15 +async function send<T = any>(method: string, path: string, body: any, principal?: string): Promise<T> {
12 const headers: Record<string, string> = { 'Content-Type': 'application/json' } 16 const headers: Record<string, string> = { 'Content-Type': 'application/json' }
13 if (principal) headers['X-Principal'] = principal 17 if (principal) headers['X-Principal'] = principal
14 - const r = await fetch(BASE + path, { method: 'POST', headers, body: JSON.stringify(body) }) 18 + const r = await fetch(BASE + path, { method, headers, body: JSON.stringify(body) })
15 if (!r.ok) { 19 if (!r.ok) {
16 - let msg = `POST ${path} -> ${r.status}` 20 + let msg = `${method} ${path} -> ${r.status}`
17 try { const j = await r.json(); if (j?.message) msg = j.message } catch { /* ignore */ } 21 try { const j = await r.json(); if (j?.message) msg = j.message } catch { /* ignore */ }
18 throw new Error(msg) 22 throw new Error(msg)
19 } 23 }
@@ -26,6 +30,8 @@ export const api = { @@ -26,6 +30,8 @@ export const api = {
26 aggregate: (aggregateId: string) => get(`/meta/aggregate/${aggregateId}`), 30 aggregate: (aggregateId: string) => get(`/meta/aggregate/${aggregateId}`),
27 models: () => get('/meta/models'), 31 models: () => get('/meta/models'),
28 model: (code: string) => get(`/meta/model/${code}`), 32 model: (code: string) => get(`/meta/model/${code}`),
  33 + saveModel: (code: string, content: string, principal?: string) =>
  34 + send('PUT', `/meta/model/${code}`, { content }, principal),
29 reload: (principal?: string) => post('/meta/reload', {}, principal), 35 reload: (principal?: string) => post('/meta/reload', {}, principal),
30 options: (aggregateId: string) => get(`/data/${aggregateId}`), 36 options: (aggregateId: string) => get(`/data/${aggregateId}`),
31 list: (aggregateId: string) => get(`/data/${aggregateId}/list`), 37 list: (aggregateId: string) => get(`/data/${aggregateId}/list`),
web/src/model/ModelEditor.tsx 0 → 100644
  1 +import { useEffect, useMemo, useRef, useState } from 'react'
  2 +import {
  3 + Tree, Card, Descriptions, Tag, Table, Select, Button, Space, message, Alert,
  4 + Segmented, Empty, Typography, Input, Spin, Tooltip, InputNumber, Checkbox, Popconfirm, Popover,
  5 +} from 'antd'
  6 +import {
  7 + SaveOutlined, ReloadOutlined, ApartmentOutlined, CodeOutlined, EditOutlined, EyeOutlined,
  8 + PlusOutlined, DeleteOutlined,
  9 +} from '@ant-design/icons'
  10 +import { api } from '../api'
  11 +import { loadOntology } from '../ontology/build'
  12 +import type { Ontology, OntoObject, OntoBehavior, OntoRule, OntoEvent } from '../ontology/types'
  13 +import { EditSession, type Path } from './editSession'
  14 +
  15 +const { Text, Paragraph } = Typography
  16 +
  17 +const ATTR_TYPES = ['string', 'int', 'decimal', 'boolean', 'date', 'datetime', 'enum']
  18 +const EFFECTS = ['REJECT', 'ALERT', 'COMPENSATE']
  19 +const EDIT_LAYERS = ['M1', 'MetaRule', 'ME'] // 结构化可编辑的层(对象/规则/事件)
  20 +
  21 +/**
  22 + * 模型编辑器:models/*.yaml 是唯一事实源。既可在「结构预览」里可视化浏览 / 结构化编辑,
  23 + * 也可在「源码编辑」里改 YAML 原文。两种编辑都保存即写盘并热重载。等价 onto-app 的「本体模型」页。
  24 + */
  25 +export function ModelEditor({ principal, onReloaded }: { principal: string; onReloaded?: () => void }) {
  26 + const [mode, setMode] = useState<'structure' | 'source'>('structure')
  27 + return (
  28 + <div>
  29 + <Segmented
  30 + value={mode}
  31 + onChange={(v) => setMode(v as any)}
  32 + style={{ marginBottom: 12 }}
  33 + options={[
  34 + { label: '结构预览 / 编辑', value: 'structure', icon: <ApartmentOutlined /> },
  35 + { label: '源码编辑', value: 'source', icon: <CodeOutlined /> },
  36 + ]}
  37 + />
  38 + {mode === 'structure' ? <StructureBrowser principal={principal} onReloaded={onReloaded} /> : <SourceEditor principal={principal} onReloaded={onReloaded} />}
  39 + </div>
  40 + )
  41 +}
  42 +
  43 +// ============================================================
  44 +// 结构预览 + 结构化编辑:本体导航树 + 详情(可切换编辑)
  45 +// ============================================================
  46 +function StructureBrowser({ principal, onReloaded }: { principal: string; onReloaded?: () => void }) {
  47 + const [onto, setOnto] = useState<Ontology | null>(null)
  48 + const [err, setErr] = useState('')
  49 + const [sel, setSel] = useState<string>('')
  50 + const [editing, setEditing] = useState(false)
  51 + const [entering, setEntering] = useState(false)
  52 + const [saving, setSaving] = useState(false)
  53 + const sessionRef = useRef(new EditSession())
  54 + const [, setVer] = useState(0)
  55 + const bump = () => setVer((v) => v + 1)
  56 +
  57 + const reloadOnto = () => loadOntology().then(setOnto).catch((e) => setErr(String(e?.message || e)))
  58 + useEffect(() => { reloadOnto() }, [])
  59 +
  60 + const treeData = useMemo(() => (onto ? buildTree(onto) : []), [onto])
  61 + const dirty = [...sessionRef.current.dirty]
  62 +
  63 + async function enterEdit() {
  64 + setEntering(true)
  65 + try {
  66 + await Promise.all(EDIT_LAYERS.map((c) => sessionRef.current.ensure(c)))
  67 + setEditing(true); bump()
  68 + } catch (e: any) { message.error('进入编辑失败:' + e.message) } finally { setEntering(false) }
  69 + }
  70 +
  71 + async function save() {
  72 + setSaving(true)
  73 + try {
  74 + const saved = await sessionRef.current.saveAll(principal)
  75 + message.success('已保存并热重载:' + (saved.join(', ') || '(无改动)'))
  76 + await reloadOnto()
  77 + onReloaded?.()
  78 + bump()
  79 + } catch (e: any) { message.error('保存失败:' + e.message) } finally { setSaving(false) }
  80 + }
  81 +
  82 + function exitEdit() {
  83 + if (dirty.length && !window.confirm('有未保存的结构改动,退出将丢弃。确定?')) return
  84 + sessionRef.current.reset()
  85 + setEditing(false); bump()
  86 + }
  87 +
  88 + if (err) return <Alert type="error" showIcon message="加载本体失败" description={err} />
  89 + if (!onto) return <div style={{ padding: 40, textAlign: 'center' }}><Spin /></div>
  90 +
  91 + return (
  92 + <div>
  93 + <Space style={{ marginBottom: 10 }} wrap>
  94 + <Segmented
  95 + value={editing ? 'edit' : 'view'}
  96 + onChange={(v) => (v === 'edit' ? enterEdit() : exitEdit())}
  97 + options={[
  98 + { label: '预览', value: 'view', icon: <EyeOutlined /> },
  99 + { label: '编辑', value: 'edit', icon: <EditOutlined /> },
  100 + ]}
  101 + />
  102 + {entering && <Spin size="small" />}
  103 + {editing && (
  104 + <>
  105 + <Button type="primary" icon={<SaveOutlined />} loading={saving} disabled={!dirty.length} onClick={save}>
  106 + 保存并热重载
  107 + </Button>
  108 + {dirty.length > 0 && <span>改动层:{dirty.map((c) => <Tag key={c} color="orange">{c}</Tag>)}</span>}
  109 + <Text type="secondary" style={{ fontSize: 12 }}>结构化编辑对象(M1)/规则(MetaRule)/事件(ME);只改被编辑的节点,其余原文(含注释)不动。保存需管理员(当前 {principal})。</Text>
  110 + </>
  111 + )}
  112 + </Space>
  113 + <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
  114 + <Card size="small" style={{ width: 300, flex: 'none', maxHeight: 560, overflow: 'auto' }} styles={{ body: { padding: 8 } }}>
  115 + <Tree
  116 + treeData={treeData}
  117 + defaultExpandedKeys={['g-obj', 'g-beh', 'g-rule', 'g-evt', 'g-scene']}
  118 + selectedKeys={sel ? [sel] : []}
  119 + onSelect={(k) => setSel((k[0] as string) || '')}
  120 + showLine
  121 + blockNode
  122 + />
  123 + </Card>
  124 + <Card size="small" style={{ flex: 1, minHeight: 300 }}>
  125 + {editing
  126 + ? <EditDetail session={sessionRef.current} sel={sel} bump={bump} />
  127 + : <Detail onto={onto} sel={sel} />}
  128 + </Card>
  129 + </div>
  130 + </div>
  131 + )
  132 +}
  133 +
  134 +const TYPE_TAG: Record<string, { color: string; text: string }> = {
  135 + REJECT: { color: 'red', text: 'REJECT' }, ALERT: { color: 'orange', text: 'ALERT' },
  136 + COMPENSATE: { color: 'purple', text: 'COMPENSATE' },
  137 +}
  138 +
  139 +function buildTree(onto: Ontology): any[] {
  140 + const roots = onto.objects.filter((o) => o.is_aggregate_root)
  141 + const objChildren = roots.map((r) => ({
  142 + key: `obj:${r.id}`,
  143 + title: <span>{r.name} <Text type="secondary" style={{ fontSize: 11 }}>{r.alias}</Text></span>,
  144 + children: onto.objects.filter((o) => o.parent_id === r.id).map((e) => ({
  145 + key: `obj:${e.id}`,
  146 + title: <span>{e.name} <Text type="secondary" style={{ fontSize: 11 }}>子实体</Text></span>,
  147 + })),
  148 + }))
  149 + const behByOwner = new Map<string, OntoBehavior[]>()
  150 + onto.behaviors.forEach((b) => { const k = b.owner_object_id; behByOwner.set(k, [...(behByOwner.get(k) || []), b]) })
  151 + const behChildren = [...behByOwner.entries()].map(([owner, list]) => ({
  152 + key: `bg:${owner}`, selectable: false, title: <Text type="secondary">{owner}</Text>,
  153 + children: list.map((b) => ({ key: `beh:${b.id}`, title: b.name })),
  154 + }))
  155 + const evtByAgg = new Map<string, OntoEvent[]>()
  156 + onto.events.forEach((e) => { const k = e.aggregate || '其他'; evtByAgg.set(k, [...(evtByAgg.get(k) || []), e]) })
  157 + const evtChildren = [...evtByAgg.entries()].map(([agg, list]) => ({
  158 + key: `eg:${agg}`, selectable: false, title: <Text type="secondary">{agg}</Text>,
  159 + children: list.map((e) => ({ key: `evt:${e.id}`, title: e.name })),
  160 + }))
  161 + return [
  162 + { key: 'g-obj', selectable: false, title: <b>对象模型 ({onto.objects.length})</b>, children: objChildren },
  163 + { key: 'g-beh', selectable: false, title: <b>行为模型 ({onto.behaviors.length})</b>, children: behChildren },
  164 + { key: 'g-rule', selectable: false, title: <b>规则模型 ({onto.rules.length})</b>, children: onto.rules.map((r) => ({ key: `rule:${r.id}`, title: r.name })) },
  165 + { key: 'g-evt', selectable: false, title: <b>事件模型 ({onto.events.length})</b>, children: evtChildren },
  166 + { key: 'g-scene', selectable: false, title: <b>场景流程 ({onto.processModels.length})</b>, children: onto.processModels.map((p) => ({ key: `scene:${p.id}`, title: p.name })) },
  167 + ]
  168 +}
  169 +
  170 +function splitSel(sel: string): [string, string] {
  171 + const i = sel.indexOf(':')
  172 + return [sel.slice(0, i), sel.slice(i + 1)]
  173 +}
  174 +
  175 +// ============================================================
  176 +// 只读详情
  177 +// ============================================================
  178 +function Detail({ onto, sel }: { onto: Ontology; sel: string }) {
  179 + if (!sel) return <Empty description="从左侧选择一个对象 / 行为 / 规则 / 事件 / 场景查看结构" />
  180 + const [kind, id] = splitSel(sel)
  181 + if (kind === 'obj') { const o = onto.objects.find((x) => x.id === id); return o ? <ObjectDetail o={o} /> : null }
  182 + if (kind === 'beh') { const b = onto.behaviors.find((x) => x.id === id); return b ? <BehaviorDetail b={b} onto={onto} /> : null }
  183 + if (kind === 'rule') { const r = onto.rules.find((x) => x.id === id); return r ? <RuleDetail r={r} /> : null }
  184 + if (kind === 'evt') { const e = onto.events.find((x) => x.id === id); return e ? <EventDetail e={e} /> : null }
  185 + if (kind === 'scene') {
  186 + const p = onto.processModels.find((x) => x.id === id)
  187 + if (!p) return null
  188 + return (
  189 + <div>
  190 + <Space style={{ marginBottom: 8 }}><b style={{ fontSize: 16 }}>{p.name}</b><Tag color="purple">{p.id}</Tag></Space>
  191 + <Paragraph type="secondary">流程节点(由 M4 flowSteps 合成,完整可视化见「场景流程图」视图):</Paragraph>
  192 + <Table size="small" pagination={false} rowKey="id" dataSource={p.nodes}
  193 + columns={[
  194 + { title: '节点', dataIndex: 'name' },
  195 + { title: '类型', dataIndex: 'type', render: (t: string) => <Tag>{t}</Tag> },
  196 + { title: '关联命令', dataIndex: 'behaviorId', render: (v: string) => v || '—' },
  197 + ]} />
  198 + </div>
  199 + )
  200 + }
  201 + return null
  202 +}
  203 +
  204 +function ObjectDetail({ o }: { o: OntoObject }) {
  205 + return (
  206 + <div>
  207 + <Space style={{ marginBottom: 10 }}>
  208 + <b style={{ fontSize: 16 }}>{o.name}</b>
  209 + <Tag color="blue">{o.alias}</Tag>
  210 + <Tag color={o.is_aggregate_root ? 'gold' : 'default'}>{o.is_aggregate_root ? '聚合根' : `子实体 · 属于 ${o.parent_id}`}</Tag>
  211 + </Space>
  212 + {o.description && <Paragraph type="secondary">{o.description}</Paragraph>}
  213 + <Text strong>属性({o.attributes.length})</Text>
  214 + <Table size="small" pagination={false} rowKey="name" style={{ marginTop: 6 }} dataSource={o.attributes}
  215 + columns={[
  216 + {
  217 + title: '字段', dataIndex: 'name', render: (v: string, a: any) => (
  218 + <Space size={4}>
  219 + {a.is_primary_key && <Tooltip title="主键"><span>🔑</span></Tooltip>}
  220 + {a.is_foreign_key && <Tooltip title="外键/引用"><span>🔗</span></Tooltip>}
  221 + <span className="om-mono">{v}</span>
  222 + </Space>
  223 + ),
  224 + },
  225 + { title: '类型', dataIndex: 'type' },
  226 + { title: '引用', dataIndex: 'ref', render: (r: any) => (r ? <Tag color="purple">→ {r.root}.{r.identifier}</Tag> : '—') },
  227 + {
  228 + title: '约束/标注', key: 'c', render: (_: any, a: any) => (
  229 + <Space size={4} wrap>
  230 + {a.transient && <Tag color="magenta">瞬态</Tag>}
  231 + {a.unique && <Tag>唯一</Tag>}
  232 + {a.constraints && Object.entries(a.constraints).filter(([k]) => k !== 'patternMessage').map(([k, v]) => <Tag key={k}>{k}={String(v)}</Tag>)}
  233 + </Space>
  234 + ),
  235 + },
  236 + ]} />
  237 + {o.relationships.length > 0 && (
  238 + <>
  239 + <Text strong style={{ display: 'block', marginTop: 12 }}>关系({o.relationships.length})</Text>
  240 + <Space direction="vertical" style={{ marginTop: 6, width: '100%' }}>
  241 + {o.relationships.map((r, i) => (
  242 + <div key={i}>
  243 + <Tag color={r.refType === 'composition' ? 'purple' : 'blue'}>{r.refType === 'composition' ? '组合' : '引用'}</Tag>
  244 + <Text>{r.fk_field ? `${r.fk_field} → ` : ''}{r.target_object_id}</Text>
  245 + <Text type="secondary" style={{ marginLeft: 8 }}>{r.kind}</Text>
  246 + </div>
  247 + ))}
  248 + </Space>
  249 + </>
  250 + )}
  251 + </div>
  252 + )
  253 +}
  254 +
  255 +function BehaviorDetail({ b, onto }: { b: OntoBehavior; onto: Ontology }) {
  256 + const item = (label: string, node: any) => <Descriptions.Item label={label}>{node}</Descriptions.Item>
  257 + return (
  258 + <div>
  259 + <Space style={{ marginBottom: 10 }}><b style={{ fontSize: 16 }}>{b.name}</b><Tag color="green">COMMAND</Tag><Tag>{b.commandType}</Tag></Space>
  260 + <Descriptions size="small" column={1} bordered>
  261 + {item('归属对象', b.owner_object_id)}
  262 + {b.description && item('说明', b.description)}
  263 + {b.produced_events.length > 0 && item('产出事件', b.produced_events.map((e) => <Tag key={e} color="magenta">{e}</Tag>))}
  264 + {b.applied_rules.length > 0 && item('适用规则', b.applied_rules.map((r) => <Tag key={r} color="red">{onto.rules.find((x) => x.id === r)?.name || r}</Tag>))}
  265 + {b.preconditions.length > 0 && item('前置校验', <ul style={{ margin: 0, paddingLeft: 18 }}>{b.preconditions.map((p, i) => <li key={i} className="om-mono">{p}</li>)}</ul>)}
  266 + {b.effects.length > 0 && item('派生/副作用', <ul style={{ margin: 0, paddingLeft: 18 }}>{b.effects.map((e, i) => <li key={i} className="om-mono">{e}</li>)}</ul>)}
  267 + </Descriptions>
  268 + </div>
  269 + )
  270 +}
  271 +
  272 +function RuleDetail({ r }: { r: OntoRule }) {
  273 + const t = TYPE_TAG[r.type] || { color: 'default', text: r.type }
  274 + return (
  275 + <div>
  276 + <Space style={{ marginBottom: 10 }}><b style={{ fontSize: 16 }}>{r.name}</b><Tag color={t.color}>{t.text}</Tag><Text code>{r.id}</Text></Space>
  277 + <Descriptions size="small" column={1} bordered>
  278 + <Descriptions.Item label="匹配条件"><span className="om-mono">{r.expression}</span></Descriptions.Item>
  279 + <Descriptions.Item label="提示信息">{r.description}</Descriptions.Item>
  280 + <Descriptions.Item label="绑定场景">{r.bindScene || '—'}</Descriptions.Item>
  281 + </Descriptions>
  282 + </div>
  283 + )
  284 +}
  285 +
  286 +function EventDetail({ e }: { e: OntoEvent }) {
  287 + return (
  288 + <div>
  289 + <Space style={{ marginBottom: 10 }}><b style={{ fontSize: 16 }}>{e.name}</b><Tag color="magenta">事件</Tag></Space>
  290 + <Descriptions size="small" column={1} bordered>
  291 + <Descriptions.Item label="归属聚合">{e.aggregate}</Descriptions.Item>
  292 + <Descriptions.Item label="topic"><span className="om-mono">{e.topic}</span></Descriptions.Item>
  293 + <Descriptions.Item label="载荷">{(e.payload || []).map((p) => <Tag key={p}>{p}</Tag>)}</Descriptions.Item>
  294 + <Descriptions.Item label="消费方">{(e.consumers || []).map((c) => <Tag key={c} color="blue">{c}</Tag>)}</Descriptions.Item>
  295 + </Descriptions>
  296 + </div>
  297 + )
  298 +}
  299 +
  300 +// ============================================================
  301 +// 结构化编辑详情:对象(M1) / 规则(MetaRule) / 事件(ME)
  302 +// ============================================================
  303 +function EditDetail({ session, sel, bump }: { session: EditSession; sel: string; bump: () => void }) {
  304 + if (!sel) return <Empty description="从左侧选择一个对象 / 规则 / 事件进行结构化编辑" />
  305 + const [kind, id] = splitSel(sel)
  306 + if (kind === 'obj') return <ObjectEditForm session={session} id={id} bump={bump} />
  307 + if (kind === 'rule') return <RuleEditForm session={session} id={id} bump={bump} />
  308 + if (kind === 'evt') return <EventEditForm session={session} id={id} bump={bump} />
  309 + return <Alert type="info" showIcon message="该节点暂不支持结构化编辑"
  310 + description="行为(M2 bizSteps) / 场景(M4 flowSteps) 结构复杂,请切到「源码编辑」修改;对象、规则、事件可在此结构化编辑。" />
  311 +}
  312 +
  313 +function locateObject(m1: any, id: string): { aggIdx: number; kind: 'root' | 'entity'; entIdx?: number; node: any; agg: any } | null {
  314 + const aggs = m1?.aggregates || []
  315 + for (let ai = 0; ai < aggs.length; ai++) {
  316 + const root = aggs[ai].aggregateRoot || {}
  317 + if (root.name === id) return { aggIdx: ai, kind: 'root', node: root, agg: aggs[ai] }
  318 + const ents = aggs[ai].entities || []
  319 + for (let ei = 0; ei < ents.length; ei++) if (ents[ei].name === id) return { aggIdx: ai, kind: 'entity', entIdx: ei, node: ents[ei], agg: aggs[ai] }
  320 + }
  321 + return null
  322 +}
  323 +
  324 +/** 约束编辑:展示全部现有约束(与预览一致)+ 弹层编辑全集(唯一/min/max/exclusiveMin/exclusiveMax/pattern/patternMessage)。 */
  325 +function ConstraintCell({ session, path, a, bump }: { session: EditSession; path: Path; a: any; bump: () => void }) {
  326 + const cons = a.constraints || {}
  327 + const setC = (key: string, val: any) => { session.setConstraint('M1', path, key, val); bump() }
  328 + // v ?? undefined 保留 0(min:0 / exclusiveMin:0 均为合法值),清空(null)才删除该约束键
  329 + const num = (key: string) => (
  330 + <span style={{ whiteSpace: 'nowrap' }}>
  331 + <Text type="secondary" style={{ fontSize: 12, marginRight: 4 }}>{key}</Text>
  332 + <InputNumber size="small" style={{ width: 84 }} value={cons[key] ?? null} onChange={(v) => setC(key, v ?? undefined)} />
  333 + </span>
  334 + )
  335 + const content = (
  336 + <div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 10 }}>
  337 + <Checkbox checked={!!cons.unique} onChange={(e) => setC('unique', e.target.checked)}>唯一 unique</Checkbox>
  338 + <Space wrap>{num('min')}{num('max')}</Space>
  339 + <Space wrap>{num('exclusiveMin')}{num('exclusiveMax')}</Space>
  340 + <div>
  341 + <Text type="secondary" style={{ fontSize: 12 }}>正则 pattern</Text>
  342 + <Input size="small" className="om-mono" value={cons.pattern || ''} onChange={(e) => setC('pattern', e.target.value)} placeholder="如 ^\d{11}$" />
  343 + </div>
  344 + <div>
  345 + <Text type="secondary" style={{ fontSize: 12 }}>校验提示 patternMessage</Text>
  346 + <Input size="small" value={cons.patternMessage || ''} onChange={(e) => setC('patternMessage', e.target.value)} placeholder="不满足正则时的提示语" />
  347 + </div>
  348 + </div>
  349 + )
  350 + const keys = Object.keys(cons)
  351 + return (
  352 + <Space size={4} wrap>
  353 + {keys.length === 0 && <Text type="secondary">—</Text>}
  354 + {keys.filter((k) => k !== 'patternMessage').map((k) => <Tag key={k} style={{ margin: 0 }} className="om-mono">{k}={String(cons[k])}</Tag>)}
  355 + {a.refAggregate && <Tag color="purple" style={{ margin: 0 }}>→ {a.refRoot}.{a.refIdentifier}</Tag>}
  356 + <Popover trigger="click" placement="leftTop" title="编辑约束" content={content}>
  357 + <Button size="small" type="link" style={{ padding: '0 4px' }} icon={<EditOutlined />} />
  358 + </Popover>
  359 + </Space>
  360 + )
  361 +}
  362 +
  363 +function ObjectEditForm({ session, id, bump }: { session: EditSession; id: string; bump: () => void }) {
  364 + const m1 = session.toJS('M1')
  365 + const loc = locateObject(m1, id)
  366 + if (!loc) return <Alert type="warning" message={`在 M1 中找不到对象 ${id}(可能刚被改名,请重新选择)`} />
  367 + const basePath: Path = loc.kind === 'root' ? ['aggregates', loc.aggIdx, 'aggregateRoot'] : ['aggregates', loc.aggIdx, 'entities', loc.entIdx!]
  368 + const attrsPath: Path = [...basePath, 'attributes']
  369 + const idField = loc.kind === 'root' ? 'identifier' : 'localIdentifier'
  370 + const attrs: any[] = loc.node.attributes || []
  371 + const set = (p: Path, v: any) => { session.setIn('M1', p, v); bump() }
  372 +
  373 + return (
  374 + <div>
  375 + <Space style={{ marginBottom: 10 }} wrap>
  376 + <b style={{ fontSize: 16 }}>{loc.node.name}</b>
  377 + <Tag color={loc.kind === 'root' ? 'gold' : 'default'}>{loc.kind === 'root' ? '聚合根' : '子实体'}</Tag>
  378 + <Tag color="blue">{loc.agg.aggregateId}</Tag>
  379 + <Text type="secondary" style={{ fontSize: 12 }}>(对象改名请用源码编辑,避免跨层引用错乱)</Text>
  380 + </Space>
  381 +
  382 + <Descriptions size="small" column={1} bordered style={{ marginBottom: 12 }}>
  383 + <Descriptions.Item label={<Space>🔑 主键字段名</Space>}>
  384 + <Input size="small" value={loc.node[idField] || ''} onChange={(e) => set([...basePath, idField], e.target.value)} style={{ maxWidth: 260 }} />
  385 + </Descriptions.Item>
  386 + {loc.kind === 'root' && (
  387 + <Descriptions.Item label="聚合描述">
  388 + <Input.TextArea size="small" autoSize value={loc.agg.description || ''} onChange={(e) => set(['aggregates', loc.aggIdx, 'description'], e.target.value)} />
  389 + </Descriptions.Item>
  390 + )}
  391 + </Descriptions>
  392 +
  393 + <Space style={{ marginBottom: 6 }}>
  394 + <Text strong>属性({attrs.length})</Text>
  395 + <Button size="small" icon={<PlusOutlined />} onClick={() => { session.addIn('M1', attrsPath, { name: 'newField', type: 'string' }); bump() }}>添加属性</Button>
  396 + </Space>
  397 + <Table size="small" pagination={false} rowKey={(_, i) => String(i)} dataSource={attrs}
  398 + columns={[
  399 + {
  400 + title: '字段名', width: 200, render: (_: any, a: any, j: number) => (
  401 + <Space size={4}>
  402 + {loc.node[idField] === a.name && <Tooltip title="主键"><span>🔑</span></Tooltip>}
  403 + {a.refAggregate && <Tooltip title={`引用 ${a.refRoot}.${a.refIdentifier}`}><span>🔗</span></Tooltip>}
  404 + <Input size="small" className="om-mono" value={a.name || ''} onChange={(e) => set([...attrsPath, j, 'name'], e.target.value)} />
  405 + </Space>
  406 + ),
  407 + },
  408 + {
  409 + title: '类型', width: 130, render: (_: any, a: any, j: number) => (
  410 + <Select size="small" style={{ width: 118 }} value={a.type || 'string'} options={ATTR_TYPES.map((t) => ({ value: t, label: t }))}
  411 + onChange={(v) => set([...attrsPath, j, 'type'], v)} />
  412 + ),
  413 + },
  414 + {
  415 + title: '约束 / 引用', render: (_: any, a: any, j: number) => (
  416 + <ConstraintCell session={session} path={[...attrsPath, j]} a={a} bump={bump} />
  417 + ),
  418 + },
  419 + {
  420 + title: '', width: 40, render: (_: any, __: any, j: number) => (
  421 + <Popconfirm title="删除该属性?" onConfirm={() => { session.deleteIn('M1', [...attrsPath, j]); bump() }}>
  422 + <Button size="small" type="text" danger icon={<DeleteOutlined />} />
  423 + </Popconfirm>
  424 + ),
  425 + },
  426 + ]} />
  427 + <Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
  428 + 提示:改字段类型 / 增删字段后点上方「保存并热重载」,「数据结构图」「场景运行台」表单会同步变化。
  429 + </Text>
  430 + </div>
  431 + )
  432 +}
  433 +
  434 +function locateRule(mr: any, id: string): { gIdx: number; rIdx: number; node: any } | null {
  435 + const groups = mr?.metaRuleModel?.ruleGroups || []
  436 + for (let g = 0; g < groups.length; g++) {
  437 + const rules = groups[g].rules || []
  438 + for (let r = 0; r < rules.length; r++) if (rules[r].ruleId === id) return { gIdx: g, rIdx: r, node: rules[r] }
  439 + }
  440 + return null
  441 +}
  442 +
  443 +function RuleEditForm({ session, id, bump }: { session: EditSession; id: string; bump: () => void }) {
  444 + const mr = session.toJS('MetaRule')
  445 + const loc = locateRule(mr, id)
  446 + if (!loc) return <Alert type="warning" message={`在 MetaRule 中找不到规则 ${id}`} />
  447 + const base: Path = ['metaRuleModel', 'ruleGroups', loc.gIdx, 'rules', loc.rIdx]
  448 + const set = (field: string, v: any) => { session.setIn('MetaRule', [...base, field], v); bump() }
  449 + return (
  450 + <div>
  451 + <Space style={{ marginBottom: 10 }}><b style={{ fontSize: 16 }}>{loc.node.ruleName}</b><Text code>{loc.node.ruleId}</Text></Space>
  452 + <Descriptions size="small" column={1} bordered>
  453 + <Descriptions.Item label="规则名"><Input size="small" value={loc.node.ruleName || ''} onChange={(e) => set('ruleName', e.target.value)} /></Descriptions.Item>
  454 + <Descriptions.Item label="效果">
  455 + <Select size="small" style={{ width: 180 }} value={loc.node.effect} options={EFFECTS.map((x) => ({ value: x, label: x }))} onChange={(v) => set('effect', v)} />
  456 + </Descriptions.Item>
  457 + <Descriptions.Item label="匹配条件 (Aviator)"><Input.TextArea size="small" autoSize className="om-mono" value={loc.node.matchCondition || ''} onChange={(e) => set('matchCondition', e.target.value)} /></Descriptions.Item>
  458 + <Descriptions.Item label="提示信息"><Input.TextArea size="small" autoSize value={loc.node.message || ''} onChange={(e) => set('message', e.target.value)} /></Descriptions.Item>
  459 + </Descriptions>
  460 + <Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>提示:改匹配条件/阈值后保存,前后端规则即时生效(如把大额订单阈值改小,普通用户下单更易被 REJECT)。</Text>
  461 + </div>
  462 + )
  463 +}
  464 +
  465 +function locateEvent(me: any, id: string): { dIdx: number; name: string; node: any } | null {
  466 + const defs = me?.eventModel?.aggregateEventDefinitions || []
  467 + for (let d = 0; d < defs.length; d++) {
  468 + const events = defs[d].events || {}
  469 + if (id in events) return { dIdx: d, name: id, node: events[id] }
  470 + }
  471 + return null
  472 +}
  473 +
  474 +function EventEditForm({ session, id, bump }: { session: EditSession; id: string; bump: () => void }) {
  475 + const me = session.toJS('ME')
  476 + const loc = locateEvent(me, id)
  477 + if (!loc) return <Alert type="warning" message={`在 ME 中找不到事件 ${id}`} />
  478 + const base: Path = ['eventModel', 'aggregateEventDefinitions', loc.dIdx, 'events', loc.name]
  479 + const set = (field: string, v: any) => { session.setIn('ME', [...base, field], v); bump() }
  480 + return (
  481 + <div>
  482 + <Space style={{ marginBottom: 10 }}><b style={{ fontSize: 16 }}>{loc.name}</b><Tag color="magenta">事件</Tag></Space>
  483 + <Descriptions size="small" column={1} bordered>
  484 + <Descriptions.Item label="topic"><Input size="small" className="om-mono" value={loc.node.topic || ''} onChange={(e) => set('topic', e.target.value)} /></Descriptions.Item>
  485 + <Descriptions.Item label="载荷字段">
  486 + <Select size="small" mode="tags" style={{ width: '100%' }} value={loc.node.payload || []} onChange={(v) => set('payload', v)} placeholder="回车添加字段" />
  487 + </Descriptions.Item>
  488 + <Descriptions.Item label="消费方">
  489 + <Select size="small" mode="tags" style={{ width: '100%' }} value={loc.node.consumers || []} onChange={(v) => set('consumers', v)} placeholder="回车添加消费方服务" />
  490 + </Descriptions.Item>
  491 + </Descriptions>
  492 + </div>
  493 + )
  494 +}
  495 +
  496 +// ============================================================
  497 +// 源码编辑:逐层编辑 YAML 原文,保存即热重载
  498 +// ============================================================
  499 +function SourceEditor({ principal, onReloaded }: { principal: string; onReloaded?: () => void }) {
  500 + const [layers, setLayers] = useState<{ code: string; fileName: string }[]>([])
  501 + const [code, setCode] = useState('M1')
  502 + const [text, setText] = useState('')
  503 + const [orig, setOrig] = useState('')
  504 + const [dir, setDir] = useState('')
  505 + const [loading, setLoading] = useState(false)
  506 + const [saving, setSaving] = useState(false)
  507 +
  508 + useEffect(() => { api.models().then((d) => { setLayers(d.layers); setDir(d.modelsDir) }) }, [])
  509 +
  510 + const loadLayer = (c: string) => {
  511 + setLoading(true)
  512 + api.model(c).then((d) => { setText(d.raw ?? ''); setOrig(d.raw ?? '') }).finally(() => setLoading(false))
  513 + }
  514 + useEffect(() => { if (code) loadLayer(code) }, [code])
  515 +
  516 + const dirty = text !== orig
  517 +
  518 + const switchLayer = (c: string) => {
  519 + if (dirty && !window.confirm('当前层有未保存的修改,切换将丢弃。确定切换?')) return
  520 + setCode(c)
  521 + }
  522 +
  523 + async function save() {
  524 + setSaving(true)
  525 + try {
  526 + const r = await api.saveModel(code, text, principal)
  527 + message.success(r.message || '已保存')
  528 + setOrig(text)
  529 + onReloaded?.()
  530 + } catch (e: any) {
  531 + message.error('保存失败:' + e.message)
  532 + } finally { setSaving(false) }
  533 + }
  534 +
  535 + return (
  536 + <div>
  537 + <Space style={{ marginBottom: 8 }} wrap>
  538 + <Select value={code} style={{ width: 300 }} onChange={switchLayer}
  539 + options={layers.map((l) => ({ value: l.code, label: `${l.code} · ${l.fileName}` }))} />
  540 + <Button type="primary" icon={<SaveOutlined />} loading={saving} disabled={!dirty} onClick={save}>
  541 + 保存并热重载
  542 + </Button>
  543 + <Button icon={<ReloadOutlined />} disabled={!dirty} onClick={() => setText(orig)}>撤销修改</Button>
  544 + {dirty && <Tag color="orange">未保存</Tag>}
  545 + </Space>
  546 + <Alert style={{ marginBottom: 8 }} type="info" showIcon
  547 + message={<span>直接编辑 <Text code>{dir}/{layers.find((l) => l.code === code)?.fileName}</Text> 的 YAML 原文;保存即写盘并热重载(含 M1/M3 变更自动补表)。<b>仅管理员主体可保存</b>(当前:{principal})。</span>} />
  548 + <Spin spinning={loading}>
  549 + <Input.TextArea
  550 + value={text}
  551 + onChange={(e) => setText(e.target.value)}
  552 + spellCheck={false}
  553 + style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12.5, lineHeight: 1.5, minHeight: 480, whiteSpace: 'pre', overflowX: 'auto' }}
  554 + autoSize={{ minRows: 22, maxRows: 40 }}
  555 + />
  556 + </Spin>
  557 + </div>
  558 + )
  559 +}
web/src/model/editSession.ts 0 → 100644
  1 +// 结构化编辑会话:每个模型层持有一份 yaml Document(保留注释),编辑走「按路径改节点」,
  2 +// 只动被编辑的节点、其余原文(含注释/顺序)不变。保存时把脏层逐个 PUT 回后端。
  3 +import { parseDocument, type Document } from 'yaml'
  4 +import { api } from '../api'
  5 +
  6 +export type Path = (string | number)[]
  7 +
  8 +export class EditSession {
  9 + private docs = new Map<string, Document>()
  10 + dirty = new Set<string>()
  11 +
  12 + /** 懒加载某层的 Document(用后端返回的源文件原文,保留注释)。 */
  13 + async ensure(code: string): Promise<Document> {
  14 + if (!this.docs.has(code)) {
  15 + const d = await api.model(code)
  16 + this.docs.set(code, parseDocument(d.raw ?? ''))
  17 + }
  18 + return this.docs.get(code)!
  19 + }
  20 +
  21 + has(code: string) { return this.docs.has(code) }
  22 + toJS(code: string): any { return this.docs.get(code)?.toJS() }
  23 +
  24 + setIn(code: string, path: Path, value: any) {
  25 + const doc = this.docs.get(code); if (!doc) return
  26 + doc.setIn(path, value)
  27 + this.dirty.add(code)
  28 + }
  29 +
  30 + deleteIn(code: string, path: Path) {
  31 + const doc = this.docs.get(code); if (!doc) return
  32 + doc.deleteIn(path)
  33 + this.dirty.add(code)
  34 + }
  35 +
  36 + /** 在某数组末尾追加一个节点。 */
  37 + addIn(code: string, arrayPath: Path, value: any) {
  38 + const doc = this.docs.get(code); if (!doc) return
  39 + doc.addIn(arrayPath, doc.createNode(value))
  40 + this.dirty.add(code)
  41 + }
  42 +
  43 + /** 设置一个约束键;value 为空/假时删除该键,并在约束对象空了时删除整个 constraints。 */
  44 + setConstraint(code: string, attrPath: Path, key: string, value: any) {
  45 + const doc = this.docs.get(code); if (!doc) return
  46 + const consPath = [...attrPath, 'constraints']
  47 + if (value === undefined || value === null || value === '' || value === false) {
  48 + doc.deleteIn([...consPath, key])
  49 + const cons = doc.getIn(consPath) as any
  50 + if (cons && typeof cons.toJSON === 'function') {
  51 + const j = cons.toJSON()
  52 + if (!j || Object.keys(j).length === 0) doc.deleteIn(consPath)
  53 + }
  54 + } else {
  55 + if (doc.getIn(consPath) === undefined) doc.setIn(consPath, doc.createNode({}))
  56 + doc.setIn([...consPath, key], value)
  57 + }
  58 + this.dirty.add(code)
  59 + }
  60 +
  61 + // lineWidth:0 关闭自动折行,避免把未编辑的长字符串(如 description)重新折行造成无谓 diff。
  62 + render(code: string): string { return this.docs.get(code)?.toString({ lineWidth: 0 }) ?? '' }
  63 +
  64 + async saveAll(principal: string): Promise<string[]> {
  65 + const saved: string[] = []
  66 + for (const code of [...this.dirty]) {
  67 + await api.saveModel(code, this.render(code), principal)
  68 + saved.push(code)
  69 + }
  70 + this.dirty.clear()
  71 + return saved
  72 + }
  73 +
  74 + reset() { this.docs.clear(); this.dirty.clear() }
  75 +}
web/src/ontology/bpmn.ts 0 → 100644
  1 +// ProcessModel → ELK-BPMN JSON(bpmn-elk-layout 的输入)+ 点击详情表。
  2 +// 节点 id 重编为合法 NCName(Node_N),序列流端点按映射改写,与详情表 id 完全一致。
  3 +
  4 +import type { Ontology, ProcessModel, ProcessNode } from './types'
  5 +
  6 +const EVENT_TYPES = new Set(['startEvent', 'endEvent', 'intermediateCatchEvent'])
  7 +const GATEWAY_TYPES = new Set(['exclusiveGateway', 'parallelGateway'])
  8 +
  9 +function elkNode(n: ProcessNode, nid: string) {
  10 + const bpmn: any = {}
  11 + if (EVENT_TYPES.has(n.type)) { bpmn.type = n.type; bpmn.eventDefinitionType = 'none' }
  12 + else if (GATEWAY_TYPES.has(n.type)) bpmn.type = n.type
  13 + else bpmn.type = n.type === 'userTask' ? 'userTask' : 'task'
  14 + if (n.name) bpmn.name = n.name
  15 + return { id: nid, bpmn }
  16 +}
  17 +
  18 +// 仅加大层间距,给分支条件标签留位置;其余布局用库默认。
  19 +const ELK_OPTS = { 'elk.layered.spacing.nodeNodeBetweenLayers': 90 }
  20 +
  21 +/** ProcessModel → ELK-BPMN JSON。 */
  22 +export function toElkBpmn(pm: ProcessModel): any {
  23 + const idmap = new Map<string, string>()
  24 + pm.nodes.forEach((n, i) => idmap.set(n.id, `Node_${i + 1}`))
  25 +
  26 + const edges = pm.flows.flatMap((f, i) => {
  27 + const s = idmap.get(f.source), t = idmap.get(f.target)
  28 + if (!s || !t) return []
  29 + const b: any = { type: 'sequenceFlow' }
  30 + const label = f.name || f.condition
  31 + if (label) b.name = label
  32 + if (f.condition) b.conditionExpression = { body: f.condition }
  33 + return [{ id: `Flow_${i + 1}`, sources: [s], targets: [t], bpmn: b }]
  34 + })
  35 +
  36 + return {
  37 + id: 'definitions',
  38 + children: [{
  39 + id: 'Process_1',
  40 + bpmn: { type: 'process' },
  41 + layoutOptions: { ...ELK_OPTS },
  42 + children: pm.nodes.map((n) => elkNode(n, idmap.get(n.id)!)),
  43 + edges,
  44 + }],
  45 + }
  46 +}
  47 +
  48 +const KIND_TEXT: Record<string, string> = {
  49 + startEvent: '开始事件', endEvent: '结束事件', task: '任务', userTask: '人工任务',
  50 + exclusiveGateway: '排他网关(分支判断)', parallelGateway: '并行网关', intermediateCatchEvent: '中间事件',
  51 +}
  52 +
  53 +/** bpmn 元素 id → 详情(点击查看:任务关联行为、网关分支、连线条件)。 */
  54 +export function detailMap(pm: ProcessModel, onto: Ontology): Record<string, any> {
  55 + const idmap = new Map<string, string>()
  56 + pm.nodes.forEach((n, i) => idmap.set(n.id, `Node_${i + 1}`))
  57 + const nameOf = (id: string) => pm.nodes.find((n) => n.id === id)?.name || id
  58 + const details: Record<string, any> = {}
  59 +
  60 + for (const n of pm.nodes) {
  61 + const d: any = { kind: n.type, kindLabel: KIND_TEXT[n.type] || n.type, name: n.name || '', lane: n.lane || '' }
  62 + if (n.behaviorId) {
  63 + const b = onto.behaviors.find((x) => x.id === n.behaviorId)
  64 + if (b) {
  65 + const owner = onto.objects.find((o) => o.id === b.owner_object_id)
  66 + d.behavior = {
  67 + id: b.id, name: b.name, type: b.type, commandType: b.commandType,
  68 + owner: owner ? owner.name : b.owner_object_id, description: b.description || '',
  69 + preconditions: b.preconditions, effects: b.effects,
  70 + produced_events: b.produced_events,
  71 + applied_rules: b.applied_rules.map((rid) => onto.rules.find((r) => r.id === rid)?.name || rid),
  72 + }
  73 + }
  74 + }
  75 + if (GATEWAY_TYPES.has(n.type)) {
  76 + d.branches = pm.flows.filter((f) => f.source === n.id).map((f) => ({
  77 + to: nameOf(f.target), label: f.name || '', condition: f.condition || '',
  78 + }))
  79 + }
  80 + details[idmap.get(n.id)!] = d
  81 + }
  82 +
  83 + pm.flows.forEach((f, i) => {
  84 + if (!idmap.get(f.source) || !idmap.get(f.target)) return
  85 + details[`Flow_${i + 1}`] = {
  86 + kind: 'sequenceFlow', kindLabel: '连线 / 分支', name: f.name || '', condition: f.condition || '',
  87 + from: nameOf(f.source), to: nameOf(f.target),
  88 + }
  89 + })
  90 + return details
  91 +}
web/src/ontology/build.ts 0 → 100644
  1 +// 本体 IR 合成器:从后端 /api/meta/model/{code}(解析后的 YAML→JSON)在浏览器端合成统一 IR。
  2 +// 等价于 onto-app 里 Python codegen 的 IR 构建,改为 TypeScript 实现(“换一种方式重写”)。
  3 +// 仅读取现有只读接口,不改后端;三个视图(ER / 网络图 / 流程图)共用产物。
  4 +
  5 +import { api } from '../api'
  6 +import type {
  7 + Ontology, OntoObject, OntoAttribute, OntoRelationship,
  8 + OntoBehavior, OntoRule, OntoEvent, ProcessModel, ProcessNode, ProcessFlow,
  9 +} from './types'
  10 +
  11 +const A = <T = any>(x: any): T[] => (Array.isArray(x) ? x : [])
  12 +const S = (x: any): string => (x == null ? '' : String(x))
  13 +
  14 +/** 拉取相关本体层并合成 IR。 */
  15 +export async function loadOntology(): Promise<Ontology> {
  16 + const [m1, m2, me, mr, m4] = await Promise.all([
  17 + api.model('M1'), api.model('M2'), api.model('ME'), api.model('MetaRule'), api.model('M4'),
  18 + ])
  19 + return buildOntology(m1.content, m2.content, me.content, mr.content, m4.content)
  20 +}
  21 +
  22 +export function buildOntology(m1: any, m2: any, me: any, mr: any, m4: any): Ontology {
  23 + const objects = buildObjects(m1)
  24 + const events = buildEvents(me)
  25 + const rules = buildRules(mr)
  26 + const behaviors = buildBehaviors(m2)
  27 + linkRulesToBehaviors(behaviors, rules, m4)
  28 + const processModels = buildProcessModels(m4)
  29 + return {
  30 + domain: '销售订单交易(M 系列本体)',
  31 + version: '2.0',
  32 + objects, behaviors, rules, events,
  33 + navigations: [], // 本项目无显式导航树,网络图退化为按聚合平铺
  34 + processModels,
  35 + }
  36 +}
  37 +
  38 +// ============================================================
  39 +// M1 → objects(聚合根 + 组合子实体,含属性/主键/外键/引用/组合关系)
  40 +// ============================================================
  41 +function buildObjects(m1: any): OntoObject[] {
  42 + const out: OntoObject[] = []
  43 + for (const agg of A(m1?.aggregates)) {
  44 + const aggregateId = S(agg.aggregateId)
  45 + const root = agg.aggregateRoot || {}
  46 + const rootId = S(root.name) || aggregateId
  47 + const rootAttrs = mapAttributes(root.identifier, A(root.attributes))
  48 + const rootRels: OntoRelationship[] = []
  49 +
  50 + // 跨聚合引用(refAggregate 字段)→ 引用关系
  51 + for (const a of rootAttrs) {
  52 + if (a.ref) rootRels.push({
  53 + name: a.ref.root, label: a.name, target_object_id: a.ref.root,
  54 + kind: 'MANY_TO_ONE', refType: 'reference', fk_field: a.name,
  55 + })
  56 + }
  57 + // 组合关系(relations[relationType=composition])→ 子实体
  58 + const childNames = A(root.relations).filter((r: any) => S(r.relationType) === 'composition').map((r: any) => S(r.ref))
  59 + for (const cn of childNames) rootRels.push({
  60 + name: cn, label: '组合', target_object_id: cn, kind: 'ONE_TO_MANY', refType: 'composition',
  61 + })
  62 +
  63 + out.push({
  64 + id: rootId, name: rootId, alias: aggregateId, description: S(root.description) || S(agg.description),
  65 + is_aggregate_root: true, parent_id: null, aggregateId, attributes: rootAttrs, relationships: rootRels,
  66 + })
  67 +
  68 + // 子实体
  69 + for (const ent of A(agg.entities)) {
  70 + const entId = S(ent.name)
  71 + const entAttrs = mapAttributes(ent.localIdentifier, A(ent.attributes))
  72 + const entRels: OntoRelationship[] = []
  73 + for (const a of entAttrs) {
  74 + if (a.ref) entRels.push({
  75 + name: a.ref.root, label: a.name, target_object_id: a.ref.root,
  76 + kind: 'MANY_TO_ONE', refType: 'reference', fk_field: a.name,
  77 + })
  78 + }
  79 + out.push({
  80 + id: entId, name: entId, alias: `${aggregateId}.${entId}`, description: S(ent.description),
  81 + is_aggregate_root: false, parent_id: rootId, aggregateId, attributes: entAttrs, relationships: entRels,
  82 + })
  83 + }
  84 + }
  85 + return out
  86 +}
  87 +
  88 +/** identifier(主键,M1 中单列于外层)前置为合成主键属性 + 其余字段。 */
  89 +function mapAttributes(identifier: any, attrs: any[]): OntoAttribute[] {
  90 + const out: OntoAttribute[] = []
  91 + const idName = S(identifier)
  92 + if (idName) out.push({ name: idName, label: idName, type: 'string', is_primary_key: true, required: true })
  93 + for (const a of attrs) {
  94 + const ref = a.refAggregate ? { aggregate: S(a.refAggregate), root: S(a.refRoot), identifier: S(a.refIdentifier) } : undefined
  95 + const cons = a.constraints || undefined
  96 + const desc = S(a.description)
  97 + out.push({
  98 + name: S(a.name), label: S(a.name), type: S(a.type) || 'string',
  99 + is_foreign_key: !!ref, ref, constraints: cons,
  100 + required: cons?.required || undefined, unique: cons?.unique || undefined,
  101 + transient: /瞬态|不落库/.test(desc) || undefined,
  102 + description: desc || undefined,
  103 + })
  104 + }
  105 + return out
  106 +}
  107 +
  108 +// ============================================================
  109 +// M2 → behaviors(命令)
  110 +// ============================================================
  111 +function buildBehaviors(m2: any): OntoBehavior[] {
  112 + const out: OntoBehavior[] = []
  113 + for (const beh of A(m2?.behaviors)) {
  114 + const owner = S(beh.aggregateRoot)
  115 + for (const cmd of A(beh.commands)) {
  116 + out.push({
  117 + id: S(cmd.cmdId), name: S(cmd.cmdId), owner_object_id: owner,
  118 + type: 'COMMAND', commandType: S(cmd.commandType) || 'create',
  119 + description: S(cmd.desc),
  120 + applied_rules: [],
  121 + produced_events: A(cmd.emitEvents).map(S),
  122 + preconditions: A(cmd.validations).map(S),
  123 + effects: A(cmd.derivations).map((d: any) => `${S(d.targetField)} = ${S(d.expression)}`)
  124 + .concat(cmd.effect ? [`${S(cmd.effect.op)} ${S(cmd.effect.targetField)}`] : []),
  125 + })
  126 + }
  127 + }
  128 + return out
  129 +}
  130 +
  131 +// ============================================================
  132 +// MetaRule → rules;并按 bindScene→绑定命令,回填 behavior.applied_rules
  133 +// ============================================================
  134 +function buildRules(mr: any): OntoRule[] {
  135 + const out: OntoRule[] = []
  136 + for (const g of A(mr?.metaRuleModel?.ruleGroups)) {
  137 + for (const r of A(g.rules)) {
  138 + out.push({
  139 + id: S(r.ruleId), name: S(r.ruleName), type: S(r.effect),
  140 + expression: S(r.matchCondition), enforced: true, bindScene: S(g.bindScene),
  141 + description: S(r.message),
  142 + })
  143 + }
  144 + }
  145 + return out
  146 +}
  147 +
  148 +function linkRulesToBehaviors(behaviors: OntoBehavior[], rules: OntoRule[], m4: any) {
  149 + const byId = new Map(behaviors.map((b) => [b.id, b]))
  150 + const scenes = A(m4?.sceneModel?.sceneDefinitions)
  151 + for (const rule of rules) {
  152 + if (!rule.bindScene) continue
  153 + const scene = scenes.find((s: any) => S(s.sceneId) === rule.bindScene)
  154 + if (!scene) continue
  155 + for (const cmdId of collectBindCommands(A(scene.flowSteps))) {
  156 + const b = byId.get(cmdId)
  157 + if (b && !b.applied_rules.includes(rule.id)) b.applied_rules.push(rule.id)
  158 + }
  159 + }
  160 +}
  161 +
  162 +/** 递归收集 flowSteps 里所有 bindCommand 的命令 id(含 then/else/body/branches/cases/default 嵌套)。 */
  163 +function collectBindCommands(steps: any[]): string[] {
  164 + const acc: string[] = []
  165 + const walk = (arr: any[]) => {
  166 + for (const st of A(arr)) {
  167 + if (S(st.stepType) === 'bindCommand' && st.bindCommand) acc.push(S(st.bindCommand))
  168 + walk(st.then); walk(st.else); walk(st.body); walk(st.default)
  169 + for (const br of A(st.branches)) walk(br.steps)
  170 + for (const c of A(st.cases)) walk(c.steps)
  171 + }
  172 + }
  173 + walk(steps)
  174 + return acc
  175 +}
  176 +
  177 +// ============================================================
  178 +// ME → events
  179 +// ============================================================
  180 +function buildEvents(me: any): OntoEvent[] {
  181 + const out: OntoEvent[] = []
  182 + for (const def of A(me?.eventModel?.aggregateEventDefinitions)) {
  183 + const aggregate = S(def.aggregateId)
  184 + const events = def.events || {}
  185 + for (const name of Object.keys(events)) {
  186 + const e = events[name] || {}
  187 + out.push({
  188 + id: name, name, aggregate,
  189 + topic: S(e.topic), payload: A(e.payload).map(S), consumers: A(e.consumers).map(S),
  190 + })
  191 + }
  192 + }
  193 + return out
  194 +}
  195 +
  196 +// ============================================================
  197 +// M4 flowSteps → ProcessModel[](BPMN 语义骨架,供 bpmn-elk-layout 布局)
  198 +// ============================================================
  199 +function buildProcessModels(m4: any): ProcessModel[] {
  200 + const scenes = A(m4?.sceneModel?.sceneDefinitions)
  201 + return scenes.map((sc: any) => synthProcess(sc)).filter((p: ProcessModel) => p.nodes.length > 1)
  202 +}
  203 +
  204 +/** 单场景 flowSteps → 节点 + 序列流(递归处理控制流)。 */
  205 +function synthProcess(scene: any): ProcessModel {
  206 + const nodes: ProcessNode[] = []
  207 + const flows: ProcessFlow[] = []
  208 + let c = 0
  209 + const nid = (p: string) => `${p}_${++c}`
  210 + const push = (n: ProcessNode) => { nodes.push(n); return n.id }
  211 + const connect = (froms: string[], to: string, opts?: Partial<ProcessFlow>) =>
  212 + froms.forEach((f) => flows.push({ id: nid('Flow'), source: f, target: to, ...opts }))
  213 +
  214 + const start = push({ id: nid('Start'), type: 'startEvent', name: '开始' })
  215 +
  216 + // build:把一段 steps 接到 incoming 之后,firstEdge 修饰 incoming→首节点的连线;返回“尾部”节点集合
  217 + const build = (steps: any[], incoming: string[], firstEdge?: Partial<ProcessFlow>): string[] => {
  218 + let tails = incoming
  219 + let edge = firstEdge
  220 + for (const st of A(steps)) {
  221 + const r = node(st, tails, edge)
  222 + tails = r
  223 + edge = undefined // 仅第一段带修饰
  224 + }
  225 + return tails
  226 + }
  227 +
  228 + const node = (st: any, incoming: string[], firstEdge?: Partial<ProcessFlow>): string[] => {
  229 + const t = S(st.stepType)
  230 + const simple = (type: ProcessNode['type'], name: string, extra?: Partial<ProcessNode>): string[] => {
  231 + const id = push({ id: nid('Node'), type, name, ...extra }); connect(incoming, id, firstEdge); return [id]
  232 + }
  233 + switch (t) {
  234 + case 'start': return incoming
  235 + case 'return': case 'end': case 'errorEnd': {
  236 + const id = push({ id: nid('End'), type: 'endEvent', name: t === 'errorEnd' ? '异常结束' : '结束' })
  237 + connect(incoming, id, firstEdge); return []
  238 + }
  239 + case 'bindCommand':
  240 + return simple('task', S(st.bindCommand), { behaviorId: S(st.bindCommand), lane: S(st.bindAggregate) })
  241 + case 'readOnlyCheck': return simple('task', `只读校验 ${S(st.bindAggregate)}`)
  242 + case 'validate': return simple('task', '校验')
  243 + case 'derive': return simple('task', '派生')
  244 + case 'assign': {
  245 + const label = A(st.assignments).length
  246 + ? `赋值 ${A(st.assignments).map((x: any) => S(x.targetField)).join(', ')}`
  247 + : `赋值 ${S(st.targetField)}`
  248 + return simple('task', label)
  249 + }
  250 + case 'script': return simple('task', `脚本 → ${S(st.assignTo) || S(st.expression)}`)
  251 + case 'transform': return simple('task', '转换')
  252 + case 'callService': return simple('task', `调用服务 ${S(st.method)} ${S(st.endpoint)}`)
  253 + case 'userTask': return simple('userTask', `人工任务 ${S(st.role) ? '· ' + S(st.role) : ''}`)
  254 + case 'timer': return simple('intermediateCatchEvent', `定时 ${S(st.delayMs)}ms`)
  255 + case 'waitEvent': return simple('intermediateCatchEvent', `等待事件 ${S(st.waitEvent)}`)
  256 + case 'emitEvent': return simple('intermediateCatchEvent', `发事件 ${S(st.event)}`)
  257 + case 'condition': {
  258 + const g = push({ id: nid('Gw'), type: 'exclusiveGateway', name: '判断' })
  259 + connect(incoming, g, firstEdge)
  260 + const thenT = build(A(st.then), [g], { name: '是', condition: S(st.when) })
  261 + const elseT = build(A(st.else), [g], { name: '否' })
  262 + return [...thenT, ...elseT]
  263 + }
  264 + case 'switch': {
  265 + const g = push({ id: nid('Gw'), type: 'exclusiveGateway', name: `分派 ${S(st.switchOn)}` })
  266 + connect(incoming, g, firstEdge)
  267 + const tails: string[] = []
  268 + for (const cs of A(st.cases)) tails.push(...build(A(cs.steps), [g], { name: `= ${S(cs.equals)}` }))
  269 + tails.push(...build(A(st.default), [g], { name: 'default' }))
  270 + return tails.length ? tails : [g]
  271 + }
  272 + case 'while': {
  273 + const g = push({ id: nid('Gw'), type: 'exclusiveGateway', name: '循环判断' })
  274 + connect(incoming, g, firstEdge)
  275 + const bodyT = build(A(st.body), [g], { name: '循环', condition: S(st.when) })
  276 + connect(bodyT, g) // 回边
  277 + return [g] // 退出边由后续步骤接出
  278 + }
  279 + case 'parallel': {
  280 + const split = push({ id: nid('Gw'), type: 'parallelGateway', name: '并行分叉' })
  281 + connect(incoming, split, firstEdge)
  282 + const join = push({ id: nid('Gw'), type: 'parallelGateway', name: '并行汇合' })
  283 + for (const br of A(st.branches)) {
  284 + const bt = build(A(br.steps), [split])
  285 + connect(bt, join)
  286 + }
  287 + return [join]
  288 + }
  289 + case 'retry': {
  290 + const id = push({ id: nid('Node'), type: 'task', name: `重试 ×${S(st.maxAttempts)}` })
  291 + connect(incoming, id, firstEdge)
  292 + return build(A(st.body), [id])
  293 + }
  294 + case 'compensate': return simple('task', `补偿 ${S(st.compensateCommand)}`)
  295 + default: return simple('task', t || '步骤')
  296 + }
  297 + }
  298 +
  299 + const tails = build(A(scene.flowSteps), [start])
  300 + if (tails.length) { const end = push({ id: nid('End'), type: 'endEvent', name: '结束' }); connect(tails, end) }
  301 + return { id: S(scene.sceneId), name: S(scene.sceneName) || S(scene.sceneId), nodes, flows }
  302 +}
web/src/ontology/types.ts 0 → 100644
  1 +// 本体可视化中间表示(IR)。
  2 +// 这是把当前项目的 M0–M8 / ME / MetaRule 本体“投影”成一份统一、视图友好的对象模型;
  3 +// 由 build.ts 从后端 /api/meta/model/{code} 返回的解析后 YAML(JSON)在浏览器端合成,
  4 +// 供 ER 图 / 本体网络图 / 场景流程图三个视图共用(等价于 onto-app 里 Python 侧的 IR,改为 TS 实现)。
  5 +
  6 +export type RelKind = 'ONE_TO_ONE' | 'ONE_TO_MANY' | 'MANY_TO_ONE' | 'MANY_TO_MANY'
  7 +
  8 +/** 属性(字段)。identifier / localIdentifier 会被前置成 is_primary_key 的合成属性。 */
  9 +export interface OntoAttribute {
  10 + name: string
  11 + label?: string
  12 + type: string
  13 + required?: boolean
  14 + unique?: boolean
  15 + is_primary_key?: boolean
  16 + is_foreign_key?: boolean
  17 + /** 跨聚合引用目标(来自 M1 refAggregate/refRoot/refIdentifier)。 */
  18 + ref?: { aggregate: string; root: string; identifier: string }
  19 + constraints?: Record<string, any>
  20 + /** 瞬态字段:无 M3 列,不落库(据 description 启发式识别,仅作展示标注)。 */
  21 + transient?: boolean
  22 + description?: string
  23 +}
  24 +
  25 +/** 对象间关联:跨聚合引用(reference)或聚合内组合(composition)。 */
  26 +export interface OntoRelationship {
  27 + name: string
  28 + label?: string
  29 + target_object_id: string
  30 + kind: RelKind
  31 + refType: 'reference' | 'composition'
  32 + /** reference 时为持有外键的字段名(在源对象 attributes 内)。 */
  33 + fk_field?: string
  34 +}
  35 +
  36 +/** 一个业务对象:聚合根(parent_id 为空)或其组合子实体(parent_id 指向根)。 */
  37 +export interface OntoObject {
  38 + id: string
  39 + name: string
  40 + alias: string
  41 + description?: string
  42 + is_aggregate_root: boolean
  43 + parent_id?: string | null
  44 + aggregateId: string
  45 + attributes: OntoAttribute[]
  46 + relationships: OntoRelationship[]
  47 +}
  48 +
  49 +/** 行为(M2 命令)。 */
  50 +export interface OntoBehavior {
  51 + id: string
  52 + name: string
  53 + owner_object_id: string
  54 + type: 'COMMAND' | 'QUERY'
  55 + commandType?: string
  56 + description?: string
  57 + applied_rules: string[]
  58 + produced_events: string[]
  59 + preconditions: string[]
  60 + effects: string[]
  61 +}
  62 +
  63 +/** 规则(MetaRule ruleGroups[].rules[])。 */
  64 +export interface OntoRule {
  65 + id: string
  66 + name: string
  67 + type: string // effect: REJECT / ALERT / COMPENSATE
  68 + expression?: string // matchCondition
  69 + enforced?: boolean
  70 + bindScene?: string
  71 + description?: string
  72 +}
  73 +
  74 +/** 领域事件(ME aggregateEventDefinitions)。 */
  75 +export interface OntoEvent {
  76 + id: string
  77 + name: string
  78 + description?: string
  79 + topic?: string
  80 + aggregate?: string
  81 + payload?: string[]
  82 + consumers?: string[]
  83 +}
  84 +
  85 +/** 导航目录节点(本项目无显式导航树,可为空;网络图退化为按聚合平铺)。 */
  86 +export interface NavNode {
  87 + id: string
  88 + name: string
  89 + zh: string
  90 + link?: string | null
  91 + children: NavNode[]
  92 +}
  93 +
  94 +/** BPMN 语义骨架(由 M4 flowSteps 合成,不含坐标)。 */
  95 +export interface ProcessNode {
  96 + id: string
  97 + type: 'startEvent' | 'endEvent' | 'task' | 'userTask' | 'exclusiveGateway' | 'parallelGateway' | 'intermediateCatchEvent'
  98 + name: string
  99 + behaviorId?: string
  100 + lane?: string
  101 +}
  102 +export interface ProcessFlow {
  103 + id: string
  104 + source: string
  105 + target: string
  106 + name?: string
  107 + condition?: string
  108 +}
  109 +export interface ProcessModel {
  110 + id: string
  111 + name: string
  112 + nodes: ProcessNode[]
  113 + flows: ProcessFlow[]
  114 +}
  115 +
  116 +/** 顶层:一个本体 = 全部建模产物的视图投影。 */
  117 +export interface Ontology {
  118 + domain: string
  119 + version: string
  120 + objects: OntoObject[]
  121 + behaviors: OntoBehavior[]
  122 + rules: OntoRule[]
  123 + events: OntoEvent[]
  124 + navigations: NavNode[]
  125 + processModels: ProcessModel[]
  126 +}
web/src/viz/ERDiagram.tsx 0 → 100644
  1 +import { useMemo, useEffect } from 'react'
  2 +import {
  3 + ReactFlow,
  4 + Background,
  5 + Controls,
  6 + MiniMap,
  7 + Handle,
  8 + Position,
  9 + useNodesState,
  10 + useEdgesState,
  11 + MarkerType,
  12 + BaseEdge,
  13 + EdgeLabelRenderer,
  14 + getSmoothStepPath,
  15 + type Node,
  16 + type Edge,
  17 + type NodeProps,
  18 + type EdgeProps,
  19 +} from '@xyflow/react'
  20 +import dagre from '@dagrejs/dagre'
  21 +import '@xyflow/react/dist/style.css'
  22 +import type { Ontology, OntoObject, OntoAttribute, RelKind } from '../ontology/types'
  23 +
  24 +// 交互式 ER 图:表 = React Flow 自定义节点(字段级 handle),外键 = 节点间连线(蓝色基数样式);
  25 +// 组合(composition)= 父对象 → 子实体的紫色含义连线(含菱形标记,读作 containment)。
  26 +// 拖拽 / 缩放 / 平移 / 小地图均由 React Flow 内置。首屏用 dagre 做有向布局(按依赖左右排开)。
  27 +
  28 +const NODE_W = 232
  29 +const HDL: React.CSSProperties = { width: 9, height: 9, background: '#5b7fe0', border: '2px solid #fff' }
  30 +
  31 +// ---- 节点/边 data 负载类型 ----------------------------------------------------
  32 +interface TableAttr {
  33 + name: string
  34 + type: string
  35 + pk: boolean
  36 + fk: boolean
  37 +}
  38 +interface TableNodeData {
  39 + name: string
  40 + alias: string
  41 + attributes: TableAttr[]
  42 + [key: string]: unknown
  43 +}
  44 +interface CardinalityEdgeData {
  45 + srcCard: string
  46 + tgtCard: string
  47 + name: string
  48 + [key: string]: unknown
  49 +}
  50 +interface CompositionEdgeData {
  51 + name: string
  52 + [key: string]: unknown
  53 +}
  54 +
  55 +type TableFlowNode = Node<TableNodeData, 'table'>
  56 +type CardinalityFlowEdge = Edge<CardinalityEdgeData, 'cardinality'>
  57 +type CompositionFlowEdge = Edge<CompositionEdgeData, 'composition'>
  58 +
  59 +// 自定义节点:一张表卡片,每行字段左右各一个 handle(外键行出右侧 source,主键行出左侧 target)
  60 +function TableNode({ data }: NodeProps<TableFlowNode>) {
  61 + return (
  62 + <div style={{ width: NODE_W, borderRadius: 8, background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,.10)' }}>
  63 + <div style={{ background: '#5b7fe0', color: '#fff', fontWeight: 700, fontSize: 13, textAlign: 'center', padding: '8px', borderRadius: '8px 8px 0 0' }}>
  64 + {data.name} <span style={{ fontWeight: 500, opacity: 0.85 }}>({data.alias})</span>
  65 + </div>
  66 + <div style={{ border: '1px solid #e6ecf8', borderTop: 0, borderRadius: '0 0 8px 8px' }}>
  67 + {data.attributes.map((a) => (
  68 + <div key={a.name} style={{ position: 'relative', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 12px', fontSize: 11.5, borderBottom: '1px solid #f4f6fb' }}>
  69 + <Handle type="target" position={Position.Left} id={`${a.name}__target`} style={{ ...HDL, opacity: a.pk ? 1 : 0 }} isConnectable={false} />
  70 + <span className="om-mono" style={{ color: a.pk ? '#d99a1a' : a.fk ? '#c2528a' : '#3f3f46', whiteSpace: 'nowrap' }}>{a.pk ? '🔑 ' : a.fk ? '🔗 ' : ''}{a.name}</span>
  71 + <span style={{ color: '#b4b4ba', marginLeft: 8 }}>{a.type}</span>
  72 + <Handle type="source" position={Position.Right} id={`${a.name}__source`} style={{ ...HDL, opacity: a.fk ? 1 : 0 }} isConnectable={false} />
  73 + </div>
  74 + ))}
  75 + </div>
  76 + </div>
  77 + )
  78 +}
  79 +
  80 +const nodeTypes = { table: TableNode }
  81 +
  82 +// 关系型基数:ONE_TO_MANY 的 FK 一般在“多”侧,故按 源→目标 方向给两端基数
  83 +const CARD: Record<RelKind, [string, string]> = {
  84 + ONE_TO_ONE: ['1', '1'],
  85 + ONE_TO_MANY: ['1', 'N'],
  86 + MANY_TO_ONE: ['N', '1'],
  87 + MANY_TO_MANY: ['N', 'N'], // 关系库里无直接 N-N(经中间表实现),此处仅兜底
  88 +}
  89 +
  90 +// 自定义边:平滑折线 + 两端基数徽标(1 / N)+ 中间关系名(蓝色,reference/外键)
  91 +function CardinalityEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, style, data }: EdgeProps<CardinalityFlowEdge>) {
  92 + const [path] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 10 })
  93 + const dx = targetX - sourceX, dy = targetY - sourceY
  94 + const len = Math.hypot(dx, dy) || 1
  95 + const ux = dx / len, uy = dy / len
  96 + const badge = (x: number, y: number, text: string, extra?: React.CSSProperties) => (
  97 + <div style={{ position: 'absolute', transform: `translate(-50%,-50%) translate(${x}px,${y}px)`, pointerEvents: 'none', fontSize: 10.5, fontWeight: 700, lineHeight: 1, padding: '2px 5px', borderRadius: 6, background: '#fff', border: '1px solid #cdd6ee', color: '#4a6fd6', ...extra }}>{text}</div>
  98 + )
  99 + return (
  100 + <>
  101 + <BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} />
  102 + <EdgeLabelRenderer>
  103 + {badge(sourceX + ux * 20, sourceY + uy * 20, data?.srcCard ?? '')}
  104 + {badge(targetX - ux * 20, targetY - uy * 20, data?.tgtCard ?? '')}
  105 + {data?.name && badge((sourceX + targetX) / 2, (sourceY + targetY) / 2, data.name, { color: '#8a93ad', fontWeight: 500, borderColor: '#e6e9f2', background: 'rgba(255,255,255,.9)' })}
  106 + </EdgeLabelRenderer>
  107 + </>
  108 + )
  109 +}
  110 +
  111 +// 组合边:父 → 子实体(聚合内 composition)。紫色虚线 + 源端菱形(containment)+ “组合 (1:N)”标签。
  112 +const COMP_COLOR = '#7c4dcf'
  113 +function CompositionEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, style, data }: EdgeProps<CompositionFlowEdge>) {
  114 + const [path] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 10 })
  115 + const dx = targetX - sourceX, dy = targetY - sourceY
  116 + const len = Math.hypot(dx, dy) || 1
  117 + const ux = dx / len, uy = dy / len
  118 + return (
  119 + <>
  120 + <BaseEdge id={id} path={path} markerEnd={markerEnd} style={style} />
  121 + <EdgeLabelRenderer>
  122 + {/* 源端(父/整体)菱形标记:旋转 45° 的实心方块,读作 UML 组合 */}
  123 + <div style={{ position: 'absolute', transform: `translate(-50%,-50%) translate(${sourceX + ux * 12}px,${sourceY + uy * 12}px) rotate(45deg)`, width: 11, height: 11, background: COMP_COLOR, border: '2px solid #fff', boxShadow: '0 0 0 1px rgba(124,77,207,.4)', pointerEvents: 'none' }} />
  124 + {/* 关系标签 */}
  125 + <div style={{ position: 'absolute', transform: `translate(-50%,-50%) translate(${(sourceX + targetX) / 2}px,${(sourceY + targetY) / 2}px)`, pointerEvents: 'none', fontSize: 10.5, fontWeight: 700, lineHeight: 1, padding: '2px 6px', borderRadius: 6, background: 'rgba(255,255,255,.92)', border: `1px solid ${COMP_COLOR}`, color: COMP_COLOR, whiteSpace: 'nowrap' }}>
  126 + {data?.name ? `◆ ${data.name}` : '◆ 组合 (1:N)'}
  127 + </div>
  128 + </EdgeLabelRenderer>
  129 + </>
  130 + )
  131 +}
  132 +
  133 +const edgeTypes = { cardinality: CardinalityEdge, composition: CompositionEdge }
  134 +
  135 +// 主键 / 首字段的锚定 handle(组合边用节点级锚点,无需专门字段 handle)
  136 +function anchorAttr(o: OntoObject): OntoAttribute | undefined {
  137 + return (o.attributes || []).find((a) => a.is_primary_key) || (o.attributes || [])[0]
  138 +}
  139 +
  140 +// 由本体对象模型构建 React Flow 的 nodes/edges,并用 dagre 计算初始坐标
  141 +function buildGraph(ontology: Ontology): { nodes: TableFlowNode[]; edges: (CardinalityFlowEdge | CompositionFlowEdge)[] } {
  142 + const objects: OntoObject[] = (ontology && ontology.objects) || []
  143 + const byId = (id: string) => objects.find((o) => o.id === id)
  144 +
  145 + const g = new dagre.graphlib.Graph()
  146 + g.setGraph({ rankdir: 'LR', nodesep: 36, ranksep: 96, marginx: 24, marginy: 24 })
  147 + g.setDefaultEdgeLabel(() => ({}))
  148 + objects.forEach((o) => g.setNode(o.id, { width: NODE_W, height: 40 + (o.attributes || []).length * 25 }))
  149 +
  150 + const edges: (CardinalityFlowEdge | CompositionFlowEdge)[] = []
  151 + objects.forEach((o) => (o.relationships || []).forEach((r, i) => {
  152 + const tgt = byId(r.target_object_id)
  153 + if (!tgt) return
  154 +
  155 + if (r.refType === 'composition') {
  156 + // 组合:父对象 → 子实体(无 fk_field,node 级连线,锚在双方 PK/首字段 handle)
  157 + const src = anchorAttr(o)
  158 + const dst = anchorAttr(tgt)
  159 + g.setEdge(o.id, tgt.id)
  160 + edges.push({
  161 + id: `c-${o.id}-${tgt.id}-${i}`,
  162 + source: o.id, sourceHandle: src ? `${src.name}__source` : undefined,
  163 + target: tgt.id, targetHandle: dst ? `${dst.name}__target` : undefined,
  164 + type: 'composition',
  165 + data: { name: r.label || r.name || '组合 (1:N)' },
  166 + style: { stroke: COMP_COLOR, strokeWidth: 1.8, strokeDasharray: '6 4' },
  167 + markerEnd: { type: MarkerType.ArrowClosed, color: COMP_COLOR, width: 15, height: 15 },
  168 + })
  169 + return
  170 + }
  171 +
  172 + // reference:外键连线(需源侧持有 fk_field 且目标有主键)
  173 + if (!r.fk_field) return
  174 + const hasFk = (o.attributes || []).some((a) => a.name === r.fk_field)
  175 + const pk = (tgt.attributes || []).find((a) => a.is_primary_key)
  176 + if (!hasFk || !pk) return
  177 + g.setEdge(o.id, tgt.id)
  178 + const [srcCard, tgtCard] = CARD[r.kind] || ['N', '1']
  179 + edges.push({
  180 + id: `e-${o.id}-${r.fk_field}-${tgt.id}-${i}`,
  181 + source: o.id, sourceHandle: `${r.fk_field}__source`,
  182 + target: tgt.id, targetHandle: `${pk.name}__target`,
  183 + type: 'cardinality',
  184 + data: { srcCard, tgtCard, name: r.name || '' },
  185 + style: { stroke: '#8aa2e6', strokeWidth: 1.6 },
  186 + markerEnd: { type: MarkerType.ArrowClosed, color: '#8aa2e6', width: 15, height: 15 },
  187 + })
  188 + }))
  189 + dagre.layout(g)
  190 +
  191 + const nodes: TableFlowNode[] = objects.map((o) => {
  192 + const n = g.node(o.id)
  193 + const fkSet = new Set((o.relationships || []).map((r) => r.fk_field).filter(Boolean) as string[])
  194 + return {
  195 + id: o.id,
  196 + type: 'table',
  197 + position: { x: (n ? n.x : 0) - NODE_W / 2, y: (n ? n.y - n.height / 2 : 0) },
  198 + data: {
  199 + name: o.name,
  200 + alias: o.alias,
  201 + attributes: (o.attributes || []).map((a) => ({
  202 + name: a.name,
  203 + type: a.type,
  204 + pk: !!a.is_primary_key,
  205 + fk: !!a.is_foreign_key || fkSet.has(a.name),
  206 + })),
  207 + },
  208 + }
  209 + })
  210 + return { nodes, edges }
  211 +}
  212 +
  213 +export default function ERDiagram({ ontology }: { ontology: Ontology }) {
  214 + const graph = useMemo(() => buildGraph(ontology), [ontology])
  215 + const [nodes, setNodes, onNodesChange] = useNodesState<TableFlowNode>(graph.nodes)
  216 + const [edges, setEdges, onEdgesChange] = useEdgesState<CardinalityFlowEdge | CompositionFlowEdge>(graph.edges)
  217 + // 本体变化时重建(重新布局并复位)
  218 + useEffect(() => { setNodes(graph.nodes); setEdges(graph.edges) }, [graph, setNodes, setEdges])
  219 +
  220 + return (
  221 + <div style={{ position: 'absolute', inset: 0 }}>
  222 + <ReactFlow
  223 + nodes={nodes}
  224 + edges={edges}
  225 + onNodesChange={onNodesChange}
  226 + onEdgesChange={onEdgesChange}
  227 + nodeTypes={nodeTypes}
  228 + edgeTypes={edgeTypes}
  229 + fitView
  230 + fitViewOptions={{ padding: 0.2 }}
  231 + minZoom={0.2}
  232 + maxZoom={2.5}
  233 + nodesConnectable={false}
  234 + elementsSelectable
  235 + proOptions={{ hideAttribution: false }}
  236 + >
  237 + <Background gap={18} size={1} color="#e9ecf3" />
  238 + <MiniMap pannable zoomable nodeColor="#c7d3f0" nodeStrokeColor="#5b7fe0" maskColor="rgba(240,242,247,.6)" />
  239 + <Controls showInteractive={false} />
  240 + </ReactFlow>
  241 + </div>
  242 + )
  243 +}
web/src/viz/NetworkGraph.tsx 0 → 100644
  1 +import React, { useEffect, useMemo, useRef, useState } from 'react';
  2 +import cytoscape from 'cytoscape';
  3 +// @ts-ignore cytoscape-fcose ships no type declarations
  4 +import fcose from 'cytoscape-fcose';
  5 +import type { Ontology, NavNode } from '../ontology/types';
  6 +
  7 +// 用成熟的 Cytoscape.js + fCoSE 力导向布局渲染本体网络图。
  8 +// fCoSE 是官方推荐的“最小化交叉、聚类清晰”的力导向布局。
  9 +// 节点模型:
  10 +// - 实体 = compound(复合)框;框内只保留自身「标识(主键)」子节点(显示自己模块内的名字)。
  11 +// - 普通属性 = 独立节点(卫星),用连线挂到实体框外,不再嵌进框内。
  12 +// - 跨聚合引用 = 独立「引用节点」(专色 C 紫),落在引用方与被引用实体之间,
  13 +// 显示的是「引用地的表述」(引用方 fk 字段的 zh,如 客户编号2),常驻可见。
  14 +// - 标识被隐藏后,实体框变空,模块名回到框内居中(不再浮在空框上方)。
  15 +// 左侧目录内置为组件自身的左栏(不再 portal 到外部侧边栏),严格按本体的 navigations(M3 导航树)
  16 +// 组织;无导航时退化为按聚合平铺。
  17 +cytoscape.use(fcose);
  18 +
  19 +const COL: Record<string, string[]> = {
  20 + entity: ['#eef2fd', '#5b7fe0', '#33529e'],
  21 + behavior: ['#e9f7ee', '#4fae6e', '#2f7d4c'],
  22 + rule: ['#fdf3e8', '#e0944a', '#9a5e1d'],
  23 + event: ['#fbe3ef', '#e8579b', '#b83b7e'],
  24 + nav: ['#f2f3f5', '#8e8e96', '#3f3f46'],
  25 + identifier: ['#fdf3d6', '#caa011', '#7a5c00'], // 标识 A(金)
  26 + attribute: ['#eef0f3', '#9298a3', '#4b5058'], // 属性 B(灰)——与标识同形不同色
  27 + bridge: ['#efe7fb', '#7c4dcf', '#4d2a94'], // 引用节点 C(紫)——跨实体引用的落点
  28 +};
  29 +const TYPE_CN: Record<string, string> = { entity: '实体', behavior: '行为', rule: '规则', event: '事件', nav: '目录', identifier: '标识', attribute: '属性', bridge: '引用' };
  30 +
  31 +// 属性 / 标识作为独立节点:合成 id = attr::<对象id>::<字段名>(字段名仅在对象内唯一,故带上对象 id 前缀)。
  32 +// 标识 = is_primary_key 的属性(M1 导入时被放到 attributes 首位),其余为普通属性。
  33 +const attrNodeId = (oid: string, name: string) => `attr::${oid}::${name}`;
  34 +const attrNodeType = (a: any) => (a.is_primary_key ? 'identifier' : 'attribute');
  35 +// 跨聚合引用列(relationship.fk_field):不单独成普通属性节点,而是生成一个「引用节点」,
  36 +// 显示引用方对该外键列的表述(如订单里的 客户编号2),落在引用方与被引用实体之间。
  37 +const fkFields = (o: any) => new Set((o.relationships || []).filter((r: any) => r.fk_field).map((r: any) => r.fk_field));
  38 +const attrNodeIds = (o: any) => { const fk = fkFields(o); return (o.attributes || []).filter((a: any) => !fk.has(a.name)).map((a: any) => attrNodeId(o.id, a.name)); };
  39 +// 引用节点 id:按(引用方对象, 关系)唯一。每条跨聚合引用一个,互不共享。
  40 +const refNodeId = (ownerId: string, rel: any) => `ref::${ownerId}::${rel.fk_field || rel.name}`;
  41 +// 引用方对该外键列的表述(引用地的 zh);取不到时退回关系名。
  42 +const refLabel = (o: any, rel: any) => {
  43 + const a = (o.attributes || []).find((x: any) => x.name === rel.fk_field);
  44 + return a?.label || rel.label || rel.name;
  45 +};
  46 +
  47 +// M3 导航节点是“空节点”:自身无实体/行为内容,只作为界面目录入口。
  48 +function findNav(nodes: NavNode[] | undefined, id: string): NavNode | null {
  49 + for (const n of nodes || []) { if (`nav__${n.id}` === id) return n; const f = findNav(n.children, id); if (f) return f; }
  50 + return null;
  51 +}
  52 +
  53 +function nameOf(ontology: Ontology, id: string): string {
  54 + if (typeof id === 'string' && id.startsWith('nav__')) { const n = findNav(ontology.navigations, id); if (n) return n.zh || n.name || n.id; }
  55 + return ontology.objects.find((o) => o.id === id)?.name
  56 + || ontology.behaviors.find((b) => b.id === id)?.name
  57 + || ontology.rules.find((r) => r.id === id)?.name
  58 + || ontology.events.find((e) => e.id === id)?.name || id;
  59 +}
  60 +function describe(ontology: Ontology, id: string): any {
  61 + // 引用节点:显示引用地的表述 + 引用方 / 指向
  62 + if (typeof id === 'string' && id.startsWith('ref::')) {
  63 + const [, ownerId, fk] = id.split('::');
  64 + const owner = ontology.objects.find((x) => x.id === ownerId);
  65 + const rel = owner && (owner.relationships || []).find((r) => (r.fk_field || r.name) === fk);
  66 + const target = rel && ontology.objects.find((x) => x.id === rel.target_object_id);
  67 + return {
  68 + type: 'bridge',
  69 + title: owner && rel ? refLabel(owner, rel) : fk,
  70 + lines: [
  71 + '跨实体引用(连接点)',
  72 + owner ? `引用方: ${owner.name}` : null,
  73 + target ? `指向: ${target.name}` : null,
  74 + rel?.fk_field ? `字段 ${rel.fk_field}` : null,
  75 + ].filter(Boolean),
  76 + };
  77 + }
  78 + if (typeof id === 'string' && id.startsWith('attr::')) {
  79 + const rest = id.slice('attr::'.length);
  80 + const sep = rest.indexOf('::');
  81 + const oid = rest.slice(0, sep), fname = rest.slice(sep + 2);
  82 + const o = ontology.objects.find((x) => x.id === oid);
  83 + const a: any = o && (o.attributes || []).find((x) => x.name === fname);
  84 + if (a) {
  85 + const referenced = a.is_primary_key && ontology.objects.some((x) => (x.relationships || []).some((r) => r.fk_field && r.target_object_id === oid));
  86 + return {
  87 + type: attrNodeType(a),
  88 + title: a.label || a.name,
  89 + lines: [
  90 + `字段 ${a.name} · ${a.type}`,
  91 + a.is_primary_key ? '主键(标识)' : null,
  92 + referenced ? '被其他实体引用' : null,
  93 + [a.required ? '必填' : null, a.unique ? '唯一' : null].filter(Boolean).join(' · ') || null,
  94 + a.enum_values?.length ? `枚举: ${a.enum_values.join('、')}` : null,
  95 + o ? `属于: ${o.name}` : null,
  96 + a.description,
  97 + ].filter(Boolean),
  98 + };
  99 + }
  100 + }
  101 + if (typeof id === 'string' && id.startsWith('nav__')) {
  102 + const n = findNav(ontology.navigations, id);
  103 + if (n) { const kids = (n.children || []).map((c) => c.zh || c.name).filter(Boolean);
  104 + return { type: 'nav', title: n.zh || n.name || n.id, lines: [
  105 + n.link ? `入口 → ${nameOf(ontology, n.link)}` : '分组节点(空节点)',
  106 + kids.length ? `子项: ${kids.join('、')}` : null,
  107 + ].filter(Boolean) }; }
  108 + }
  109 + const o = ontology.objects.find((x) => x.id === id);
  110 + if (o) {
  111 + // 关系已由图上的连线表达,卡片内不再重复列出
  112 + return { type: 'entity', title: `${o.name} (${o.alias})`, lines: [o.description].filter(Boolean) };
  113 + }
  114 + const b = ontology.behaviors.find((x) => x.id === id);
  115 + if (b) return { type: 'behavior', title: b.name, lines: [
  116 + `作用于: ${nameOf(ontology, b.owner_object_id)}`,
  117 + b.applied_rules?.length ? `规则: ${b.applied_rules.map((r) => nameOf(ontology, r)).join(', ')}` : null,
  118 + b.produced_events?.length ? `事件: ${b.produced_events.map((e) => nameOf(ontology, e)).join(', ')}` : null,
  119 + ].filter(Boolean) };
  120 + const r = ontology.rules.find((x) => x.id === id);
  121 + if (r) return { type: 'rule', title: r.name, lines: [`${r.type} · ${r.enforced ? '可强制' : '仅说明'}`, r.expression].filter(Boolean) };
  122 + const e = ontology.events.find((x) => x.id === id);
  123 + if (e) { const by = ontology.behaviors.filter((x) => (x.produced_events || []).includes(id)).map((x) => x.name);
  124 + return { type: 'event', title: e.name, lines: [by.length ? `由 ${by.join(', ')} 产出` : null, e.description].filter(Boolean) }; }
  125 + return null;
  126 +}
  127 +
  128 +function buildElements(ontology: Ontology): any[] {
  129 + const els: any[] = [];
  130 + const clen = (s: string) => [...s].length;
  131 + ontology.objects.forEach((o) => {
  132 + const bw = Math.max(56, Math.min(120, clen(o.name) * 14 + 22));
  133 + els.push({ data: { id: o.id, label: o.name, type: 'entity', bw, bh: 34, bfont: 12, btw: bw - 14, bmy: 0 } });
  134 + const fk = fkFields(o);
  135 + (o.attributes || []).forEach((a) => {
  136 + if (fk.has(a.name)) return; // 外键引用列不成普通属性节点(由引用节点表达)
  137 + const nid = attrNodeId(o.id, a.name);
  138 + const lb = a.label || a.name;
  139 + const w = Math.max(44, Math.min(116, clen(lb) * 11 + 16));
  140 + const base = { id: nid, label: lb, type: attrNodeType(a), bw: w, bh: 24, bfont: 9, btw: w - 12, bmy: 0 };
  141 + // 标识(主键)→ 嵌进实体框(compound 子节点);普通属性 → 独立卫星节点(连线在下方补)
  142 + els.push({ data: a.is_primary_key ? { ...base, parent: o.id } : base });
  143 + });
  144 + });
  145 + ontology.behaviors.forEach((b) => els.push({ data: { id: b.id, label: b.name, type: 'behavior', bw: 58, bh: 58, bfont: 9.5, btw: 46, bmy: 0 } }));
  146 + ontology.rules.forEach((r) => els.push({ data: { id: r.id, label: r.name, type: 'rule', bw: 68, bh: 68, bfont: 8.5, btw: 42, bmy: 0 } }));
  147 + ontology.events.forEach((e) => els.push({ data: { id: e.id, label: e.name, type: 'event', bw: 56, bh: 56, bfont: 8.5, btw: 34, bmy: 8 } }));
  148 + // M3 导航:每个目录节点作为“空节点”入图,按层级相连,叶子连到所引用的聚合根。
  149 + // 层级连线标注级别:顶层→子=一级,再往下=二级……(取子节点深度)
  150 + const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
  151 + const levelLabel = (d: number) => `${CN_NUM[d - 1] || d}级`;
  152 + const navMeta: any[] = [];
  153 + const walkNav = (n: NavNode, parentGid: string | null, depth: number) => {
  154 + const gid = `nav__${n.id}`;
  155 + const label = n.zh || n.name || n.id;
  156 + const bw = Math.max(56, Math.min(150, clen(label) * 14 + 22));
  157 + els.push({ data: { id: gid, label, type: 'nav', bw, bh: 32, bfont: 11, btw: bw - 14, bmy: 0 } });
  158 + navMeta.push({ gid, parentGid, link: n.link || null, depth });
  159 + (n.children || []).forEach((c) => walkNav(c, gid, depth + 1));
  160 + };
  161 + (ontology.navigations || []).forEach((n) => walkNav(n, null, 0));
  162 + const ids = new Set(els.map((e) => e.data.id));
  163 + const edge = (s: string, t: string, label: string, kind: string, i: any) => els.push({ data: { id: `${s}_${kind}_${t}_${i}`, source: s, target: t, label, hlcolor: COL[kind][1] } });
  164 + ontology.behaviors.forEach((b, i) => {
  165 + if (ids.has(b.owner_object_id)) edge(b.id, b.owner_object_id, 'ownerEntity', 'behavior', i);
  166 + (b.applied_rules || []).forEach((r, j) => ids.has(r) && edge(b.id, r, 'appliedRules', 'rule', `${i}${j}`));
  167 + (b.produced_events || []).forEach((e, j) => ids.has(e) && edge(b.id, e, 'produces', 'event', `${i}${j}`));
  168 + });
  169 + // 普通属性(卫星)→ 实体框:连线(标识在框内由 compound 包含表达,不连线)
  170 + ontology.objects.forEach((o, i) => (o.attributes || []).forEach((a, j) => {
  171 + if (a.is_primary_key || fkFields(o).has(a.name)) return;
  172 + const nid = attrNodeId(o.id, a.name);
  173 + if (ids.has(nid)) edge(o.id, nid, '', 'attribute', `a${i}_${j}`);
  174 + }));
  175 + ontology.objects.forEach((o, i) => (o.relationships || []).forEach((rel, j) => {
  176 + if (!ids.has(rel.target_object_id)) return;
  177 + // 跨聚合引用:引用方 →〔引用节点(引用地表述,如 客户编号2)〕→ 被引用实体
  178 + if (rel.fk_field) {
  179 + const rid = refNodeId(o.id, rel);
  180 + const lb = refLabel(o, rel);
  181 + const w = Math.max(44, Math.min(120, clen(lb) * 11 + 16));
  182 + els.push({ data: { id: rid, label: lb, type: 'attribute', bridge: true, bw: w, bh: 24, bfont: 9, btw: w - 12, bmy: 0 } });
  183 + edge(o.id, rid, '引用', 'bridge', `${i}${j}`);
  184 + edge(rid, rel.target_object_id, '', 'bridge', `${i}${j}b`);
  185 + return;
  186 + }
  187 + edge(o.id, rel.target_object_id, rel.label || rel.name, 'entity', `${i}${j}`);
  188 + }));
  189 + navMeta.forEach((m, i) => {
  190 + if (m.parentGid) edge(m.parentGid, m.gid, levelLabel(m.depth), 'nav', `nv${i}`);
  191 + if (m.link && ids.has(m.link)) edge(m.gid, m.link, '入口', 'nav', `nl${i}`);
  192 + });
  193 + return els;
  194 +}
  195 +
  196 +// 聚合层级:聚合根 → 子实体 / 跨聚合引用 / 行为(→ 规则、事件)。
  197 +function buildTree(ontology: Ontology) {
  198 + const roots = ontology.objects.filter((o) => !o.parent_id);
  199 + const usedRules = new Set<string>(); const usedEvents = new Set<string>();
  200 + const tree = roots.map((root) => {
  201 + const subs = ontology.objects.filter((o) => o.parent_id === root.id);
  202 + const subIds = new Set(subs.map((s) => s.id));
  203 + const refs: any[] = [];
  204 + [root, ...subs].forEach((o) => (o.relationships || []).forEach((rel) => {
  205 + if (subIds.has(rel.target_object_id) || rel.target_object_id === root.id) return; // 组合已列为子实体
  206 + const t = ontology.objects.find((x) => x.id === rel.target_object_id);
  207 + if (t) refs.push({ key: `${o.id}_${rel.name}`, target: t.id, label: `${rel.fk_field || rel.name} → ${t.name}`, refNode: rel.fk_field ? refNodeId(o.id, rel) : null });
  208 + }));
  209 + const behaviors = ontology.behaviors
  210 + .filter((b) => b.owner_object_id === root.id || subIds.has(b.owner_object_id))
  211 + .map((b) => {
  212 + const rules = (b.applied_rules || []).map((id) => ontology.rules.find((r) => r.id === id)).filter(Boolean) as any[];
  213 + const events = (b.produced_events || []).map((id) => ontology.events.find((e) => e.id === id)).filter(Boolean) as any[];
  214 + rules.forEach((r) => usedRules.add(r.id)); events.forEach((e) => usedEvents.add(e.id));
  215 + return { b, rules, events };
  216 + });
  217 + return { root, subs, refs, behaviors };
  218 + });
  219 + // 挂不上任何聚合的节点兜底展示,保证目录覆盖图上每个节点
  220 + const knownObj = new Set(ontology.objects.map((o) => o.id));
  221 + const others = [
  222 + ...ontology.behaviors.filter((b) => !knownObj.has(b.owner_object_id)).map((b) => ({ id: b.id, label: b.name, type: 'behavior' })),
  223 + ...ontology.rules.filter((r) => !usedRules.has(r.id)).map((r) => ({ id: r.id, label: r.name, type: 'rule' })),
  224 + ...ontology.events.filter((e) => !usedEvents.has(e.id)).map((e) => ({ id: e.id, label: e.name, type: 'event' })),
  225 + ];
  226 + return { tree, others };
  227 +}
  228 +
  229 +// 聚合簇覆盖的全部节点 id(根 + 子实体 + 跨聚合引用节点/目标 + 行为 + 规则 + 事件)。
  230 +// 引用目标(如 订单明细→产品)纳入本簇:选中本模块时,即使产品属于别的模块也一并显示。
  231 +function clusterIds(t: any): any[] {
  232 + const ids = [t.root.id, ...attrNodeIds(t.root),
  233 + ...t.subs.flatMap((s: any) => [s.id, ...attrNodeIds(s)]),
  234 + ...t.refs.map((r: any) => r.target), ...t.refs.map((r: any) => r.refNode).filter(Boolean)];
  235 + t.behaviors.forEach(({ b, rules, events }: any) => ids.push(b.id, ...rules.map((r: any) => r.id), ...events.map((e: any) => e.id)));
  236 + return ids;
  237 +}
  238 +
  239 +// 一行:缩进由外层 nest() 容器提供,这里只管内容与选中态。
  240 +function Row({ type = 'entity', label, alias, note, bold, selected, chev, onChev, onPick }: any) {
  241 + const c = COL[type] || COL.entity;
  242 + return (
  243 + <div onClick={onPick} title={label}
  244 + style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '4px 8px', borderRadius: 7, cursor: 'pointer', background: selected ? c[0] : 'transparent', fontSize: 12, color: '#3f3f46', lineHeight: 1.45, userSelect: 'none' }}>
  245 + <span onClick={onChev ? (e: any) => { e.stopPropagation(); onChev(); } : undefined}
  246 + style={{ width: 12, textAlign: 'center', color: '#b4b4ba', fontSize: 9, flex: 'none', cursor: onChev ? 'pointer' : 'default' }}>
  247 + {chev === 'open' ? '▾' : chev === 'closed' ? '▸' : ''}
  248 + </span>
  249 + <span style={{ width: 8, height: 8, borderRadius: type === 'entity' || type === 'nav' ? 2 : 8, background: c[1], flex: 'none' }} />
  250 + <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: bold ? 600 : 400 }}>
  251 + {label}{alias && <span style={{ color: '#b8b8be', fontWeight: 400, marginLeft: 6, fontSize: 11 }}>{alias}</span>}
  252 + </span>
  253 + {note && <span style={{ fontSize: 10, color: '#b4b4ba', flex: 'none' }}>{note}</span>}
  254 + </div>
  255 + );
  256 +}
  257 +
  258 +const GroupLabel = ({ text, count }: any) => (
  259 + <div style={{ padding: '5px 6px 2px 8px', fontSize: 10, fontWeight: 600, letterSpacing: '.03em', color: '#aeaeb4', userSelect: 'none' }}>{text}{count != null ? ` · ${count}` : ''}</div>
  260 +);
  261 +
  262 +// 按类型 + 孤儿 + 目录折叠 过滤:用 display:none(可逆),连线随端点自动隐藏。
  263 +// 孤儿 = 没有任何连到“类型可见”邻居的边(含 0 边的独立节点)。
  264 +// dirHidden = 目录里被折叠的聚合/导航分组所覆盖的全部节点 id。
  265 +function applyFilter(cy: any, hiddenTypes: Record<string, boolean>, hideOrphans: boolean, dirHidden: Set<any> = new Set()) {
  266 + if (!cy) return;
  267 + cy.batch(() => {
  268 + cy.nodes().forEach((n: any) => {
  269 + const t = n.data('type');
  270 + let disp = 'element';
  271 + // 引用节点常驻显示——即使隐藏了“标识/属性”类型,也保留两实体之间的连接点
  272 + if (n.data('bridge')) { n.style('display', 'element'); return; }
  273 + if (hiddenTypes[t] || dirHidden.has(n.id())) disp = 'none';
  274 + else if (hideOrphans) {
  275 + const hasNbr = n.connectedEdges().some((e: any) => {
  276 + const other = e.source().id() === n.id() ? e.target() : e.source();
  277 + return !hiddenTypes[other.data('type')] && !dirHidden.has(other.id());
  278 + });
  279 + if (!hasNbr) disp = 'none';
  280 + }
  281 + n.style('display', disp);
  282 + });
  283 + // 标识(框内)被隐藏后,实体框变空 → 让模块名回到框内居中(不再浮在空框上方)
  284 + cy.nodes('[type = "entity"]').forEach((n: any) => {
  285 + const hasVisibleChild = n.children().some((c: any) => c.style('display') !== 'none');
  286 + n.toggleClass('entity-empty', !hasVisibleChild);
  287 + });
  288 + });
  289 + // 不调用 fit:筛选时保持当前视图位置与缩放不变
  290 +}
  291 +
  292 +const STYLE: any[] = [
  293 + { selector: 'node', style: { label: 'data(label)', 'text-wrap': 'wrap', 'border-width': 1.6, 'transition-property': 'opacity', 'transition-duration': '0.15s' } },
  294 + // 叶子节点(属性/标识/行为/规则/事件/目录)用固定基准尺寸,标签居中
  295 + { selector: 'node[type != "entity"]', style: { width: 'data(bw)', height: 'data(bh)', 'font-size': 'data(bfont)', 'text-max-width': 'data(btw)', 'text-valign': 'center', 'text-halign': 'center' } },
  296 + // 实体 = compound 父节点:自动裹住框内标识,半透明底,标签置顶
  297 + { selector: 'node[type="entity"]', style: { shape: 'round-rectangle', 'background-color': COL.entity[0], 'background-opacity': 0.4, 'border-color': COL.entity[1], color: COL.entity[2], 'text-valign': 'top', 'text-halign': 'center', 'font-size': 12, 'font-weight': 700, 'text-margin-y': 3, padding: '12px', 'min-width': 44, 'min-height': 22 } },
  298 + { selector: 'node[type="behavior"]', style: { shape: 'ellipse', 'background-color': COL.behavior[0], 'border-color': COL.behavior[1], color: COL.behavior[2] } },
  299 + { selector: 'node[type="rule"]', style: { shape: 'diamond', 'background-color': COL.rule[0], 'border-color': COL.rule[1], color: COL.rule[2] } },
  300 + { selector: 'node[type="event"]', style: { shape: 'triangle', 'background-color': COL.event[0], 'border-color': COL.event[1], color: COL.event[2], 'text-margin-y': 'data(bmy)' } },
  301 + // 目录节点=空节点:白底虚线边框,视觉上“空”
  302 + { selector: 'node[type="nav"]', style: { shape: 'round-rectangle', 'background-color': '#ffffff', 'border-color': COL.nav[1], 'border-style': 'dashed', color: COL.nav[2] } },
  303 + // 标识 A(金)与属性 B(灰)同形(圆角矩形),只靠颜色区分
  304 + { selector: 'node[type="identifier"]', style: { shape: 'round-rectangle', 'background-color': COL.identifier[0], 'border-color': COL.identifier[1], color: COL.identifier[2] } },
  305 + { selector: 'node[type="attribute"]', style: { shape: 'round-rectangle', 'background-color': COL.attribute[0], 'border-color': COL.attribute[1], color: COL.attribute[2] } },
  306 + // 引用节点用属性灰(type=attribute),不再单独着色
  307 + // 实体框内标识被隐藏 → 空框:回到固定尺寸、实底、标签居中(模块名回到框内)
  308 + { selector: 'node.entity-empty', style: { 'min-width': 'data(bw)', 'min-height': 'data(bh)', 'text-valign': 'center', 'text-halign': 'center', 'text-margin-y': 0, 'background-opacity': 1, padding: '0px' } },
  309 + { selector: 'edge', style: { width: 1.3, 'line-color': '#dfe1e8', 'curve-style': 'bezier', label: 'data(label)', 'font-size': 8, color: '#b8bac4', 'text-rotation': 'autorotate', 'text-background-color': '#fbfbfc', 'text-background-opacity': 0.85, 'text-background-padding': 1 } },
  310 + { selector: '.faded', style: { opacity: 0.12, 'text-opacity': 0.06 } },
  311 + { selector: 'node.hl', style: { 'border-width': 3.5 } },
  312 + { selector: 'edge.hl', style: { 'line-color': 'data(hlcolor)', color: 'data(hlcolor)' } },
  313 +];
  314 +
  315 +export default function NetworkGraph({ ontology }: { ontology: Ontology }) {
  316 + const boxRef = useRef<HTMLDivElement | null>(null);
  317 + const cyRef = useRef<any>(null);
  318 + const tipRef = useRef<HTMLDivElement | null>(null);
  319 + const activeRef = useRef<any>(null);
  320 + const selRef = useRef(false);
  321 + const [tip, setTip] = useState<any>(null);
  322 + const [hiddenTypes, setHiddenTypes] = useState<Record<string, boolean>>({ nav: true }); // 默认隐藏目录节点(可在筛选面板点「目录」显示)
  323 + const [hideOrphans, setHideOrphans] = useState(false);
  324 + const filterRef = useRef<any>({ hiddenTypes: { nav: true }, hideOrphans: false, dirHidden: new Set() });
  325 +
  326 + // ---- 目录数据:M3 导航树 + 聚合层级 ----
  327 + const { tree, others } = useMemo(() => buildTree(ontology), [ontology]);
  328 + const navs = useMemo(() => ontology.navigations || [], [ontology]);
  329 + const byRoot = useMemo(() => Object.fromEntries(tree.map((t) => [t.root.id, t])), [tree]);
  330 + // 未被任何导航叶子引用的聚合,兜底列在目录底部
  331 + const restTree = useMemo(() => {
  332 + if (!navs.length) return [];
  333 + const used = new Set<string>();
  334 + const mark = (n: NavNode) => { if (n.link) used.add(n.link); (n.children || []).forEach(mark); };
  335 + navs.forEach(mark);
  336 + return tree.filter((t) => !used.has(t.root.id));
  337 + }, [navs, tree]);
  338 +
  339 + const [closedNavs, setClosedNavs] = useState<Set<string>>(() => new Set()); // 折叠的导航节点(含叶子)
  340 + const [closedRoots, setClosedRoots] = useState<Set<string>>(() => new Set()); // 无导航退化模式下折叠的聚合
  341 + const [openBehs, setOpenBehs] = useState<Set<string>>(() => new Set());
  342 + const [selId, setSelId] = useState<string | null>(null);
  343 + const pickRef = useRef<((id: string) => void) | null>(null);
  344 + const pick = (id: string) => pickRef.current && pickRef.current(id);
  345 + // 每个节点 → 它的“底层全部节点”id 列表(点击 → 高亮整簇);图与目录共用同一份。
  346 + const descRef = useRef<Map<any, any>>(new Map());
  347 + useEffect(() => {
  348 + const m = new Map<any, any>();
  349 + tree.forEach((t) => {
  350 + m.set(t.root.id, clusterIds(t));
  351 + t.subs.forEach((s) => {
  352 + const obj = ontology.objects.find((o) => o.id === s.id);
  353 + const refT = (obj?.relationships || []).map((r) => r.target_object_id).filter((id) => id && id !== t.root.id);
  354 + const refNodes = (obj?.relationships || []).filter((r) => r.fk_field).map((r) => refNodeId(s.id, r));
  355 + m.set(s.id, [s.id, ...attrNodeIds(s), ...refT, ...refNodes]);
  356 + });
  357 + t.behaviors.forEach(({ b, rules, events }) => {
  358 + m.set(b.id, [b.id, ...rules.map((r) => r.id), ...events.map((e) => e.id)]);
  359 + rules.forEach((r) => m.has(r.id) || m.set(r.id, [r.id]));
  360 + events.forEach((e) => m.has(e.id) || m.set(e.id, [e.id]));
  361 + });
  362 + });
  363 + const walk = (n: NavNode) => {
  364 + const ids: any[] = [];
  365 + const w2 = (node: NavNode) => { ids.push(`nav__${node.id}`); if (node.link && byRoot[node.link]) ids.push(...clusterIds(byRoot[node.link])); (node.children || []).forEach(w2); };
  366 + w2(n);
  367 + m.set(`nav__${n.id}`, ids);
  368 + (n.children || []).forEach(walk);
  369 + };
  370 + navs.forEach(walk);
  371 + descRef.current = m;
  372 + }, [ontology, tree, navs, byRoot]);
  373 +
  374 + // 目录折叠只作用于目录本身(展开/合并树行),不再隐藏图上节点;
  375 + // 图的聚焦改由“点击任意节点 → 高亮它旗下的全部底层节点”实现(见 descRef / highlightById)。
  376 + const dirHidden = useMemo(() => new Set(), []);
  377 +
  378 + useEffect(() => {
  379 + const cy = cytoscape({
  380 + container: boxRef.current,
  381 + elements: buildElements(ontology),
  382 + style: STYLE,
  383 + minZoom: 0.2, maxZoom: 4, wheelSensitivity: 0.25,
  384 + });
  385 + cyRef.current = cy;
  386 +
  387 + // 布局在挂载后(容器与节点尺寸就绪)才跑,避免首帧节点重叠/漏画
  388 + const LAYOUT: any = { name: 'fcose', quality: 'proof', randomize: true, animate: false, fit: true, padding: 45, nodeSeparation: 110, idealEdgeLength: 120, nodeRepulsion: 8000, gravity: 0.28, packComponents: true, nestingFactor: 0.6 };
  389 + const runLayout = () => { cy.resize(); cy.layout(LAYOUT).run(); };
  390 + requestAnimationFrame(runLayout);
  391 + cy.on('layoutstop', () => { applyFilter(cy, filterRef.current.hiddenTypes, filterRef.current.hideOrphans, filterRef.current.dirHidden); });
  392 + const ro = new ResizeObserver(() => cy.resize());
  393 + if (boxRef.current) ro.observe(boxRef.current);
  394 +
  395 + const place = () => {
  396 + const n = activeRef.current, el = tipRef.current;
  397 + if (!n || !el || !boxRef.current) return;
  398 + const p = n.renderedPosition();
  399 + const flip = p.x > boxRef.current.clientWidth * 0.58;
  400 + el.style.left = `${flip ? p.x - 300 : p.x + 30}px`;
  401 + el.style.top = `${Math.max(8, p.y - 12)}px`;
  402 + };
  403 + const showTip = (n: any) => { activeRef.current = n; setTip(describe(ontology, n.id())); requestAnimationFrame(place); };
  404 + const hideTip = () => { activeRef.current = null; setTip(null); };
  405 +
  406 + // 高亮一组节点(整簇):淡化其余,连线仅保留簇内。
  407 + const highlightCluster = (idsArr: any[], focusId: string | null, doFit?: boolean) => {
  408 + const eles = cy.collection();
  409 + idsArr.forEach((id) => { const n = cy.$id(id); if (n && n.nonempty() && n.style('display') !== 'none') eles.merge(n); });
  410 + if (eles.empty()) return;
  411 + selRef.current = true;
  412 + cy.elements().removeClass('faded hl');
  413 + cy.elements().addClass('faded');
  414 + eles.removeClass('faded');
  415 + const inner = eles.edgesWith(eles);
  416 + inner.removeClass('faded').addClass('hl');
  417 + eles.addClass('hl');
  418 + setSelId(focusId || null);
  419 + hideTip();
  420 + if (doFit && eles.length > 1) cy.animate({ fit: { eles: eles.union(inner), padding: 60 }, duration: 260 });
  421 + else if (focusId) { const f = cy.$id(focusId); if (f.nonempty()) cy.animate({ center: { eles: f }, duration: 220 }); }
  422 + };
  423 + // 任意节点点击(图内或目录)→ 高亮它的“底层全部节点”,而非仅相邻节点
  424 + const highlightById = (id: string, doFit?: boolean) => highlightCluster(descRef.current.get(id) || [id], id, doFit);
  425 + cy.on('tap', 'node', (evt: any) => highlightById(evt.target.id(), true));
  426 + cy.on('tap', (evt: any) => { if (evt.target === cy) { selRef.current = false; cy.elements().removeClass('faded hl'); setSelId(null); hideTip(); } });
  427 + pickRef.current = (id: string) => highlightById(id, true);
  428 + cy.on('mouseover', 'node', (evt: any) => { if (!selRef.current) showTip(evt.target); });
  429 + cy.on('mouseout', 'node', () => { if (!selRef.current) hideTip(); });
  430 + cy.on('render', place);
  431 +
  432 + return () => { ro.disconnect(); cy.destroy(); };
  433 + }, [ontology]);
  434 +
  435 + // 过滤变化时重新应用(类型筛选 / 孤儿 / 目录折叠)
  436 + useEffect(() => {
  437 + filterRef.current = { hiddenTypes, hideOrphans, dirHidden };
  438 + if (cyRef.current) applyFilter(cyRef.current, hiddenTypes, hideOrphans, dirHidden);
  439 + }, [hiddenTypes, hideOrphans, dirHidden]);
  440 +
  441 + const zoom = (f: number) => { const cy = cyRef.current; if (cy) cy.zoom({ level: cy.zoom() * f, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); };
  442 + const fit = () => { const cy = cyRef.current; if (cy) { const v = cy.elements(':visible'); cy.fit(v.length ? v : undefined, 40); } };
  443 + const TYPES: [string, string][] = [['entity', '实体'], ['identifier', '标识'], ['attribute', '属性'], ['behavior', '行为'], ['rule', '规则'], ['event', '事件'], ['nav', '目录']];
  444 + const toggleType = (t: string) => setHiddenTypes((h) => ({ ...h, [t]: !h[t] }));
  445 + const btn: React.CSSProperties = { width: 30, height: 30, borderRadius: 8, border: '1px solid #e6e6e6', background: '#fff', color: '#52525b', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' };
  446 + const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>) => (id: string) => setter((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
  447 + const toggleNav = toggleSet(setClosedNavs);
  448 + const toggleRoot = toggleSet(setClosedRoots);
  449 + const toggleBeh = toggleSet(setOpenBehs);
  450 + const allNavIds = useMemo(() => { const s: string[] = []; const walk = (n: NavNode) => { s.push(n.id); (n.children || []).forEach(walk); }; navs.forEach(walk); return s; }, [navs]);
  451 + const expandAll = () => { setClosedNavs(new Set()); setClosedRoots(new Set()); setOpenBehs(new Set(ontology.behaviors.map((b) => b.id))); };
  452 + const collapseAll = () => { setClosedNavs(new Set(allNavIds)); setClosedRoots(new Set(tree.map((t) => t.root.id))); setOpenBehs(new Set()); };
  453 +
  454 + // ---- 目录渲染(树线样式) ----
  455 + // 一层缩进容器,画竖直引导线
  456 + const nest = (children: React.ReactNode) => (
  457 + <div style={{ marginLeft: 9, paddingLeft: 7, borderLeft: '1px solid #e8e8ea' }}>{children}</div>
  458 + );
  459 + // 顶层模块标题:点标题=图上高亮整簇;点三角=展开/合并目录
  460 + const moduleHeader = (label: string, open: boolean, onToggle: () => void, onPick: () => void, note: any) => (
  461 + <div onClick={onPick} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', userSelect: 'none', padding: '6px 8px', margin: '3px 0 1px', borderRadius: 6, fontSize: 13, fontWeight: 700, color: '#27272a' }}>
  462 + <span onClick={(e) => { e.stopPropagation(); onToggle(); }} style={{ width: 12, textAlign: 'center', color: '#9a9aa0', fontSize: 10, cursor: 'pointer' }}>{open ? '▾' : '▸'}</span>
  463 + <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
  464 + {note && <span style={{ fontSize: 10, color: '#b4b4ba', fontWeight: 400 }}>{note}</span>}
  465 + </div>
  466 + );
  467 +
  468 + // 聚合内部:子实体 / 引用 / 行为(→ 规则、事件),不含根行(根由上层承载)。
  469 + // 这些是具体节点,点击走 pick():居中并高亮邻域(看单节点关系)。
  470 + const renderAggInner = (t: any) => (
  471 + <>
  472 + {t.subs.length > 0 && <GroupLabel text="子实体" count={t.subs.length} />}
  473 + {t.subs.map((s: any) => <Row key={s.id} type="entity" label={s.name} selected={selId === s.id} onPick={() => pick(s.id)} />)}
  474 + {t.refs.length > 0 && <GroupLabel text="引用" count={t.refs.length} />}
  475 + {t.refs.map((r: any) => <Row key={r.key} type="entity" label={r.label} selected={selId === r.target} onPick={() => pick(r.target)} />)}
  476 + {t.behaviors.length > 0 && <GroupLabel text="行为" count={t.behaviors.length} />}
  477 + {t.behaviors.map(({ b, rules, events }: any) => {
  478 + const kids = rules.length + events.length;
  479 + const bOpen = openBehs.has(b.id);
  480 + return (
  481 + <React.Fragment key={b.id}>
  482 + <Row type="behavior" label={b.name} chev={kids ? (bOpen ? 'open' : 'closed') : null} onChev={kids ? () => toggleBeh(b.id) : null} selected={selId === b.id} onPick={() => pick(b.id)} />
  483 + {bOpen && nest(
  484 + <>
  485 + {rules.map((r: any) => <Row key={r.id} type="rule" label={r.name} selected={selId === r.id} onPick={() => pick(r.id)} />)}
  486 + {events.map((e: any) => <Row key={e.id} type="event" label={e.name} selected={selId === e.id} onPick={() => pick(e.id)} />)}
  487 + </>
  488 + )}
  489 + </React.Fragment>
  490 + );
  491 + })}
  492 + </>
  493 + );
  494 +
  495 + // 聚合根实体行 + 内部。根实体(如 订单)在图里是独立节点,目录里也单列一行,
  496 + // 作为叶子目录(如 销售订单明细)的子节点,与图完全对齐。
  497 + const renderAggRootAndInner = (t: any) => {
  498 + const open = !closedRoots.has(t.root.id);
  499 + return (
  500 + <>
  501 + <Row type="entity" label={t.root.name} bold note={`${t.subs.length + t.refs.length}子·${t.behaviors.length}行为`}
  502 + chev={open ? 'open' : 'closed'} onChev={() => toggleRoot(t.root.id)}
  503 + selected={selId === t.root.id} onPick={() => pick(t.root.id)} />
  504 + {open && nest(renderAggInner(t))}
  505 + </>
  506 + );
  507 + };
  508 +
  509 + // 非顶层导航节点:点标题=高亮该目录旗下全部底层节点;点三角=展开/合并目录
  510 + const renderNav = (n: NavNode) => {
  511 + const open = !closedNavs.has(n.id);
  512 + const t = n.link ? byRoot[n.link] : null;
  513 + const label = n.zh || n.name || n.id;
  514 + return (
  515 + <React.Fragment key={n.id}>
  516 + <Row type="nav" label={label} bold
  517 + note={t ? `${t.subs.length + t.refs.length}子·${t.behaviors.length}行为` : null}
  518 + chev={open ? 'open' : 'closed'} onChev={() => toggleNav(n.id)}
  519 + selected={selId === `nav__${n.id}`} onPick={() => pick(`nav__${n.id}`)} />
  520 + {open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))}
  521 + </React.Fragment>
  522 + );
  523 + };
  524 +
  525 + // 顶层模块(M3 第一层)
  526 + const renderTopModule = (n: NavNode) => {
  527 + const open = !closedNavs.has(n.id);
  528 + const t = n.link ? byRoot[n.link] : null;
  529 + const label = n.zh || n.name || n.id;
  530 + const note = t ? `${t.subs.length + t.refs.length}子·${t.behaviors.length}行为` : null;
  531 + return (
  532 + <React.Fragment key={n.id}>
  533 + {moduleHeader(label, open, () => toggleNav(n.id), () => pick(`nav__${n.id}`), note)}
  534 + {open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))}
  535 + </React.Fragment>
  536 + );
  537 + };
  538 +
  539 + // 顶层聚合(无 M3 导航 / 未入目录时的兜底)
  540 + const renderAggTop = (t: any) => (
  541 + <div key={t.root.id} style={{ marginBottom: 2 }}>{renderAggRootAndInner(t)}</div>
  542 + );
  543 +
  544 + const dirLink: React.CSSProperties = { cursor: 'pointer', color: '#6b6b72', userSelect: 'none' };
  545 + const directory = (
  546 + <div style={{ padding: '2px 4px 12px' }}>
  547 + <div style={{ display: 'flex', gap: 12, padding: '2px 8px 8px', fontSize: 11 }}>
  548 + <span onClick={expandAll} style={dirLink}>全部展开</span>
  549 + <span onClick={collapseAll} style={dirLink}>全部合并</span>
  550 + </div>
  551 + {navs.length ? (
  552 + <>
  553 + {navs.map(renderTopModule)}
  554 + {restTree.length > 0 && <GroupLabel text="未入目录" count={restTree.length} />}
  555 + {restTree.map(renderAggTop)}
  556 + </>
  557 + ) : tree.map(renderAggTop)}
  558 + {others.length > 0 && <GroupLabel text="未归组" count={others.length} />}
  559 + {others.map((o) => <Row key={o.id} type={o.type} label={o.label} selected={selId === o.id} onPick={() => pick(o.id)} />)}
  560 + </div>
  561 + );
  562 +
  563 + return (
  564 + <div style={{ position: 'absolute', inset: 0, display: 'flex', overflow: 'hidden' }}>
  565 + {/* 左侧目录(内置为组件自身的左栏,不再 portal 到外部侧边栏) */}
  566 + <div style={{ width: 260, flex: 'none', overflow: 'auto', borderRight: '1px solid #ececec', background: '#fff' }}>
  567 + {directory}
  568 + </div>
  569 +
  570 + {/* 右侧:本体网络图 + 悬浮控件 */}
  571 + <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
  572 + <div ref={boxRef} style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 55% 42%,#fdfdfe,#f6f7f9)' }} />
  573 +
  574 + {/* 筛选面板:按类型显示/隐藏 + 隐藏孤儿节点 */}
  575 + <div style={{ position: 'absolute', top: 12, left: 14, width: 236, zIndex: 4 }}>
  576 + <div style={{ background: '#fff', border: '1px solid #ececec', borderRadius: 10, padding: '8px 10px', boxShadow: '0 1px 8px rgba(0,0,0,.06)' }}>
  577 + <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
  578 + <span style={{ fontSize: 11, color: '#a6a6ac' }}>显示</span>
  579 + {TYPES.map(([t, label]) => {
  580 + const hid = !!hiddenTypes[t]; const c = COL[t];
  581 + return (
  582 + <span key={t} onClick={() => toggleType(t)} title={hid ? '点击显示' : '点击隐藏'}
  583 + style={{ cursor: 'pointer', fontSize: 11.5, padding: '3px 9px', borderRadius: 12, userSelect: 'none', border: `1px solid ${hid ? '#e6e6e6' : c[1]}`, background: hid ? '#f4f4f5' : c[0], color: hid ? '#b4b4ba' : c[2], textDecoration: hid ? 'line-through' : 'none' }}>{label}</span>
  584 + );
  585 + })}
  586 + </div>
  587 + <label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 7, fontSize: 12, color: '#52525b', cursor: 'pointer', userSelect: 'none' }}>
  588 + <input type="checkbox" checked={hideOrphans} onChange={(e) => setHideOrphans(e.target.checked)} style={{ cursor: 'pointer' }} /> 隐藏孤儿节点
  589 + </label>
  590 + </div>
  591 + </div>
  592 +
  593 + <div style={{ position: 'absolute', bottom: 16, left: 14, fontSize: 11, color: '#c4c4cc', pointerEvents: 'none' }}>滚轮缩放 · 拖拽平移 · 单击节点看关系</div>
  594 + <div style={{ position: 'absolute', top: 12, right: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
  595 + <div style={btn} onClick={() => zoom(1.25)}>+</div>
  596 + <div style={btn} onClick={() => zoom(1 / 1.25)}>-</div>
  597 + <div style={{ ...btn, fontSize: 13 }} onClick={fit} title="适应画布">⤢</div>
  598 + </div>
  599 +
  600 + <div ref={tipRef} style={{ position: 'absolute', width: 282, background: '#fff', border: '1px solid #ececec', borderRadius: 12, padding: '12px 14px', boxShadow: '0 6px 24px rgba(0,0,0,.12)', pointerEvents: 'none', display: tip ? 'block' : 'none', zIndex: 5 }}>
  601 + {tip && (
  602 + <>
  603 + <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
  604 + <span style={{ fontSize: 10.5, fontWeight: 600, color: '#fff', background: COL[tip.type][1], padding: '2px 8px', borderRadius: 5 }}>{TYPE_CN[tip.type]}</span>
  605 + <span style={{ fontSize: 14.5, fontWeight: 700 }}>{tip.title}</span>
  606 + </div>
  607 + {tip.lines.map((l: any, i: number) => (<div key={i} style={{ fontSize: 12, color: '#52525b', lineHeight: 1.65 }}>{l}</div>))}
  608 + </>
  609 + )}
  610 + </div>
  611 + </div>
  612 + </div>
  613 + );
  614 +}
web/src/viz/OntologyCanvas.tsx 0 → 100644
  1 +import { useEffect, useState } from 'react'
  2 +import { Spin, Alert, Button, Typography } from 'antd'
  3 +import { ReloadOutlined } from '@ant-design/icons'
  4 +import { loadOntology } from '../ontology/build'
  5 +import type { Ontology } from '../ontology/types'
  6 +import NetworkGraph from './NetworkGraph'
  7 +import ERDiagram from './ERDiagram'
  8 +import ProcessDiagram from './ProcessDiagram'
  9 +
  10 +const { Title, Text } = Typography
  11 +
  12 +const META: Record<string, { title: string; desc: string }> = {
  13 + network: { title: '本体网络图', desc: '对象 + 行为 + 规则 + 事件的关联网络(M1/M2/ME/MetaRule 动态合成)' },
  14 + er: { title: '数据结构图(ER)', desc: '聚合根/子实体的字段结构、外键引用与组合关系(M1 动态合成)' },
  15 + process: { title: '场景流程图(BPMN)', desc: '端到端场景流程(M4 flowSteps → bpmn-elk-layout 自动布局 → bpmn-js 渲染)' },
  16 +}
  17 +
  18 +/** 可视化画布宿主:加载一次本体 IR,全高渲染选中的视图。 */
  19 +export function OntologyCanvas({ which }: { which: 'network' | 'er' | 'process' }) {
  20 + const [onto, setOnto] = useState<Ontology | null>(null)
  21 + const [err, setErr] = useState('')
  22 + const [key, setKey] = useState(0)
  23 +
  24 + useEffect(() => {
  25 + setOnto(null); setErr('')
  26 + loadOntology().then(setOnto).catch((e) => setErr(String(e?.message || e)))
  27 + }, [key])
  28 +
  29 + const m = META[which]
  30 + return (
  31 + <div style={{ padding: 16 }}>
  32 + <div style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
  33 + <div>
  34 + <Title level={4} style={{ margin: 0 }}>{m.title}</Title>
  35 + <Text type="secondary" style={{ fontSize: 13 }}>{m.desc}</Text>
  36 + </div>
  37 + <div style={{ flex: 1 }} />
  38 + <Button icon={<ReloadOutlined />} onClick={() => setKey((k) => k + 1)}>刷新</Button>
  39 + </div>
  40 + <div style={{ position: 'relative', height: 'calc(100vh - 190px)', minHeight: 420, border: '1px solid #f0f0f0', borderRadius: 10, background: '#fff', overflow: 'hidden' }}>
  41 + {err ? (
  42 + <div style={{ padding: 24 }}><Alert type="error" showIcon message="加载本体失败" description={err} /></div>
  43 + ) : !onto ? (
  44 + <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Spin tip="合成本体视图…"><div style={{ width: 80, height: 40 }} /></Spin></div>
  45 + ) : which === 'network' ? (
  46 + <NetworkGraph ontology={onto} />
  47 + ) : which === 'er' ? (
  48 + <ERDiagram ontology={onto} />
  49 + ) : (
  50 + <ProcessDiagram ontology={onto} />
  51 + )}
  52 + </div>
  53 + </div>
  54 + )
  55 +}
web/src/viz/ProcessDiagram.tsx 0 → 100644
  1 +// 场景流程图(BPMN)视图 —— 通用渲染引擎,数据全在前端合成,无后端依赖。
  2 +// ProcessModel(M4 flowSteps 投影) → toElkBpmn 出 ELK-BPMN JSON(含泳道语义)
  3 +// → bpmn-elk-layout(elkjs) 自动横向布局 → 按类型着色 → bpmn-js NavigatedViewer 渲染。
  4 +// 点击任意节点/连线 → 用 detailMap(按 element.id 索引)弹出详情(完整条件、关联行为、网关分支等)。
  5 +// 从 onto-app 的 BpmnDiagram.jsx 移植而来(JSX→TSX);唯一区别:ELK JSON + 详情表由本地
  6 +// toElkBpmn / detailMap 合成,而非后端 api.getBpmn 拉取。
  7 +import React, { useEffect, useRef, useState } from 'react'
  8 +// @ts-ignore bpmn-js 的 NavigatedViewer 附带自身类型声明;此处兜底防止解析差异。
  9 +import NavigatedViewer from 'bpmn-js/lib/NavigatedViewer'
  10 +import { BpmnElkLayout } from 'bpmn-elk-layout'
  11 +import 'bpmn-js/dist/assets/diagram-js.css'
  12 +import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
  13 +import type { Ontology } from '../ontology/types'
  14 +import { toElkBpmn, detailMap } from '../ontology/bpmn'
  15 +
  16 +// 按 BPMN 元素类型上色(bpmn-js 认 bioc/color 两套 DI 着色扩展)。配色对齐本体网络图。
  17 +const NODE_COLOR: Record<string, { fill: string; stroke: string }> = {
  18 + startEvent: { fill: '#e9f7ee', stroke: '#3f9d63' },
  19 + endEvent: { fill: '#fdecef', stroke: '#d6486a' },
  20 + intermediateCatchEvent: { fill: '#fbe3ef', stroke: '#c2528a' },
  21 + userTask: { fill: '#eef2fd', stroke: '#5b7fe0' },
  22 + task: { fill: '#eef2fd', stroke: '#5b7fe0' },
  23 + exclusiveGateway: { fill: '#fff4e3', stroke: '#e0944a' },
  24 + parallelGateway: { fill: '#fff4e3', stroke: '#e0944a' },
  25 +}
  26 +
  27 +function colorize(xml: string): string {
  28 + const doc = new DOMParser().parseFromString(xml, 'application/xml')
  29 + const typeOf: Record<string, string> = {} // 元素 id -> BPMN 类型(从语义部分收集)
  30 + for (const el of Array.from(doc.getElementsByTagName('*'))) {
  31 + const id = el.getAttribute && el.getAttribute('id')
  32 + if (id && NODE_COLOR[el.localName]) typeOf[id] = el.localName
  33 + }
  34 + for (const el of Array.from(doc.getElementsByTagName('*'))) {
  35 + if (el.localName !== 'BPMNShape') continue
  36 + const be = el.getAttribute('bpmnElement')
  37 + const c = be ? NODE_COLOR[typeOf[be]] : undefined
  38 + if (!c) continue // 泳道/池保持白底
  39 + el.setAttribute('bioc:fill', c.fill)
  40 + el.setAttribute('bioc:stroke', c.stroke)
  41 + el.setAttribute('color:background-color', c.fill)
  42 + el.setAttribute('color:border-color', c.stroke)
  43 + }
  44 + const defs = doc.documentElement
  45 + defs.setAttribute('xmlns:bioc', 'http://bpmn.io/schema/bpmn/biocolor/1.0')
  46 + defs.setAttribute('xmlns:color', 'http://www.omg.org/spec/BPMN/non-normative/color/1.0')
  47 + return new XMLSerializer().serializeToString(doc)
  48 +}
  49 +
  50 +export default function ProcessDiagram({ ontology }: { ontology: Ontology }) {
  51 + const boxRef = useRef<HTMLDivElement | null>(null)
  52 + const viewerRef = useRef<any>(null)
  53 + const xmlRef = useRef<string>('')
  54 + const [status, setStatus] = useState<'loading' | 'ok' | 'error'>('loading') // loading | ok | error
  55 + const [err, setErr] = useState('')
  56 + const [active, setActive] = useState(0) // 当前显示的场景下标
  57 + const [selected, setSelected] = useState<any>(null) // 点中的图元详情(节点/连线)
  58 +
  59 + const scenes = ontology.processModels || []
  60 + const empty = scenes.length === 0
  61 +
  62 + // 切本体时复位到第一张
  63 + useEffect(() => { setActive(0) }, [ontology])
  64 +
  65 + useEffect(() => {
  66 + if (empty || !boxRef.current) return
  67 + let cancelled = false
  68 + const viewer: any = new NavigatedViewer({ container: boxRef.current })
  69 + viewerRef.current = viewer
  70 + setSelected(null)
  71 + ;(async () => {
  72 + try {
  73 + setStatus('loading')
  74 + const pm = scenes[active]
  75 + if (!pm) return
  76 + const elk = toElkBpmn(pm) // ProcessModel → ELK-BPMN JSON(前端合成,替代后端)
  77 + const details = detailMap(pm, ontology) // bpmn 元素 id → 点击详情(前端合成)
  78 + const laid = await new BpmnElkLayout().to_bpmn(elk) // elkjs 布局 → 带坐标+泳道的原生横向 BPMN XML
  79 + const xml = colorize(laid) // 只按类型着色(标签由布局库摆放)
  80 + if (cancelled) return
  81 + xmlRef.current = xml
  82 + await viewer.importXML(xml)
  83 + if (cancelled) return
  84 + viewer.get('canvas').zoom('fit-viewport', 'auto') // 'auto' = 缩放后在视口内居中(否则窄图会贴左)
  85 + // 点击节点/连线 → 查详情;点空白/泳道(无 details)则关闭面板
  86 + viewer.get('eventBus').on('element.click', (ev: any) => {
  87 + const d = details[ev.element?.id]
  88 + setSelected(d ? { ...d, _id: ev.element.id } : null)
  89 + })
  90 + setStatus('ok')
  91 + } catch (e: any) {
  92 + if (!cancelled) { setErr(String(e?.message || e)); setStatus('error') }
  93 + }
  94 + })()
  95 + return () => { cancelled = true; viewer.destroy() }
  96 + }, [ontology, active, empty])
  97 +
  98 + const zoom = (f: number) => { const c = viewerRef.current?.get('canvas'); if (c) c.zoom(c.zoom() * f) }
  99 + const fit = () => { const c = viewerRef.current?.get('canvas'); if (c) c.zoom('fit-viewport', 'auto') }
  100 + const download = () => {
  101 + if (!xmlRef.current) return
  102 + const blob = new Blob([xmlRef.current], { type: 'application/xml' })
  103 + const a = document.createElement('a')
  104 + a.href = URL.createObjectURL(blob)
  105 + a.download = (scenes[active]?.name || 'process') + '.bpmn'
  106 + a.click()
  107 + URL.revokeObjectURL(a.href)
  108 + }
  109 +
  110 + const btn: React.CSSProperties = { width: 30, height: 30, borderRadius: 8, border: '1px solid #e6e6e6', background: '#fff', color: '#52525b', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }
  111 +
  112 + // ---- 详情面板 ----
  113 + const KIND_LABEL: Record<string, string> = { startEvent: '开始事件', endEvent: '结束事件', task: '任务', userTask: '任务', exclusiveGateway: '排他网关(分支判断)', parallelGateway: '并行网关', intermediateCatchEvent: '中间事件', sequenceFlow: '连线 / 分支' }
  114 + const dRow = (k: string, v: any, mono?: boolean) => (
  115 + <div style={{ marginBottom: 9 }}>
  116 + <div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 2 }}>{k}</div>
  117 + <div className={mono ? 'om-mono' : ''} style={{ fontSize: 12.5, color: '#27272a', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{(v ?? '') !== '' ? v : '—'}</div>
  118 + </div>
  119 + )
  120 + const dList = (k: string, arr?: string[]) => (arr && arr.length > 0 ? (
  121 + <div style={{ marginBottom: 9 }}>
  122 + <div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 3 }}>{k}</div>
  123 + {arr.map((x, i) => <div key={i} className="om-mono" style={{ fontSize: 11.5, color: '#3f3f46', background: '#f7f7f9', borderRadius: 6, padding: '5px 8px', marginBottom: 4, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{x}</div>)}
  124 + </div>
  125 + ) : null)
  126 + const renderDetailBody = (sel: any) => {
  127 + if (sel.kind === 'sequenceFlow') {
  128 + return (<>
  129 + {dRow('流向', `${sel.from} → ${sel.to}`)}
  130 + {sel.name ? dRow('分支标签', sel.name) : null}
  131 + {dRow('完整条件 (condition)', sel.condition, true)}
  132 + {!sel.condition ? <div style={{ fontSize: 12, color: '#a0a0a6' }}>这是一条无条件连线。</div> : null}
  133 + </>)
  134 + }
  135 + if (sel.kind === 'exclusiveGateway' || sel.kind === 'parallelGateway') {
  136 + return (
  137 + <div>
  138 + <div style={{ fontSize: 11, color: '#a0a0a6', marginBottom: 4 }}>分支({(sel.branches || []).length})</div>
  139 + {(sel.branches || []).map((b: any, i: number) => (
  140 + <div key={i} style={{ border: '1px solid #eee', borderRadius: 8, padding: '7px 9px', marginBottom: 6 }}>
  141 + <div style={{ fontSize: 12.5, fontWeight: 600, color: '#27272a' }}>{b.label || '(无标签)'} <span style={{ color: '#a0a0a6', fontWeight: 400 }}>→ {b.to}</span></div>
  142 + {b.condition ? <div className="om-mono" style={{ fontSize: 11.5, color: '#52525b', marginTop: 3, whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{b.condition}</div> : null}
  143 + </div>
  144 + ))}
  145 + </div>
  146 + )
  147 + }
  148 + const b = sel.behavior
  149 + if (b) {
  150 + return (<>
  151 + {dRow('关联行为', `${b.name}(${b.id})`)}
  152 + {dRow('行为类型', b.type === 'COMMAND' ? 'COMMAND(改状态)' : b.type)}
  153 + {b.commandType ? dRow('命令类型 (commandType)', b.commandType) : null}
  154 + {dRow('归属对象', b.owner)}
  155 + {b.description ? dRow('说明', b.description) : null}
  156 + {dList('前置条件', b.preconditions)}
  157 + {dList('后置赋值', b.effects)}
  158 + {dList('产出事件', b.produced_events)}
  159 + {dList('适用规则', b.applied_rules)}
  160 + </>)
  161 + }
  162 + return <div style={{ fontSize: 12, color: '#a0a0a6' }}>{sel.kind === 'startEvent' ? '流程开始。' : sel.kind === 'endEvent' ? '流程结束。' : '(无更多详情)'}</div>
  163 + }
  164 +
  165 + if (empty) {
  166 + return (
  167 + <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#a0a0a6', fontSize: 14 }}>暂无可视化的场景流程</div>
  168 + )
  169 + }
  170 +
  171 + return (
  172 + <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
  173 + <div ref={boxRef} style={{ position: 'absolute', inset: 0 }} />
  174 +
  175 + {scenes.length > 1 && (
  176 + <div style={{ position: 'absolute', top: 12, left: 14, display: 'flex', flexWrap: 'wrap', gap: 6, maxWidth: 'calc(100% - 80px)', zIndex: 5 }}>
  177 + {scenes.map((pm, i) => (
  178 + <div key={pm.id} onClick={() => setActive(i)} title={pm.name}
  179 + style={{ padding: '6px 12px', fontSize: 12.5, borderRadius: 8, cursor: 'pointer', border: '1px solid ' + (i === active ? '#18181b' : '#e6e6e6'), background: i === active ? '#18181b' : '#fff', color: i === active ? '#fff' : '#52525b', fontWeight: i === active ? 600 : 500, whiteSpace: 'nowrap', boxShadow: '0 1px 3px rgba(0,0,0,.06)' }}>
  180 + {pm.name}
  181 + </div>
  182 + ))}
  183 + </div>
  184 + )}
  185 +
  186 + {status === 'loading' && (
  187 + <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#a0a0a6', fontSize: 14 }}>正在生成 BPMN 流程图…</div>
  188 + )}
  189 + {status === 'error' && (
  190 + <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#c0392b', fontSize: 13, padding: 24, textAlign: 'center' }}>流程图渲染失败:{err}</div>
  191 + )}
  192 +
  193 + <div style={{ position: 'absolute', top: 12, right: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
  194 + <div style={btn} onClick={() => zoom(1.2)}>+</div>
  195 + <div style={btn} onClick={() => zoom(1 / 1.2)}>-</div>
  196 + <div style={{ ...btn, fontSize: 13 }} onClick={fit} title="适应画布">⤢</div>
  197 + <div style={{ ...btn, fontSize: 15 }} onClick={download} title="导出 .bpmn 文件">⬇</div>
  198 + </div>
  199 +
  200 + {selected && (
  201 + <div style={{ position: 'absolute', top: 12, right: 56, width: 300, maxHeight: 'calc(100% - 24px)', overflowY: 'auto', background: '#fff', border: '1px solid #e6e6e6', borderRadius: 12, boxShadow: '0 8px 28px rgba(0,0,0,.14)', zIndex: 8, padding: '14px 16px' }}>
  202 + <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 10 }}>
  203 + <div style={{ flex: 1, minWidth: 0 }}>
  204 + <div style={{ fontSize: 15, fontWeight: 700, color: '#18181b' }}>{selected.name || selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind}</div>
  205 + <div style={{ fontSize: 11.5, color: '#a0a0a6', marginTop: 2 }}>{(selected.kindLabel || KIND_LABEL[selected.kind] || selected.kind)}{selected.lane ? ' · ' + selected.lane : ''}</div>
  206 + </div>
  207 + <div onClick={() => setSelected(null)} title="关闭" style={{ cursor: 'pointer', color: '#a0a0a6', fontSize: 18, lineHeight: 1, flex: 'none' }}>✕</div>
  208 + </div>
  209 + {renderDetailBody(selected)}
  210 + </div>
  211 + )}
  212 +
  213 + <div style={{ position: 'absolute', bottom: 14, left: 14, fontSize: 11, color: '#c4c4cc', pointerEvents: 'none' }}>滚轮缩放 · 拖拽平移 · 点击节点/连线看详情 · 由 bpmn-js 渲染</div>
  214 + </div>
  215 + )
  216 +}
web/vite.config.ts
@@ -8,7 +8,7 @@ export default defineConfig({ @@ -8,7 +8,7 @@ export default defineConfig({
8 port: 5174, 8 port: 5174,
9 proxy: { 9 proxy: {
10 '/api': { 10 '/api': {
11 - target: 'http://localhost:8091', 11 + target: 'http://localhost:8080',
12 changeOrigin: true, 12 changeOrigin: true,
13 }, 13 },
14 }, 14 },