- def-editor 新增画布模式(默认):bpmn-js 内存画布(精简 palette/汉化/撤销重做/缩放/出口命名/导出 BPMN), toBpmn/fromBpmn 双向序列化,contentJson 仍为持久格式(+layout 坐标);卡片模式保留回退 - 新增 utils/flowLayout.ts 分层布局(设计器与查看器共用) - TaskHandle:多出口节点出口选择下拉;退回/移交按钮与弹窗;轨迹显示已退回/已移交及所选出口 - FlowDiagram:优先使用保存坐标;边画出口名标签;办件状态着色/当前节点高亮 - api/flow:approve 带 edgeName,新增 returnBack/transfer
This commit is contained in:
Generated
+225
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-3
@@ -30,6 +30,8 @@ export const TASK_STATUS: Record<number, string> = {
|
||||
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) {
|
||||
|
||||
@@ -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<string, { w: number; h: number }> = {
|
||||
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<string, Pos> {
|
||||
const nodes: any[] = data?.nodes || [];
|
||||
const edges: any[] = data?.edges || [];
|
||||
if (!nodes.length) return {};
|
||||
const inDeg: Record<string, number> = {};
|
||||
nodes.forEach((n) => { inDeg[n.id] = 0; });
|
||||
edges.forEach((e) => { if (inDeg[e.to] !== undefined) inDeg[e.to]++; });
|
||||
const depth: Record<string, number> = {};
|
||||
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<number, number> = {};
|
||||
const pos: Record<string, Pos> = {};
|
||||
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;
|
||||
}
|
||||
@@ -14,73 +14,57 @@ const svgRef = ref<SVGSVGElement | null>(null);
|
||||
|
||||
const DEF_STATUS: Record<number, string> = { 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<string, { w: number; h: number }> = {
|
||||
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<string, Pos> {
|
||||
const nodes: any[] = data?.nodes || [];
|
||||
const edges: any[] = data?.edges || [];
|
||||
if (!nodes.length) return {};
|
||||
const inDeg: Record<string, number> = {};
|
||||
nodes.forEach((n) => { inDeg[n.id] = 0; });
|
||||
edges.forEach((e) => { if (inDeg[e.to] !== undefined) inDeg[e.to]++; });
|
||||
const depth: Record<string, number> = {};
|
||||
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<number, number> = {};
|
||||
/** 位置计算:优先用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),缺坐标回退自动布局 */
|
||||
const positions = computed<Record<string, Pos>>(() => {
|
||||
const nodes: any[] = vo.value?.nodes || [];
|
||||
const edges: any[] = vo.value?.edges || [];
|
||||
const saved = vo.value?.layout as Record<string, { x: number; y: number }> | undefined;
|
||||
if (saved && Object.keys(saved).length) {
|
||||
const pos: Record<string, Pos> = {};
|
||||
nodes.forEach((n) => {
|
||||
const d = depth[n.id] ?? 0;
|
||||
depth[n.id] = d;
|
||||
const row = rowsIn[d] ?? 0;
|
||||
rowsIn[d] = row + 1;
|
||||
let complete = true;
|
||||
nodes.forEach((n, i) => {
|
||||
const p = saved[n.id];
|
||||
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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
vbExtent.w = 0;
|
||||
vbExtent.h = 0;
|
||||
return autoLayout({ nodes, edges });
|
||||
});
|
||||
|
||||
const positions = computed<Record<string, Pos>>(() => 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<string, string> = {
|
||||
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());
|
||||
<path v-for="(e, i) in vo.edges" :key="'edge-' + i" :d="edgePath(e)"
|
||||
fill="none" stroke="#515a6e" stroke-width="1.5" marker-end="url(#flow-arrow)" />
|
||||
|
||||
<!-- 多出口连线标签(白底保证折线上可读) -->
|
||||
<template v-for="(e, i) in vo.edges" :key="'edge-label-' + i">
|
||||
<template v-if="e.name && edgeLabelAnchor(e)">
|
||||
<g>
|
||||
<rect :x="edgeLabelAnchor(e)!.x - e.name.length * 6 - 6"
|
||||
:y="edgeLabelAnchor(e).y - 9"
|
||||
:width="e.name.length * 12 + 12" height="18"
|
||||
rx="4" fill="#fff" stroke="#e8eaec" />
|
||||
<text :x="edgeLabelAnchor(e).x" :y="edgeLabelAnchor(e).y + 4"
|
||||
text-anchor="middle" fill="#515a6e" font-size="11">{{ e.name }}</text>
|
||||
</g>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-for="n in vo.nodes" :key="n.id">
|
||||
<!-- START/END:胶囊 -->
|
||||
<g v-if="n.type === 'START' || n.type === 'END'">
|
||||
<rect :x="pos(n).x" :y="pos(n).y" :width="pos(n).w" :height="pos(n).h"
|
||||
:rx="pos(n).h / 2" :fill="n.type === 'START' ? '#19be6b' : '#808695'" />
|
||||
:rx="pos(n).h / 2"
|
||||
:fill="nodeState(n) === 'active' ? '#808695'
|
||||
: n.type === 'START' ? '#19be6b' : '#808695'" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||
text-anchor="middle" fill="#fff" font-size="13">{{ n.label }}</text>
|
||||
</g>
|
||||
<!-- SPLIT/JOIN:菱形 -->
|
||||
<g v-else-if="n.type === 'PARALLEL_SPLIT' || n.type === 'PARALLEL_JOIN'">
|
||||
<title>{{ n.subLabel || n.label }}</title>
|
||||
<polygon :points="diamondPoints(n)" fill="#fff8f0" stroke="#ff9900" stroke-width="1.5" />
|
||||
<polygon :points="diamondPoints(n)"
|
||||
:fill="STATE_STROKE[nodeState(n) || ''] ? '#fff8f0' : '#fff8f0'"
|
||||
:stroke="nodeState(n) === 'active' ? '#ed4014' : '#ff9900'"
|
||||
stroke-width="1.5" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||
text-anchor="middle" fill="#e46a0a" font-size="12">{{ n.label }}</text>
|
||||
</g>
|
||||
@@ -249,7 +271,11 @@ watch(() => route.query, () => load());
|
||||
<g v-else>
|
||||
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
||||
<rect :x="pos(n).x" :y="pos(n).y" :width="pos(n).w" :height="pos(n).h"
|
||||
rx="8" fill="#fff" stroke="#2d8cf0" stroke-width="1.5" />
|
||||
rx="8"
|
||||
:class="{ 'node-active': nodeState(n) === 'active' }"
|
||||
:fill="nodeState(n) === 'rejected' ? '#fff1f0' : '#fff'"
|
||||
:stroke="STATE_STROKE[nodeState(n) || ''] || '#2d8cf0'"
|
||||
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
|
||||
text-anchor="middle" fill="#17233d" font-size="13" font-weight="600">
|
||||
{{ trunc(n.label, 10) }}
|
||||
@@ -273,4 +299,10 @@ watch(() => route.query, () => load());
|
||||
.muted { color: #999; font-size: 12px; }
|
||||
.canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
|
||||
.empty { padding: 40px; text-align: center; }
|
||||
/* 当前在办节点呼吸高亮 */
|
||||
.node-active { animation: nodePulse 1.6s ease-in-out infinite; }
|
||||
@keyframes nodePulse {
|
||||
0%, 100% { filter: none; }
|
||||
50% { filter: drop-shadow(0 0 6px rgba(237, 64, 20, 0.8)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,16 @@ const myTask = ref<any>(null);
|
||||
const opinion = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
// ===== 去向/退回/移交(本轮新增能力) =====
|
||||
/** 多出口节点可选项(detail.outlets,后端按本人待办节点的出边解析;单出口为空数组) */
|
||||
const outlets = computed<any[]>(() => detail.value?.outlets || []);
|
||||
const chosenEdge = ref("");
|
||||
const showReturn = ref(false);
|
||||
const returnTarget = ref("");
|
||||
const showTransfer = ref(false);
|
||||
const transferUser = ref("");
|
||||
const transferName = ref("");
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
@@ -85,7 +95,14 @@ async function submit(approved: boolean) {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (approved) {
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined).send();
|
||||
// 多出口节点必须带所选出口
|
||||
const edgeName = outlets.value.length > 1 ? chosenEdge.value : undefined;
|
||||
if (outlets.value.length > 1 && !edgeName) {
|
||||
Message.warning("该节点有多个办理去向,请先选择");
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
||||
Message.success("已同意");
|
||||
} else {
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||
@@ -98,12 +115,57 @@ async function submit(approved: boolean) {
|
||||
}
|
||||
|
||||
function confirmApprove() {
|
||||
if (outlets.value.length > 1 && !chosenEdge.value) {
|
||||
Message.warning("该节点有多个办理去向,请先选择");
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: "同意该任务?",
|
||||
onOk: () => submit(true),
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 退回 =====
|
||||
function openReturn() {
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
returnTarget.value = targets.length ? String(targets[targets.length - 1]?.nodeId || "") : "";
|
||||
showReturn.value = true;
|
||||
}
|
||||
|
||||
function doReturn() {
|
||||
submitting.value = true;
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
|
||||
targets[targets.length - 1] || null;
|
||||
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined)
|
||||
.send()
|
||||
.then(() => {
|
||||
Message.success("已退回");
|
||||
showReturn.value = false;
|
||||
router.push("/workbench");
|
||||
})
|
||||
.finally(() => { submitting.value = false; });
|
||||
}
|
||||
|
||||
// ===== 移交 =====
|
||||
async function doTransfer() {
|
||||
const uid = Number(transferUser.value);
|
||||
if (!uid || uid <= 0) {
|
||||
Message.warning("请输入有效的移交对象工号");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
|
||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
||||
Message.success("已移交");
|
||||
showTransfer.value = false;
|
||||
router.push("/workbench");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -147,10 +209,20 @@ onMounted(load);
|
||||
<!-- 办理区 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>办理意见</template>
|
||||
<div class="outlet" v-if="myTaskId && outlets.length > 1">
|
||||
<span class="outlet-label">办理去向:</span>
|
||||
<Select v-model="chosenEdge" size="small" style="width: 220px" placeholder="请选择办理去向">
|
||||
<Option v-for="o in outlets" :key="o.name" :value="o.name" :label="o.name">
|
||||
{{ o.name }}({{ o.toNodeName }})
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
||||
placeholder="同意可选填意见;拒绝必须填写意见" />
|
||||
<div class="actions" v-if="myTaskId">
|
||||
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||
<Button :loading="submitting" @click="openReturn">退回</Button>
|
||||
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||
</div>
|
||||
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</div>
|
||||
@@ -161,14 +233,50 @@ onMounted(load);
|
||||
<template #title>流程轨迹</template>
|
||||
<Timeline>
|
||||
<TimelineItem v-for="t in detail.trace" :key="t.taskId"
|
||||
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : 'gray'">
|
||||
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : t.status === 4 ? 'orange' : t.status === 5 ? 'cyan' : 'gray'">
|
||||
<span class="node">{{ t.nodeName || t.nodeId }}</span>
|
||||
<Tag size="small">{{ t.assigneeName || t.assigneeId }}</Tag>
|
||||
<Tag size="small" v-if="t.status === 4" color="orange">已退回</Tag>
|
||||
<Tag size="small" v-if="t.status === 5" color="cyan">已移交</Tag>
|
||||
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
|
||||
<span class="opinion" v-if="t.edgeName">去向:{{ t.edgeName }}</span>
|
||||
<div class="time">{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
<Button size="small" ghost type="primary"
|
||||
@click="router.push(`/diagram?instanceId=${detail.instanceId}`)">查看流程图</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗 -->
|
||||
<Modal v-model="showReturn" title="退回" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">退回至:</span>
|
||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||
placeholder="缺省=最近一个已办节点">
|
||||
<Option value="">(最近一个已办节点)</Option>
|
||||
<Option v-for="t in detail.returnTargets || []" :key="t.nodeId" :value="t.nodeId"
|
||||
:label="t.nodeName || t.nodeId" />
|
||||
</Select>
|
||||
</div>
|
||||
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</div>
|
||||
<template #footer>
|
||||
<Button @click="showReturn = false">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 移交弹窗 -->
|
||||
<Modal v-model="showTransfer" title="移交" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">移交对象工号:</span>
|
||||
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
||||
</div>
|
||||
<div class="muted modal-tip">移交后由对方接手本任务办理;意见可在下方填写交接说明。</div>
|
||||
<template #footer>
|
||||
<Button @click="showTransfer = false">取消</Button>
|
||||
<Button type="primary" :loading="submitting" @click="doTransfer">确认移交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
<div v-else class="page muted">加载中…</div>
|
||||
</template>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<string, string>) => {
|
||||
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<string, unknown>) {
|
||||
const { create, elementFactory, translate, globalConnect } = this as Record<string, any>;
|
||||
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()],
|
||||
});
|
||||
}
|
||||
@@ -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<Record<string, unknown>>;
|
||||
edges: Array<Record<string, unknown>>;
|
||||
settings: Record<string, unknown>;
|
||||
layout?: Record<string, { x: number; y: number }>;
|
||||
}
|
||||
|
||||
const BPMN_TYPE: Record<string, string> = {
|
||||
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<string, { x: number; y: number }> = 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<string, unknown> = {};
|
||||
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<Record<string, unknown>> = [];
|
||||
const edges: Array<Record<string, unknown>> = [];
|
||||
const layoutOut: Record<string, { x: number; y: number }> = {};
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
@@ -188,10 +188,7 @@ function onRemove(): void {
|
||||
<Radio label="ANY">或签(一人同意)</Radio>
|
||||
</RadioGroup>
|
||||
<div class="hint">
|
||||
会签:每名办理人都收到任务,全部同意才通过;或签:任一人办理即通过
|
||||
</div>
|
||||
<div v-if="form.countersign === 'ANY'" class="hint warn">
|
||||
⚠ 当前引擎尚未实现「或签」放行:节点仍会等全部办理人同意后才推进(该语义待后续版本生效)
|
||||
会签:每名办理人都收到任务,全部同意才通过;或签:任一人办理即通过,其余待办自动失效
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
// bpmn-js 画布(方案 §C):拖拽建模器,仅暴露业务 5 类元素;contentJson 为唯一持久化格式,
|
||||
// 本组件负责画布初始化、按名路由的出口命名、以及 fromBpmn 序列化。
|
||||
// bpmn.io 水印为协议要求(左下角 "Powered by bpmn.io"),勿删。
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { createDesigner } from "../bpmn/custom";
|
||||
import { toBpmn, fromBpmn, CanvasJson } from "../bpmn/serialize";
|
||||
|
||||
const props = defineProps<{
|
||||
/** contentJson 字符串;空 = 新建骨架 */
|
||||
source: string;
|
||||
}>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const emit = defineEmits<{ dirty: []; nodeConfig: [any, any]; edgeBusy: [] }>();
|
||||
|
||||
const wrap = ref<HTMLDivElement | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let modeler: any = null;
|
||||
|
||||
/** 选中元素(审批节点弹配置抽屉 / 连线弹命名) */
|
||||
const selected = ref<any>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (!wrap.value) return;
|
||||
modeler = createDesigner(wrap.value);
|
||||
// 选中级联:parent emit 让外层判断(APPROVAL → 打开配置抽屉;连线 → 预留命名按钮)
|
||||
modeler.on("selection.changed", (_e: unknown) => {
|
||||
const sel = modeler.get("selection").get();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
selected.value = sel.length === 1 ? sel[0] : null;
|
||||
emit("nodeConfig", selected.value, null);
|
||||
});
|
||||
modeler.on("connectivity-rule-violation", (evt: any) => Message.warning(String(evt?.message || "连线规则不允许")));
|
||||
init();
|
||||
});
|
||||
|
||||
onUnmounted(() => modeler?.destroy());
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
if (props.source && props.source.trim().startsWith("{")) {
|
||||
const def = JSON.parse(props.source) as CanvasJson;
|
||||
if (def.nodes?.length) {
|
||||
// 已有定义:先导入最小骨架再程序化布点连线
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await modeler.importXML(minimalXml());
|
||||
toBpmn(modeler, { ...def, code: def.code || "", name: def.name || "" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 新建:画默认骨架 start → 审批 → end
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await modeler.importXML(minimalXml());
|
||||
toBpmn(modeler, skeleton());
|
||||
} catch (e: any) {
|
||||
Message.error(`流程图载入失败:${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 新建骨架(含默认审批节点,审批人可见在外层点开抽屉配置) */
|
||||
function skeleton(): CanvasJson {
|
||||
return {
|
||||
code: "",
|
||||
name: "",
|
||||
start: "start",
|
||||
nodes: [
|
||||
{ id: "start", type: "START" },
|
||||
{ id: "n1", type: "APPROVAL", name: "步骤 1" },
|
||||
{ id: "end", type: "END" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "n1" },
|
||||
{ from: "n1", to: "end" },
|
||||
],
|
||||
settings: { rejectPolicy: "END_REJECTED" },
|
||||
};
|
||||
}
|
||||
|
||||
/** 最小合法 BPMN XML(空 process):程序化布点用 */
|
||||
function minimalXml(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||
targetNamespace="http://bpmn.io/schema/bpmn">
|
||||
<bpmn:process id="lc_process" isExecutable="true" />
|
||||
</bpmn:definitions>`;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.source,
|
||||
() => {
|
||||
if (modeler) void init();
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== 工具栏 ====================
|
||||
|
||||
function zoom(delta: number) {
|
||||
const canvas = modeler.get("canvas");
|
||||
canvas.zoom(canvas.zoom() + delta);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
modeler.get("canvas").zoom("fit-viewport", "auto");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
modeler.get("commandStack").undo();
|
||||
}
|
||||
|
||||
function redo() {
|
||||
modeler.get("commandStack").redo();
|
||||
}
|
||||
|
||||
/** 选中连线命名(出口按钮文案):弹 Vue 端 prompt(用 window.prompt 保实现简单) */
|
||||
function renameEdge() {
|
||||
const sel = modeler.get("selection").get();
|
||||
if (sel.length !== 1 || sel[0].type !== "bpmn:SequenceFlow") {
|
||||
Message.warning("请先点击选中一条连线");
|
||||
return;
|
||||
}
|
||||
const conn = sel[0];
|
||||
const cur = conn.businessObject?.name || "";
|
||||
const name = window.prompt("出口名称(即办理按钮文案;单出口可留空,多出口必须命名)", cur);
|
||||
if (name === null) return;
|
||||
modeler.get("modeling").updateProperties(conn, { name: name.trim() });
|
||||
}
|
||||
|
||||
function exportBpmn() {
|
||||
modeler.saveXML({ format: true }).then(({ xml }: { xml: string }) => {
|
||||
const blob = new Blob([xml], { type: "application/xml" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "flow.bpmn";
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
||||
Message.success("已导出 BPMN 文件(不参与保存,仅备档)");
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
defineExpose({
|
||||
/** 序列化当前画布(外层合并 code/name/settings 与审批配置) */
|
||||
serialize: (base: CanvasJson | null, code: string, name: string): CanvasJson => {
|
||||
return fromBpmn(modeler, base || ({} as CanvasJson), code, name);
|
||||
},
|
||||
/** 按 contentJson 节点 id 删除元素(卡片抽屉删除回调) */
|
||||
removeNode: (nodeId: string) => {
|
||||
const el = modeler.get("elementRegistry").get(nodeId);
|
||||
if (el) modeler.get("modeling").removeElements([el]);
|
||||
},
|
||||
/** 更新节点名(配置抽屉确定后回写画布) */
|
||||
renameNode: (nodeId: string, name: string) => {
|
||||
const el = modeler.get("elementRegistry").get(nodeId);
|
||||
if (el) modeler.get("modeling").updateProperties(el, { name });
|
||||
},
|
||||
commandStack: () => modeler?.get("commandStack"),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
elementRegistry: () => (modeler ? modeler.get("elementRegistry") : null),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
markClean: () => modeler?.get("commandStack").clear(),
|
||||
selected: () => modeler?.get("selection").get(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bpmn-wrap">
|
||||
<div class="bpmn-toolbar">
|
||||
<Button size="small" @click="undo">撤销</Button>
|
||||
<Button size="small" @click="redo">重做</Button>
|
||||
<span class="sep" />
|
||||
<Button size="small" @click="zoom(-0.1)">−</Button>
|
||||
<Button size="small" @click="resetZoom">复位</Button>
|
||||
<Button size="small" @click="zoom(0.1)">+</Button>
|
||||
<span class="sep" />
|
||||
<Button size="small" @click="renameEdge">出口命名</Button>
|
||||
<Button size="small" @click="exportBpmn">导出 BPMN</Button>
|
||||
<span class="tip">左侧工具栏拖入节点 · 选中审批节点配置 · 选中连线命名出口(多出口必填)</span>
|
||||
</div>
|
||||
<div ref="wrap" class="bpmn-container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bpmn-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
.bpmn-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
z-index: 5;
|
||||
}
|
||||
.sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: #e8eaec;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.tip {
|
||||
color: #a5abb8;
|
||||
font-size: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.bpmn-container {
|
||||
flex: 1;
|
||||
min-height: 380px;
|
||||
}
|
||||
/* bpmn.io 水印按协议保留 */
|
||||
.bpmn-container :deep(.bjs-powered-by) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -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<DeptOption[]>([]);
|
||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||
const userNames = reactive<Record<number, string>>({});
|
||||
|
||||
// ==================== 画布模式(默认):bpmn-js 建模任意 DAG ====================
|
||||
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流(保留回退) */
|
||||
const editMode = ref<"canvas" | "cards">("canvas");
|
||||
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
|
||||
/** 画布数据基准(载入时的 contentJson,供配置按 id 继承) */
|
||||
const canvasBase = ref<any>(null);
|
||||
/** 画布上审批节点的配置快照(element id → ApprovalNode 配置),点卡片写入,保存时并入 nodes */
|
||||
const canvasConfigs = reactive<Record<string, any>>({});
|
||||
/** 原始 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<Issue[]>(() => validateModel(model, depts));
|
||||
const defJson = computed(() => toContentJson(model));
|
||||
|
||||
@@ -67,6 +126,9 @@ const jsonDrawer = ref(false);
|
||||
const editingUid = ref("");
|
||||
|
||||
const editingNode = computed<ApprovalNode | null>(() => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 画布产物轻校验(保存前拦截;结构完整校验仍以发布期 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<string, Set<string>> = {};
|
||||
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));
|
||||
</label>
|
||||
</div>
|
||||
<div class="hd-right">
|
||||
<RadioGroup v-model="editMode" size="small" type="button">
|
||||
<Radio label="canvas">画布</Radio>
|
||||
<Radio label="cards">卡片</Radio>
|
||||
</RadioGroup>
|
||||
<Button @click="settingsDrawer = true">流程设置</Button>
|
||||
<Button @click="jsonDrawer = true">源码</Button>
|
||||
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||||
@@ -339,7 +500,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main v-if="!loading" class="ed-canvas">
|
||||
<!-- 画布模式:bpmn-js 建模(多出口/并联/退回环路均可画) -->
|
||||
<BpmnCanvas v-if="!loading && editMode === 'canvas'" ref="bpmnWrap"
|
||||
:source="sourceJson" @node-config="onCanvasNode" />
|
||||
|
||||
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
|
||||
<div class="ed-flow">
|
||||
<div class="capsule start">发起开始</div>
|
||||
@@ -347,7 +512,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<div class="capsule end">流程结束</div>
|
||||
</div>
|
||||
</main>
|
||||
<main v-else class="ed-canvas loading">
|
||||
<main v-else-if="loading" class="ed-canvas loading">
|
||||
<Spin size="large" fix />
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user