Commit 1946deb75138f5ea9f58f53cd3cec8c236c1f78f
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
Showing
16 changed files
with
3187 additions
and
85 deletions
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<T = any>(path: string): Promise<T> { | @@ -9,11 +9,15 @@ async function get<T = any>(path: string): Promise<T> { | ||
| 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 | }, |