diff --git a/package-lock.json b/package-lock.json index 0911d71..e4c6c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "svc-hall", + "name": "flow-center", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "svc-hall", + "name": "flow-center", "version": "0.0.0", "dependencies": { "@open-file-viewer/core": "^0.1.43", @@ -15,6 +15,7 @@ "@wangeditor/editor-for-vue": "^5.1.12", "alova": "^3.4.0", "axios": "^0.24.0", + "bpmn-js": "^17.11.1", "lodash": "^4.17.21", "mitt": "^3.0.1", "mockjs": "^1.1.0", @@ -295,6 +296,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bpmn-io/diagram-js-ui": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/@bpmn-io/diagram-js-ui/-/diagram-js-ui-0.2.4.tgz", + "integrity": "sha512-I678ZGtoH7z7FgzFhzzppM9ThsJ3Lh83kW3GjhjG3xyYciIPYHMaHK1MABP8F69H9pFZpViM6t4qUo8dwrVz0w==", + "license": "MIT", + "dependencies": { + "htm": "^3.1.1", + "preact": "^10.29.2" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.2", "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", @@ -2963,6 +2974,36 @@ "dev": true, "license": "ISC" }, + "node_modules/bpmn-js": { + "version": "17.11.1", + "resolved": "https://registry.npmmirror.com/bpmn-js/-/bpmn-js-17.11.1.tgz", + "integrity": "sha512-ywCeTg5kvN8lYkU+fHE+YXTGlfKc55lRBn7zW3k1//toeMNPy/PS/uQiujRWdFhMrH5dbtDvlwWukNw2pjWw8Q==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "bpmn-moddle": "^8.1.0", + "diagram-js": "^14.10.0", + "diagram-js-direct-editing": "^3.0.1", + "ids": "^1.0.5", + "inherits-browser": "^0.1.0", + "min-dash": "^4.1.1", + "min-dom": "^4.2.1", + "tiny-svg": "^3.1.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bpmn-moddle": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/bpmn-moddle/-/bpmn-moddle-8.1.0.tgz", + "integrity": "sha512-yI5OAFfYVJwViKTsTsonVfCBPtB3MlefADUORwNIxxBOMp21vnoxuxsdgUWlPH/dvAEZh/+mr8UtqOBNu8NC5Q==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0", + "moddle": "^6.2.3", + "moddle-xml": "^10.1.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", @@ -3265,6 +3306,15 @@ "node": ">=0.8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/collection-visit": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/collection-visit/-/collection-visit-1.0.0.tgz", @@ -3323,6 +3373,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/component-event": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/component-event/-/component-event-0.2.1.tgz", + "integrity": "sha512-wGA++isMqiDq1jPYeyv2as/Bt/u+3iLW0rEa+8NQ82jAv3TgqMiCM+B2SaBdn2DfLilLjjq736YcezihRYhfxw==", + "license": "MIT" + }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", @@ -4269,6 +4325,79 @@ "node": ">=8" } }, + "node_modules/diagram-js": { + "version": "14.11.3", + "resolved": "https://registry.npmmirror.com/diagram-js/-/diagram-js-14.11.3.tgz", + "integrity": "sha512-Seq9BHAXfzKS60L4v4Gvgvv72wOtvrfJQAyyPm9pntSZDMzjoodPSXnEUPud1G2zVCMGEUUW++s0reEdaWgkXA==", + "license": "MIT", + "dependencies": { + "@bpmn-io/diagram-js-ui": "^0.2.3", + "clsx": "^2.1.0", + "didi": "^10.2.2", + "inherits-browser": "^0.1.0", + "min-dash": "^4.1.0", + "min-dom": "^4.2.1", + "object-refs": "^0.4.0", + "path-intersection": "^3.0.0", + "tiny-svg": "^3.1.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/diagram-js-direct-editing": { + "version": "3.5.1", + "resolved": "https://registry.npmmirror.com/diagram-js-direct-editing/-/diagram-js-direct-editing-3.5.1.tgz", + "integrity": "sha512-fEQCQ/x5oKVV0m7Nmgv7i85Vhw+kJkKNXF8zcV5Vm0KwF8x/8e3Ax0aJgdXt8/fDPymEkY8HmZweXIipCeeh+g==", + "license": "MIT", + "dependencies": { + "min-dash": "^5.0.0", + "min-dom": "^5.3.0" + }, + "engines": { + "node": "*" + }, + "peerDependencies": { + "diagram-js": "*" + } + }, + "node_modules/diagram-js-direct-editing/node_modules/domify": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/domify/-/domify-3.0.0.tgz", + "integrity": "sha512-bs2yO68JDFOm6rKv8f0EnrM2cENduhRkpqOtt/s5l5JBA/eqGBZCzLPmdYoHtJ6utgLGgcBajFsEQbl12pT0lQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diagram-js-direct-editing/node_modules/min-dash": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/min-dash/-/min-dash-5.1.0.tgz", + "integrity": "sha512-HAvN6XzmCQj+js43G9sJRRw8kj/7DvoxN7qhoIN9Xo5ZN1NMljtpyKs/NunXmw0QSWwlgZzNv6tLeEa4xaZ0tg==", + "license": "MIT" + }, + "node_modules/diagram-js-direct-editing/node_modules/min-dom": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/min-dom/-/min-dom-5.3.0.tgz", + "integrity": "sha512-0w5FEBgPAyHhmFojW3zxd7we3D+m5XYS3E/06OyvxmbHJoiQVa4Nagj6RWvoAKYRw5xth6cP5TMePc5cR1M9hA==", + "license": "MIT", + "dependencies": { + "domify": "^3.0.0", + "min-dash": "^5.0.0" + } + }, + "node_modules/didi": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/didi/-/didi-10.2.2.tgz", + "integrity": "sha512-l8NYkYFXV1izHI65EyT8EXOjUZtKmQkHLTT89cSP7HU5J/G7AOj0dXKtLc04EXYlga99PBY18IPjOeZ+c3DI4w==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -4337,6 +4466,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/domify": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/domify/-/domify-1.4.2.tgz", + "integrity": "sha512-m4yreHcUWHBncGVV7U+yQzc12vIlq0jMrtHZ5mW6dQMiL/7skSYNVX9wqKwOtyO9SGCgevrAFEgOCAHmamHTUA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dompurify": { "version": "3.4.15", "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.15.tgz", @@ -5522,6 +5660,12 @@ "integrity": "sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==", "license": "Apache-2.0" }, + "node_modules/htm": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/htm/-/htm-3.1.1.tgz", + "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", + "license": "Apache-2.0" + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmmirror.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -5707,6 +5851,12 @@ "node": ">=0.10.0" } }, + "node_modules/ids": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/ids/-/ids-1.0.5.tgz", + "integrity": "sha512-XQ0yom/4KWTL29sLG+tyuycy7UmeaM/79GRtSJq6IG9cJGIPeBz5kwDCguie3TwxaMNIc3WtPi0cTa1XYHicpw==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", @@ -5796,6 +5946,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inherits-browser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/inherits-browser/-/inherits-browser-0.1.0.tgz", + "integrity": "sha512-CJHHvW3jQ6q7lzsXPpapLdMx5hDpSF3FSh45pwsj6bKxJJ8Nl8v43i5yXnr3BdfOimGHKyniewQtnAIp3vyJJw==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6997,6 +7153,23 @@ "node": ">=6" } }, + "node_modules/min-dash": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/min-dash/-/min-dash-4.2.3.tgz", + "integrity": "sha512-VLMYQI5+FcD9Ad24VcB08uA83B07OhueAlZ88jBK6PyupTvEJwllTMUqMy0wPGYs7pZUEtEEMWdHB63m3LtEcg==", + "license": "MIT" + }, + "node_modules/min-dom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/min-dom/-/min-dom-4.2.1.tgz", + "integrity": "sha512-TMoL8SEEIhUWYgkj7XMSgxmwSyGI+4fP2KFFGnN3FbHfbGHVdsLYSz8LoIsgPhz4dWRmLvxWWSMgzZMJW5sZuA==", + "license": "MIT", + "dependencies": { + "component-event": "^0.2.1", + "domify": "^1.4.1", + "min-dash": "^4.2.1" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", @@ -7063,6 +7236,26 @@ "random": "bin/random" } }, + "node_modules/moddle": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/moddle/-/moddle-6.2.3.tgz", + "integrity": "sha512-bLVN+ZHL3aKnhxc19XtjUfvdJsS3EsiEJC7bT6YPD11qYmTzvsxrGgyYz1Ouof7TZuGw0lDJ1OLmEnxcpQWk3Q==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0" + } + }, + "node_modules/moddle-xml": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/moddle-xml/-/moddle-xml-10.1.0.tgz", + "integrity": "sha512-erWckwLt+dYskewKXJso9u+aAZ5172lOiYxSOqKCPTy7L/xmqH1PoeoA7eVC7oJTt3PqF5TkZzUmbjGH6soQBg==", + "license": "MIT", + "dependencies": { + "min-dash": "^4.0.0", + "moddle": "^6.0.0", + "saxen": "^8.1.2" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz", @@ -7431,6 +7624,15 @@ "node": ">= 0.4" } }, + "node_modules/object-refs": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/object-refs/-/object-refs-0.4.0.tgz", + "integrity": "sha512-6kJqKWryKZmtte6QYvouas0/EIJKPI1/MMIuRsiBlNuhIMfqYTggzX2F1AJ2+cDs288xyi9GL7FyasHINR98BQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/object-visit": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/object-visit/-/object-visit-1.0.1.tgz", @@ -7727,6 +7929,15 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, + "node_modules/path-intersection": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/path-intersection/-/path-intersection-3.1.0.tgz", + "integrity": "sha512-3xS3lvv/vuwm5aH2BVvNRvnvwR2Drde7jQClKpCXTYXIMMjcw/EnMhzCgeHwqbCpzi760PEfAkU53vSIlrNr9A==", + "license": "MIT", + "engines": { + "node": ">= 14.20" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8649,6 +8860,12 @@ "node": ">=11.0.0" } }, + "node_modules/saxen": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/saxen/-/saxen-8.1.2.tgz", + "integrity": "sha512-xUOiiFbc3Ow7p8KMxwsGICPx46ZQvy3+qfNVhrkwfz3Vvq45eGt98Ft5IQaA1R/7Tb5B5MKh9fUR9x3c3nDTxw==", + "license": "MIT" + }, "node_modules/scroll-into-view-if-needed": { "version": "2.2.31", "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", @@ -9658,6 +9875,12 @@ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "license": "MIT" }, + "node_modules/tiny-svg": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/tiny-svg/-/tiny-svg-3.1.3.tgz", + "integrity": "sha512-9mwnPqXInRsBmH/DO6NMxBE++9LsqpVXQSSTZGc5bomoKKvL5OX/Hlotw7XVXP6XLRcHWIzZpxfovGqWKgCypQ==", + "license": "MIT" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz", diff --git a/package.json b/package.json index 82e6521..8b09ce1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@wangeditor/editor-for-vue": "^5.1.12", "alova": "^3.4.0", "axios": "^0.24.0", + "bpmn-js": "^17.11.1", "lodash": "^4.17.21", "mitt": "^3.0.1", "mockjs": "^1.1.0", diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index cdb8799..3f2c05e 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -30,6 +30,8 @@ export const TASK_STATUS: Record = { 1: "同意", 2: "拒绝", 3: "失效", + 4: "退回", + 5: "移交", }; class FlowApi { @@ -54,9 +56,17 @@ class FlowApi { taskDetail(taskId: number | string) { return get(`${ROOT}/task/${taskId}/detail`); } - /** 同意 */ - approve(taskId: number | string, opinion?: string) { - return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null }); + /** 同意(多出口节点必须传 edgeName=所选出口名,单出口可省) */ + approve(taskId: number | string, opinion?: string, edgeName?: string) { + return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null, edgeName: edgeName || null }); + } + /** 退回(targetNodeId 可选=缺省最近已办主线节点;opinion 可选) */ + returnBack(taskId: number | string, targetNodeId?: string, opinion?: string) { + return post(`${ROOT}/task/${taskId}/returnBack`, { targetNodeId: targetNodeId || null, opinion: opinion || null }); + } + /** 移交(toUserId 必填;reason 可选) */ + transfer(taskId: number | string, toUserId: number, reason?: string) { + return post(`${ROOT}/task/${taskId}/transfer`, { toUserId, reason: reason || null }); } /** 拒绝(opinion 必填) */ reject(taskId: number | string, opinion: string) { diff --git a/src/utils/flowLayout.ts b/src/utils/flowLayout.ts new file mode 100644 index 0000000..f5d021f --- /dev/null +++ b/src/utils/flowLayout.ts @@ -0,0 +1,68 @@ +/** + * 流程图自动布局(从 FlowDiagram.vue 提取的设计器/查看器共用实现): + * Kahn 拓扑分层 depth(to)=max(depth(from))+1,从左到右布列、同列按定义顺序纵排。 + * 设计器(contentJson 无 layout 坐标时算初始坐标)与查看器(无坐标回退)共用同一口径。 + */ + +export const SLOT_W = 200; // 节点槽位宽 +export const SLOT_H = 88; // 节点槽位高 +export const COL_GAP = 72; // 列间距(留给正交折线走线) +export const ROW_GAP = 24; // 行间距 +export const PAD = 24; + +export const NODE_BOX: Record = { + START: { w: 104, h: 40 }, + END: { w: 104, h: 40 }, + APPROVAL: { w: 184, h: 64 }, + PARALLEL_SPLIT: { w: 128, h: 72 }, + PARALLEL_JOIN: { w: 128, h: 72 }, +}; + +export interface Pos { + x: number; + y: number; + w: number; + h: number; + depth: number; + row: number; +} + +/** 拓扑分层布局:入参 {nodes,edges},返回 nodeId → 位置 */ +export function layout(data: any): Record { + const nodes: any[] = data?.nodes || []; + const edges: any[] = data?.edges || []; + if (!nodes.length) return {}; + const inDeg: Record = {}; + nodes.forEach((n) => { inDeg[n.id] = 0; }); + edges.forEach((e) => { if (inDeg[e.to] !== undefined) inDeg[e.to]++; }); + const depth: Record = {}; + const queue = nodes.filter((n) => !inDeg[n.id]).map((n) => n.id); + queue.forEach((id) => { depth[id] = 0; }); + const order = [...queue]; + while (order.length) { + const id = order.shift()!; + edges.forEach((e) => { + if (e.from !== id || inDeg[e.to] === undefined) return; + depth[e.to] = Math.max(depth[e.to] ?? 0, (depth[id] ?? 0) + 1); + if (--inDeg[e.to] === 0) order.push(e.to); + }); + } + const rowsIn: Record = {}; + const pos: Record = {}; + nodes.forEach((n) => { + const d = depth[n.id] ?? 0; + depth[n.id] = d; + const row = rowsIn[d] ?? 0; + rowsIn[d] = row + 1; + const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL; + pos[n.id] = { + x: PAD + d * (SLOT_W + COL_GAP) + (SLOT_W - box.w) / 2, + y: PAD + row * (SLOT_H + ROW_GAP) + (SLOT_H - box.h) / 2, + w: box.w, + h: box.h, + depth: d, + row, + }; + }); + return pos; +} diff --git a/src/views/flow/FlowDiagram.vue b/src/views/flow/FlowDiagram.vue index 4415dd2..9b57312 100644 --- a/src/views/flow/FlowDiagram.vue +++ b/src/views/flow/FlowDiagram.vue @@ -14,73 +14,57 @@ const svgRef = ref(null); const DEF_STATUS: Record = { 0: "草稿", 1: "已发布", 2: "停用" }; -// ==================== 布局常量(方案 §4.2/§4.3) ==================== -const SLOT_W = 200; // 节点槽位宽 -const SLOT_H = 88; // 节点槽位高 -const COL_GAP = 72; // 列间距(留给正交折线走线) -const ROW_GAP = 24; // 行间距 -const PAD = 24; +// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用) +import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout"; -const NODE_BOX: Record = { - START: { w: 104, h: 40 }, - END: { w: 104, h: 40 }, - APPROVAL: { w: 184, h: 64 }, - PARALLEL_SPLIT: { w: 128, h: 72 }, - PARALLEL_JOIN: { w: 128, h: 72 }, -}; - -interface Pos { - x: number; - y: number; - w: number; - h: number; - depth: number; - row: number; -} - -/** 拓扑分层布局:Kahn 消融入边,depth(to)=max(depth(from))+1;同列按定义顺序纵排 */ -function layout(data: any): Record { - const nodes: any[] = data?.nodes || []; - const edges: any[] = data?.edges || []; - if (!nodes.length) return {}; - const inDeg: Record = {}; - nodes.forEach((n) => { inDeg[n.id] = 0; }); - edges.forEach((e) => { if (inDeg[e.to] !== undefined) inDeg[e.to]++; }); - const depth: Record = {}; - const queue = nodes.filter((n) => !inDeg[n.id]).map((n) => n.id); - queue.forEach((id) => { depth[id] = 0; }); - const order = [...queue]; - while (order.length) { - const id = order.shift()!; - edges.forEach((e) => { - if (e.from !== id || inDeg[e.to] === undefined) return; - depth[e.to] = Math.max(depth[e.to] ?? 0, (depth[id] ?? 0) + 1); - if (--inDeg[e.to] === 0) order.push(e.to); +/** 位置计算:优先用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),缺坐标回退自动布局 */ +const positions = computed>(() => { + const nodes: any[] = vo.value?.nodes || []; + const edges: any[] = vo.value?.edges || []; + const saved = vo.value?.layout as Record | undefined; + if (saved && Object.keys(saved).length) { + const pos: Record = {}; + let complete = true; + nodes.forEach((n, i) => { + const p = saved[n.id]; + const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL; + if (!p) { + complete = false; + return; + } + // depth 供 vbSize 估算列号(x 换算);row 仅占位 + pos[n.id] = { x: p.x, y: p.y, w: box.w, h: box.h, depth: 0, row: i }; }); + if (complete) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + Object.values(pos).forEach((p) => { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); + }); + // 平移到与自动布局一致的留白(防设计器负坐标/超边界出血) + const dx = PAD - minX; + const dy = PAD - minY; + Object.values(pos).forEach((p) => { p.x += dx; p.y += dy; }); + vbExtent.w = maxX - minX + PAD + COL_GAP + SLOT_W; + vbExtent.h = maxY - minY + PAD + ROW_GAP + SLOT_H; + return pos; + } } - const rowsIn: Record = {}; - const pos: Record = {}; - nodes.forEach((n) => { - const d = depth[n.id] ?? 0; - depth[n.id] = d; - const row = rowsIn[d] ?? 0; - rowsIn[d] = row + 1; - const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL; - pos[n.id] = { - x: PAD + d * (SLOT_W + COL_GAP) + (SLOT_W - box.w) / 2, - y: PAD + row * (SLOT_H + ROW_GAP) + (SLOT_H - box.h) / 2, - w: box.w, - h: box.h, - depth: d, - row, - }; - }); - return pos; -} + vbExtent.w = 0; + vbExtent.h = 0; + return autoLayout({ nodes, edges }); +}); -const positions = computed>(() => layout(vo.value)); +/** 保存坐标模式的画布总尺寸(0=未启用,回退自动布局的行列估算) */ +const vbExtent = ref({ w: 0, h: 0 }); const vbSize = computed(() => { + if (vbExtent.value.w > 0) { + return { w: vbExtent.value.w, h: vbExtent.value.h }; + } const ps = Object.values(positions.value); if (!ps.length) return { w: 480, h: 160 }; let maxD = 0; @@ -116,6 +100,25 @@ function diamondPoints(n: any): string { return `${cx},${p.y} ${p.x + p.w},${cy} ${cx},${p.y + p.h} ${p.x},${cy}`; } +/** 节点运行时状态(instance 口径才有 state 字段) */ +function nodeState(n: any): string | null { + return (n.state as string) || null; +} + +const STATE_STROKE: Record = { + done: "#19be6b", + active: "#ed4014", + pending: "#dcdee2", + rejected: "#ed4014", +}; + +function edgeLabelAnchor(e: any) { + const a = positions.value[e.from]; + const b = positions.value[e.to]; + if (!a || !b) return null; + return { x: (a.x + a.w + b.x) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 }; +} + function trunc(s: string | null, n: number): string { if (!s) return ""; return s.length > n ? s.slice(0, n) + "…" : s; @@ -230,18 +233,37 @@ watch(() => route.query, () => load()); + + + @@ -184,6 +292,11 @@ onMounted(load); .biz-link { margin-top: 8px; } .actions { margin-top: 10px; display: flex; gap: 8px; } .muted { color: #999; } +.outlet { margin-bottom: 8px; display: flex; align-items: center; } +.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; } +.modal-row { display: flex; align-items: center; margin-bottom: 8px; } +.modal-label { width: 110px; color: #515a6e; font-size: 13px; flex-shrink: 0; } +.modal-tip { font-size: 12px; } .node { font-weight: 600; margin-right: 6px; } .opinion { color: #666; } .time { color: #aaa; font-size: 12px; } diff --git a/src/views/flow/admin/def-editor/bpmn/custom.ts b/src/views/flow/admin/def-editor/bpmn/custom.ts new file mode 100644 index 0000000..c696e63 --- /dev/null +++ b/src/views/flow/admin/def-editor/bpmn/custom.ts @@ -0,0 +1,101 @@ +/** + * bpmn-js 定制模块:调色板/上下文垫收敛为 4 类业务节点 + 汉化。 + * 拖拽/多选/撤销重做/缩放平移均为 bpmn-js 内建能力,此处无需实现。 + * 调色板与上下文垫只暴露业务语义的 5 类元素(开始/结束/审批/并行分发/并行合并用同一 ParallelGateway, + * split/join 由 serialize.ts 以扩展属性 flowNodeType 显式标记),其余 BPMN 形状对管理员隐藏。 + * bpmn.io 水印为协议要求,不做隐藏。 + */ +import Modeler from "bpmn-js/lib/Modeler"; +import PaletteProvider from "bpmn-js/lib/features/palette/PaletteProvider"; + +/** 汉化词表(palette/contextPad/常用按钮;未命中键原样透回) */ +const TRANSLATIONS: Record = { + "Create StartEvent": "创建开始", + "Create EndEvent": "创建结束", + "Create Task": "创建审批节点", + "Create User Task": "创建审批节点", + "Create Gateway": "创建并行分支", + "Create Gateways": "创建并行分支", + "Create ParallelGateway": "创建并行分支", + "Create expanded ParallelGateway": "创建并行分支", + "Append anything": "追加", + "Change type": "变更类型", + "Remove element": "删除", + Connect: "连线", + "Connect to other element": "连线到其他元素", + "Connect using Association/Sequence/DataAssociation": "连线", + "Activate the global connect tool": "激活全局连线工具", + "Activate the create/remove space tool": "激活空间调整工具", + "Activate the lasso tool": "激活套索选择", + "Activate the hand tool": "激活平移", + "Activate the direct editing tool": "激活文本编辑", + undo: "撤销", + redo: "重做", +}; + +function createTranslateModule() { + return { + translate: [ + "value", + (template: string, replacements?: Record) => { + let t = TRANSLATIONS[template] || template; + if (replacements) { + for (const [k, v] of Object.entries(replacements)) { + t = t.replace(`{${k}}`, v); + } + } + return t; + }, + ], + }; +} + +/** 调色板收敛:仅连线工具 + 4 类节点 */ +function createPaletteModule() { + function CustomPaletteProvider(this: unknown, palette: unknown, create: unknown, elementFactory: unknown, + globalConnect: unknown, translate: unknown) { + // @ts-expect-error 继承自 bpmn-js 内部父类(js 库,无类型面板) + PaletteProvider.call(this, palette, create, elementFactory, globalConnect, translate); + this.create = create; + this.elementFactory = elementFactory; + this.globalConnect = globalConnect; + this.translate = translate; + } + CustomPaletteProvider.$inject = ["palette", "create", "elementFactory", "globalConnect", "translate"]; + CustomPaletteProvider.prototype = Object.create(PaletteProvider.prototype); + CustomPaletteProvider.prototype.getPaletteEntries = function (this: { elementFactory: unknown } & Record) { + const { create, elementFactory, translate, globalConnect } = this as Record; + const action = (type: string, label: string, className: string) => ({ + group: "flow", + className, + title: translate(label), + action: { + dragstart: (evt: unknown) => create.start(evt, elementFactory.createShape({ type })), + click: (evt: unknown) => create.start(evt, elementFactory.createShape({ type })), + }, + }); + return { + "lc-connect": { + group: "tools", + className: "bpmn-icon-connection-multi", + title: translate("连线"), + action: { click: (evt: unknown) => globalConnect.toggle(evt) }, + }, + "lc-sep": { group: "tools", separator: true }, + "lc-start": action("bpmn:StartEvent", "创建开始", "bpmn-icon-start-event-none"), + "lc-end": action("bpmn:EndEvent", "创建结束", "bpmn-icon-end-event-none"), + "lc-task": action("bpmn:UserTask", "创建审批节点", "bpmn-icon-user-task"), + "lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"), + }; + }; + return { __init__: ["paletteProvider"], paletteProvider: ["type", CustomPaletteProvider] }; +} + +/** 创建设计器实例(调色板收敛 + 汉化;快捷键绑定 window:Ctrl+Z 撤销等) */ +export function createDesigner(container: HTMLElement): Modeler { + return new Modeler({ + container, + keyboard: { bindTo: window }, + additionalModules: [createPaletteModule(), createTranslateModule()], + }); +} diff --git a/src/views/flow/admin/def-editor/bpmn/serialize.ts b/src/views/flow/admin/def-editor/bpmn/serialize.ts new file mode 100644 index 0000000..8cc5176 --- /dev/null +++ b/src/views/flow/admin/def-editor/bpmn/serialize.ts @@ -0,0 +1,164 @@ +/** + * contentJson(+layout) ⇄ bpmn-js 内存图 双向转换(画布模式数据通道)。 + * + * 设计决策(方案 §二):BPMN XML 不作为持久化格式——contentJson 仍是唯一运行时格式, + * bpmn-js 仅作内存画布。节点映射:START→StartEvent / END→EndEvent / APPROVAL→UserTask / + * PARALLEL_SPLIT|JOIN→ParallelGateway,用扩展属性 flowNodeType 显式区分 split/join + * (不靠出入度推断,用户编辑连线后语义不漂移);出边 name 即多出口按钮文案。 + * 坐标缺失(旧定义无 layout)时用共享 flowLayout 算初始坐标。 + */ +import { layout as autoLayout } from "@/utils/flowLayout"; + +/** bpmn 画布尺寸(与 flowLayout 的槽位框不同——bpmn 内建元素有自己尺寸,就不指定 shape 尺寸) */ +const CANVAS_GAP_X = 96; +const CANVAS_ROW_H = 96; +const CANVAS_START = { x: 160, y: 160 }; + +/** 画布模式读写接口(与后端 contentJson 同构,另含可选 layout 坐标) */ +export interface CanvasJson { + code: string; + name: string; + start: string; + nodes: Array>; + edges: Array>; + settings: Record; + layout?: Record; +} + +const BPMN_TYPE: Record = { + START: "bpmn:StartEvent", + END: "bpmn:EndEvent", + APPROVAL: "bpmn:UserTask", + PARALLEL_SPLIT: "bpmn:ParallelGateway", + PARALLEL_JOIN: "bpmn:ParallelGateway", +}; + +/** 把 contentJson 画到画布上(缺 layout 时自动布局) */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function toBpmn(modeler: any, def: CanvasJson): void { + const nodes = def.nodes || []; + const edges = def.edges || []; + // 自动布局:有保存坐标用保存坐标,否则 Kahn 分层换算为 bpmn 网格 + let pos: Record = def.layout || (undefined as never); + if (!pos || !Object.keys(pos).length) { + const auto = autoLayout({ nodes, edges }); + pos = {}; + Object.entries(auto).forEach(([id, p]) => { + pos![id] = { x: CANVAS_START.x + p.x + p.w / 2, y: CANVAS_START.y + p.y + p.h / 2 }; + }); + } + const modeling = modeler.get("modeling"); + const canvas = modeler.get("canvas"); + const root = canvas.getRootElement(); + const byId: Record = {}; + nodes.forEach((n) => { + const type = BPMN_TYPE[String(n.type)]; + if (!type) return; // 未知类型不渲染(local validate 会提示) + const p = pos[String(n.id)] || CANVAS_START; + const shape = modeling.createShape({ type }, { x: p.x, y: p.y }, root); + // id 必须显式落 businessObject:配置回填与跨会话稳定绑定都按 contentJson 的节点 id 走 + // (若不设,bpmn-js 生成 Task_xxx 随机 id,二次载入时审批人/会签等配置将失配丢失) + // 节点名(直编可改);split/join 语义由「载入显式类型 / 读出按出入度」两段规则维持,存储在 contentJson + modeling.updateProperties(shape, { id: String(n.id), name: String(n.name || "") }); + byId[String(n.id)] = shape; + }); + edges.forEach((e) => { + const src = byId[String(e.from)]; + const tgt = byId[String(e.to)]; + if (!src || !tgt) return; + // connect(source, target) 按规则自动建 SequenceFlow + const conn = modeling.connect(src, tgt); + if (e.name) { + modeling.updateProperties(conn, { name: String(e.name) }); + } + }); +} + +/** bpmn 内存图 → {nodes,edges,layout}:遍历 elementRegistry 序列化回 contentJson 主体 */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: string): CanvasJson { + const registry = modeler.get("elementRegistry"); + const all = registry.getAll(); + const nodes: Array> = []; + const edges: Array> = []; + const layoutOut: Record = {}; + let startId = ""; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodeTypeOf = (n: any): string | null => { + switch (n.type) { + case "bpmn:StartEvent": { + return "START"; + } + case "bpmn:EndEvent": { + return "END"; + } + case "bpmn:UserTask": { + return "APPROVAL"; + } + case "bpmn:ParallelGateway": { + // 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型) + return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT"; + } + default: + return null; + } + }; + // 节点业务配置(审批人等)以 base 内容继承,这里只做几何/连线 + all.forEach((n: any) => { + const type = nodeTypeOf(n); + if (!type) return; + const biz = n.businessObject || {}; + const node: Record = { + id: String(biz.id || n.id), + type, + }; + if (biz.name) node.name = biz.name; + if (type === "APPROVAL" && base) { + // 审批人等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置) + const prev = base.nodes.find((x) => String(x.id) === String(node.id)); + if (prev) { + ["assignee", "countersign", "deadlineHours"].forEach((k) => { + if (prev[k] !== undefined) node[k] = prev[k]; + }); + if (!node.name && prev.name) node.name = prev.name; + } + } + nodes.push(node); + layoutOut[String(node.id)] = { x: Math.round(n.x), y: Math.round(n.y) }; + if (type === "START") startId = String(node.id); + }); + all.forEach((n: any) => { + (n.outgoing || []).forEach((c: any) => { + if (c.type !== "bpmn:SequenceFlow") return; + const target = c.target; + if (!target) return; + const edge: Record = { + from: String(n.businessObject.id || n.id), + to: String(target.businessObject.id || target.id), + }; + if (c.businessObject?.name) edge.name = c.businessObject.name; + edges.push(edge); + }); + }); + if (!startId && nodes.length) { + throw new Error("画布缺少开始节点"); + } + // 已有定义继承 settings/审批配置由 base 传入 + return { + code, + name, + start: startId, + nodes, + edges, + settings: base?.settings || { rejectPolicy: "END_REJECTED" }, + layout: layoutOut, + }; +} + +/** contentJson → CanvasJson(对既有 struct 直接加 layout) */ +export function toCanvasJson(contentJson: string | CanvasJson): CanvasJson { + if (typeof contentJson === "string") { + return JSON.parse(contentJson) as CanvasJson; + } + return contentJson; +} diff --git a/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue b/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue index aae03a9..4a28c5b 100644 --- a/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue +++ b/src/views/flow/admin/def-editor/components/ApprovalDrawer.vue @@ -188,10 +188,7 @@ function onRemove(): void { 或签(一人同意)
- 会签:每名办理人都收到任务,全部同意才通过;或签:任一人办理即通过 -
-
- ⚠ 当前引擎尚未实现「或签」放行:节点仍会等全部办理人同意后才推进(该语义待后续版本生效) + 会签:每名办理人都收到任务,全部同意才通过;或签:任一人办理即通过,其余待办自动失效
diff --git a/src/views/flow/admin/def-editor/components/BpmnCanvas.vue b/src/views/flow/admin/def-editor/components/BpmnCanvas.vue new file mode 100644 index 0000000..d137c83 --- /dev/null +++ b/src/views/flow/admin/def-editor/components/BpmnCanvas.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/src/views/flow/admin/def-editor/index.vue b/src/views/flow/admin/def-editor/index.vue index 5ed2af3..daa79dc 100644 --- a/src/views/flow/admin/def-editor/index.vue +++ b/src/views/flow/admin/def-editor/index.vue @@ -31,6 +31,7 @@ import NodeList from "./components/NodeList.vue"; import ApprovalDrawer from "./components/ApprovalDrawer.vue"; import SettingsDrawer from "./components/SettingsDrawer.vue"; import JsonDrawer from "./components/JsonDrawer.vue"; +import BpmnCanvas from "./components/BpmnCanvas.vue"; const route = useRoute(); const router = useRouter(); @@ -53,6 +54,64 @@ const depts = reactive([]); /** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */ const userNames = reactive>({}); +// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ==================== +/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */ +const editMode = ref<"canvas" | "cards">("canvas"); +const bpmnWrap = ref | null>(null); +/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */ +const canvasBase = ref(null); +/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */ +const canvasConfigs = reactive>({}); +/** 原始 contentJson 快照(画布脏检查基准) */ +const sourceJson = ref(""); + +function onCanvasNode(_el: any): void { + // selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉 + if (!_el || _el.type !== "bpmn:UserTask") return; + const id = String(_el.businessObject.id || _el.id); + const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {}; + if (!canvasConfigs[id]) { + canvasConfigs[id] = { + uid: id, + kind: "approval", + name: _el.businessObject.name || baseNode.name || "审批", + assignee: baseNode.assignee || null, + countersign: baseNode.countersign || "ALL", + deadlineHours: baseNode.deadlineHours || null, + }; + } + editingUid.value = id; + nodeDrawer.value = true; +} + +function canvasRemoveNode(uid: string): void { + bpmnWrap.value?.removeNode(uid); + delete canvasConfigs[uid]; +} + +/** 画布模式序列化:画布 → contentJson(叠加 code/name/settings 与审批配置) */ +function serializeCanvas(): string { + const canvasJson = bpmnWrap.value?.serialize( + canvasBase.value, + model.code.trim(), + model.name.trim() + ); + if (!canvasJson) return ""; + // 配置并入:canvasConfigs 以 element id 为键覆盖审批节点业务属性 + canvasJson.nodes.forEach((n: any) => { + const cfg = canvasConfigs[String(n.id)]; + if (cfg && n.type === "APPROVAL") { + n.name = cfg.name || n.name; + if (cfg.assignee) n.assignee = cfg.assignee; + if (cfg.countersign && cfg.countersign !== "ALL") n.countersign = cfg.countersign; + if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours; + } + }); + canvasJson.settings = { rejectPolicy: "END_REJECTED" }; + void model.settings; // 设置抽屉暂绑定卡模型(画布模式直接采用卡模型设置) + return JSON.stringify(canvasJson); +} + const issues = computed(() => validateModel(model, depts)); const defJson = computed(() => toContentJson(model)); @@ -67,6 +126,9 @@ const jsonDrawer = ref(false); const editingUid = ref(""); const editingNode = computed(() => { + if (editMode.value === "canvas") { + return canvasConfigs[editingUid.value] || null; + } const item = editingUid.value ? findItem(model, editingUid.value) : null; return item && item.kind === "approval" ? item : null; }); @@ -137,10 +199,20 @@ async function load(): Promise { sourceVersion.value = d?.version ?? 0; model.code = d?.code || ""; model.name = d?.name || ""; + sourceJson.value = d?.contentJson || "{}"; + try { + canvasBase.value = fromContentJson(JSON.parse(d?.contentJson || "{}")) as any; + } catch { + // 解析失败仍保留原始 JSON 供画布直接读取(fromContentJson 卡片树可能不支持) + canvasBase.value = JSON.parse(d?.contentJson || "{}"); + } try { applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}"))); } catch (e: any) { - Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请改用源码模式编辑。`); + // 画布模式可编辑管线不支持的结构(多出口等),不再强制卡片树还原 + if (editMode.value === "cards") { + Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请切换画布模式编辑。`); + } } await resolveUserNames(); savedSnapshot.value = currentSnapshot.value; @@ -171,6 +243,10 @@ function openNode(uid: string): void { } function removeNode(uid: string): void { + if (editMode.value === "canvas") { + canvasRemoveNode(uid); + return; + } const siblings = containerOfItem(uid); if (siblings && siblings.length <= 1) { Message.warning("每个流程/分支至少保留一个节点"); @@ -236,10 +312,25 @@ async function onSave(): Promise { return; } const errors = countErrors(issues.value); - if (errors.length) { + if (errors.length && editMode.value === "cards") { Message.error(`校验未通过:${errors[0].text}`); return; } + let content: string; + if (editMode.value === "canvas") { + content = serializeCanvas(); + const problems = checkCanvas(content); + if (problems.length) { + Message.error(problems[0]); + return; + } + if (!content || content === "{}") { + Message.warning("画布为空,无法保存"); + return; + } + } else { + content = JSON.stringify(toContentJson(model)); + } saving.value = true; try { // code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑 @@ -247,7 +338,7 @@ async function onSave(): Promise { id: editId.value || undefined, code: model.code.trim(), name: model.name.trim(), - contentJson: JSON.stringify(toContentJson(model)), + contentJson: content, baseGuid: baseGuid.value || undefined, }).send(); savedSnapshot.value = currentSnapshot.value; @@ -258,6 +349,72 @@ async function onSave(): Promise { } } +/** 画布产物轻校验(保存前拦截;结构完整校验仍以发布期 DefValidator 为准) */ +function checkCanvas(content: string): string[] { + const problems: string[] = []; + let def: any; + try { + def = JSON.parse(content); + } catch { + return ["画布数据序列化失败,请重试"]; + } + const nodes = def.nodes || []; + const edges = def.edges || []; + const nodeIds = new Set(nodes.map((n: any) => n.id)); + if (nodes.filter((n: any) => n.type === "START").length !== 1) { + problems.push("画布必须有且仅有 1 个开始节点"); + } + if (nodes.filter((n: any) => n.type === "END").length !== 1) { + problems.push("画布必须有且仅有 1 个结束节点"); + } + nodes.forEach((n: any) => { + if (n.type === "APPROVAL") { + if (!n.name) problems.push("审批节点未命名"); + if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置"); + } + }); + edges.forEach((e: any) => { + if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) { + problems.push("存在指向缺失节点的连线,请放置后重画"); + } + }); + // 多出口命名(镜像后端 DefValidator.validateOutlets 口径) + const outNames: Record> = {}; + edges.forEach((e: any) => { + (outNames[e.from] = outNames[e.from] || new Set()).add(e.name || ""); + }); + nodes.forEach((n: any) => { + const outs = edges.filter((e: any) => e.from === n.id); + if (n.type === "APPROVAL" && outs.length > 1) { + outs.forEach((e: any) => { + if (!e.name?.trim()) problems.push(`多出口节点「${n.name || n.id}」每个出口必须命名(按钮文案)`); + }); + outNames[n.id]?.forEach((x) => { + // 重复名展示一次即可 + }); + } + }); + // 可达性(simple BFS from start,无孤儿) + const start = def.start; + if (start) { + const seen = new Set([start]); + const queue = [start]; + while (queue.length) { + const cur = queue.shift()!; + edges.forEach((e: any) => { + if (e.from === cur && nodeIds.has(e.to) && !seen.has(e.to)) { + seen.add(e.to); + queue.push(e.to); + } + }); + } + nodes.forEach((n: any) => { + if (!seen.has(n.id)) problems.push(`从开始不可达的节点:${n.name || n.id}`); + }); + } + return [...new Set(problems)]; +} + function onOpenDiagram(): void { if (!editId.value) return; const href = router.resolve({ path: "/diagram", query: { defId: String(editId.value), mode: "auto" } }).href; @@ -332,6 +489,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
+ + 画布 + 卡片 + @@ -339,7 +500,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
-
+ + + +
点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)
发起开始
@@ -347,7 +512,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
流程结束
-
+