Compare commits
11
Commits
e30e7ab665
...
5b83fd7144
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b83fd7144 | ||
|
|
d3a2b08ba2 | ||
|
|
79866a05e1 | ||
|
|
138cb36800 | ||
|
|
ffa196954e | ||
|
|
656c0d1bf5 | ||
|
|
16717a0867 | ||
|
|
2dd7135753 | ||
|
|
e057e3bd8b | ||
|
|
678cb96801 | ||
|
|
2c65e5adc9 |
Generated
-223
@@ -15,7 +15,6 @@
|
||||
"@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",
|
||||
@@ -296,16 +295,6 @@
|
||||
"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",
|
||||
@@ -2974,36 +2963,6 @@
|
||||
"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",
|
||||
@@ -3306,15 +3265,6 @@
|
||||
"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",
|
||||
@@ -3373,12 +3323,6 @@
|
||||
"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",
|
||||
@@ -4325,79 +4269,6 @@
|
||||
"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",
|
||||
@@ -4466,15 +4337,6 @@
|
||||
"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",
|
||||
@@ -5660,12 +5522,6 @@
|
||||
"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",
|
||||
@@ -5851,12 +5707,6 @@
|
||||
"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",
|
||||
@@ -5946,12 +5796,6 @@
|
||||
"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",
|
||||
@@ -7153,23 +6997,6 @@
|
||||
"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",
|
||||
@@ -7236,26 +7063,6 @@
|
||||
"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",
|
||||
@@ -7624,15 +7431,6 @@
|
||||
"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",
|
||||
@@ -7929,15 +7727,6 @@
|
||||
"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",
|
||||
@@ -8860,12 +8649,6 @@
|
||||
"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",
|
||||
@@ -9875,12 +9658,6 @@
|
||||
"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,7 +22,6 @@
|
||||
"@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",
|
||||
|
||||
+21
-6
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import layout from "./components/layout.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
@@ -9,6 +9,9 @@ import { Message } from "view-ui-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const isLogin = ref(false);
|
||||
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
||||
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
|
||||
const isEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
const layoutData = reactive({
|
||||
appName: "流程中心",
|
||||
baseHost: config.baseHost,
|
||||
@@ -20,6 +23,14 @@ const layoutData = reactive({
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||
await router.isReady();
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
||||
if (isEmbed.value) {
|
||||
isLogin.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 1. 获取当前用户信息
|
||||
const user: any = await baseApi.getUserInfo().send();
|
||||
@@ -75,12 +86,16 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) -->
|
||||
<router-view v-if="isEmbed" />
|
||||
<template v-else>
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -84,6 +84,14 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/flow/admin/Routers.vue"),
|
||||
meta: { title: "条件路由", modId: 106 },
|
||||
},
|
||||
{
|
||||
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
|
||||
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
|
||||
path: "/embed/instance",
|
||||
name: "flow_embed_instance",
|
||||
component: () => import("@/views/flow/EmbedInstance.vue"),
|
||||
meta: { title: "办件进度" },
|
||||
},
|
||||
{ path: "/", redirect: "/workbench" },
|
||||
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
// 办件进度嵌入页(iframe 免签嵌入,2026-10-03 方案):子应用 Drawer 里以 iframe 打开本页,
|
||||
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
|
||||
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
|
||||
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, TASK_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
const detail = ref<any>(null);
|
||||
const loading = ref(true);
|
||||
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
|
||||
const expired = ref(false);
|
||||
|
||||
/** 显示口径:dept=按科室 / user=按人员(缺省)。纯前端本地状态——嵌入页 URL 归子应用管,
|
||||
* 改 query 会破坏 token 语义,不落 URL(与管理面 InstanceDetail 写 query 的动机不同) */
|
||||
const traceMode = ref<string>("user");
|
||||
function switchMode(val: string) {
|
||||
traceMode.value = val;
|
||||
}
|
||||
|
||||
const token = computed(() => String(route.query.token || ""));
|
||||
|
||||
/** 示意图直嵌:同一 token 第二处消费(换图端点零改动即认领嵌入 token) */
|
||||
const diagramSrc = computed(
|
||||
() => `${config.serviceHost}/open/flow/diagram/img?token=${token.value}`,
|
||||
);
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "摘要", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务表单数据键值平铺(同 InstanceDetail 现状,不做精细表单渲染) */
|
||||
const formRows = computed(() => {
|
||||
const raw = detail.value?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({
|
||||
k,
|
||||
v: v == null ? "-"
|
||||
: Array.isArray(v)
|
||||
? (v.map((item: any) => item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item)).join("、") || "-")
|
||||
: (typeof v === "object" ? JSON.stringify(v) : String(v)),
|
||||
}));
|
||||
}
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
});
|
||||
|
||||
// ===== 审批记录流水表(整个流程周期,与"流程轨迹"时间线互补:时间线给进度感,表格给逐条可查流水) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG:两处口径,改一处必须同步另一处) */
|
||||
const RECORD_KIND_TAG: Record<string, string> = {
|
||||
SUBMIT: "green",
|
||||
RESUBMIT: "green",
|
||||
WITHDRAW: "orange",
|
||||
APPROVE: "green",
|
||||
REJECT: "red",
|
||||
RETURN: "orange",
|
||||
TRANSFER: "cyan",
|
||||
INVALID: "default",
|
||||
PENDING: "blue",
|
||||
CC: "cyan",
|
||||
ROUTE: "purple",
|
||||
};
|
||||
|
||||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 TraceTimeline 的 multiRound) */
|
||||
const multiRound = computed(() =>
|
||||
(detail.value?.trace || []).some((t: any) => (t.round || 1) > 1));
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? String(v).replace("T", " ").replace(/\.\d+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人") */
|
||||
function recordActor(t: any): string {
|
||||
if (t.kind === "ROUTE") return "流程引擎";
|
||||
if (traceMode.value !== "dept") {
|
||||
return t.assigneeName || t.assigneeId || "-";
|
||||
}
|
||||
if (t.assigneeType === "DEPT_KEY") {
|
||||
return t.deptName || t.deptKey || t.assigneeName || "-";
|
||||
}
|
||||
if (t.assigneeType === "USERS") {
|
||||
return "指定办理人";
|
||||
}
|
||||
return t.kind === "SUBMIT" || t.kind === "RESUBMIT" || t.kind === "WITHDRAW"
|
||||
? "提交人"
|
||||
: (t.assigneeName || "-");
|
||||
}
|
||||
|
||||
/** 动作标签(口径=TraceTimeline.actionText:CC/ROUTE 专用文案,任务行走 TASK_STATUS 码表) */
|
||||
function recordAction(t: any): string {
|
||||
if (t.kind === "CC") return "已抄送";
|
||||
if (t.kind === "ROUTE") return "系统判断";
|
||||
return TASK_STATUS[t.status] || String(t.status ?? "");
|
||||
}
|
||||
|
||||
/** 表格列:Drawer iframe 宽约 828px,列宽按此设计;轮次列仅多轮时出现 */
|
||||
const recordColumns = computed(() => {
|
||||
const cols: any[] = [
|
||||
{ title: "时间", width: 150, render: (h: any, p: any) => h("span", fmtTime(p.row.time)) },
|
||||
];
|
||||
if (multiRound.value) {
|
||||
cols.push({
|
||||
title: "轮次", width: 80, align: "center",
|
||||
render: (h: any, p: any) => h("span", `第 ${p.row.round || 1} 轮`),
|
||||
});
|
||||
}
|
||||
cols.push(
|
||||
{
|
||||
title: "节点", minWidth: 120,
|
||||
render: (h: any, p: any) => h("span", p.row.nodeName || p.row.nodeId || "-"),
|
||||
},
|
||||
{ title: "办理人", width: 110, render: (h: any, p: any) => h("span", recordActor(p.row)) },
|
||||
{
|
||||
title: "动作", width: 90, align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { size: "small", color: RECORD_KIND_TAG[p.row.kind] || "default" },
|
||||
() => recordAction(p.row)),
|
||||
},
|
||||
{
|
||||
title: "意见 / 去向", minWidth: 180,
|
||||
render: (h: any, p: any) => {
|
||||
const parts: any[] = [];
|
||||
if (p.row.opinion) parts.push(h("span", `「${p.row.opinion}」`));
|
||||
if (p.row.edgeName) parts.push(h("span", `去向:${p.row.edgeName}`));
|
||||
if (p.row.kind === "ROUTE") {
|
||||
// outlet 空=判断失败挂起(同 TraceTimeline 的 route-hung 文案),等管理端重跑路由
|
||||
parts.push(p.row.outlet
|
||||
? h("span", `→ ${p.row.outlet}`)
|
||||
: h("span", { style: "color:#ed4014" }, `判断失败已挂起(第 ${p.row.attempt || 1} 次)`));
|
||||
}
|
||||
return parts.length ? h("span", parts) : h("span", { style: "color:#999" }, "-");
|
||||
},
|
||||
},
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
||||
function statusName(s: number) {
|
||||
return INSTANCE_STATUS[s] || s;
|
||||
}
|
||||
|
||||
function statusColor(s: number) {
|
||||
return INSTANCE_STATUS_COLOR[s] || "gray";
|
||||
}
|
||||
|
||||
// 免签端点不带任何会话,alova 拦截器(token 头/信封解析)一概用不上,原生 fetch 直取
|
||||
onMounted(async () => {
|
||||
if (!token.value) {
|
||||
expired.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${config.serviceHost}/open/flow/embed/data?token=${token.value}`);
|
||||
if (!res.ok) {
|
||||
// 404=过期/伪造,与缺 token 同空态(不区分,不诱导探测)
|
||||
expired.value = true;
|
||||
return;
|
||||
}
|
||||
detail.value = await res.json();
|
||||
} catch {
|
||||
expired.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="embed-page" v-if="detail">
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||||
</div>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>基本信息</template>
|
||||
<div class="grid">
|
||||
<div class="row"><span class="k">流程</span><span>{{ detail.defCode }} v{{ detail.defVersion }}</span></div>
|
||||
<div class="row"><span class="k">提交人</span><span>{{ detail.submitterName }}</span></div>
|
||||
<div class="row"><span class="k">提交时间</span><span>{{ (detail.submitTime || "").replace("T", " ") }}</span></div>
|
||||
<div class="row"><span class="k">办结时间</span><span>{{ (detail.finishTime || "").replace("T", " ") || "-" }}</span></div>
|
||||
</div>
|
||||
<template v-if="snapshotRows.length">
|
||||
<Divider size="small" />
|
||||
<div class="snapshot">
|
||||
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>流程示意图</template>
|
||||
<img class="diagram-img" :src="diagramSrc" alt="流程示意图" />
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="traceMode" />
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
审批记录
|
||||
<span class="record-tip">整个流程周期的完整事件流水</span>
|
||||
</template>
|
||||
<Table size="small" :columns="recordColumns" :data="detail.trace || []" />
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
|
||||
<template #title>业务表单数据</template>
|
||||
<div class="snapshot">
|
||||
<div v-for="row in formRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else-if="loading" class="embed-page muted">加载中…</div>
|
||||
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 根类不用 .page:UnoCSS 原子类同名会以更高优先级盖掉 scoped 的 padding(裸渲染无 layout 壳时暴露) */
|
||||
.embed-page { padding: 12px 16px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
.grid .row { display: flex; padding: 3px 0; }
|
||||
.k { width: 120px; color: #888; flex-shrink: 0; }
|
||||
.v { white-space: pre-wrap; word-break: break-all; }
|
||||
.muted { color: #999; }
|
||||
.expired { padding: 48px 16px; text-align: center; }
|
||||
.diagram-img { max-width: 100%; }
|
||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||||
/* 类名带 record- 前缀:防 UnoCSS 原子类撞名(嵌入页裸渲染无 layout 壳,撞名实测会盖样式) */
|
||||
.record-tip {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin-right: 16px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
+16
-121
@@ -1,20 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user,缺省 user)。
|
||||
// 三视图(2026-09-17):auto=示意图(手写 SVG 自动布局,零图形库)|canvas=画布视图(bpmn-js 只读渲染,
|
||||
// 复用设计器的 contentJson→BPMN 通道,与示意图视觉区分)|img=进度图(服务端 PNG 流)。
|
||||
// 出图形态两档(2026-10-03 画布视图退役):auto=示意图(后端 positions 单源,手写 SVG)|img=进度图(服务端 PNG 流)。
|
||||
// 显示口径(2026-09-18):只两档 dept=科室/user=人员,不传=人员;与办件详情/办理页的轨迹口径同参数。
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
|
||||
import { toBpmnXml, CanvasJson } from "@/views/flow/admin/def-editor/bpmn/serialize";
|
||||
// bpmn-js 渲染样式(设计器 custom.ts 已全局引过,此处再引由打包器去重;漏引连线/图标异常)
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -29,10 +22,9 @@ const sourceText = ref("");
|
||||
const sourceTitle = ref("");
|
||||
const sourceLoading = ref(false);
|
||||
|
||||
/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(bpmn-js 只读)|img=进度图(服务端 PNG 流) */
|
||||
const chartStyle = ref<string>((route.query.chart as string) || "auto");
|
||||
/** 出图形态两档(2026-10-03 砍画布视图):auto=示意图|img=进度图;旧深链 chart=canvas 归一到 auto */
|
||||
const chartStyle = ref<string>(route.query.chart === "img" ? "img" : "auto");
|
||||
const showImg = computed(() => chartStyle.value === "img");
|
||||
const showCanvas = computed(() => chartStyle.value === "canvas");
|
||||
|
||||
/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */
|
||||
const pngSrc = computed(() => {
|
||||
@@ -49,8 +41,18 @@ const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "
|
||||
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用;并行分支组垂直居中)
|
||||
import { layout as autoLayout, PAD, Pos } from "@/utils/flowLayout";
|
||||
|
||||
/** 布局:后端 positions 单源(内容区坐标);旧后端未下发时回退本地自动布局(灰度兜底) */
|
||||
const positions = computed<Record<string, Pos>>(() => {
|
||||
return autoLayout({ nodes: vo.value?.nodes || [], edges: vo.value?.edges || [] });
|
||||
const raw = (vo.value as any)?.positions as Record<string, { x: number; y: number; w: number; h: number }> | undefined;
|
||||
const nodes: any[] = vo.value?.nodes || [];
|
||||
if (raw && nodes.length && nodes.every((n) => raw[String(n.id)])) {
|
||||
const out: Record<string, Pos> = {};
|
||||
Object.entries(raw).forEach(([id, p]) => {
|
||||
out[id] = { x: p.x, y: p.y, w: p.w, h: p.h, depth: 0, row: 0 };
|
||||
});
|
||||
return out;
|
||||
}
|
||||
return autoLayout({ nodes, edges: vo.value?.edges || [] });
|
||||
});
|
||||
|
||||
const vbSize = computed(() => {
|
||||
@@ -178,94 +180,10 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 画布视图(bpmn-js 只读渲染,2026-09-17 与示意图区分) ====================
|
||||
|
||||
const bpmnHost = ref<HTMLDivElement | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let bpmnViewer: any = null;
|
||||
|
||||
/** DiagramVo → 画布视图数据(layout 为后端透传的设计器中心坐标;缺省回退共享自动布局) */
|
||||
const bpmnDef = computed<CanvasJson | null>(() => {
|
||||
const nodes: any[] = vo.value?.nodes || [];
|
||||
if (!nodes.length) return null;
|
||||
return {
|
||||
code: "",
|
||||
name: vo.value?.name || "",
|
||||
start: "start",
|
||||
nodes: nodes.map((n) => ({
|
||||
id: String(n.id),
|
||||
type: n.type,
|
||||
name: n.subLabel ? `${n.label}\n${n.subLabel}` : n.label,
|
||||
})),
|
||||
edges: ((vo.value?.edges || []) as any[]).map((e) => ({
|
||||
from: String(e.from), to: String(e.to), name: e.name,
|
||||
})),
|
||||
settings: {},
|
||||
layout: vo.value?.layout as CanvasJson["layout"],
|
||||
};
|
||||
});
|
||||
|
||||
async function initBpmn() {
|
||||
const host = bpmnHost.value;
|
||||
const def = bpmnDef.value;
|
||||
if (!host || !def) return;
|
||||
bpmnViewer?.destroy();
|
||||
bpmnViewer = new NavigatedViewer({ container: host });
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (bpmnViewer as any).importXML(toBpmnXml(def));
|
||||
} catch (e: any) {
|
||||
Message.error(`画布视图载入失败:${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const canvas = bpmnViewer.get("canvas") as any;
|
||||
canvas.zoom("fit-viewport", "auto");
|
||||
// 运行时状态着色(instance 口径):marker 类 + <style> 里 .fc-* 规则
|
||||
nodesWithState().forEach(([id, state]) => canvas.addMarker(id, `fc-${state}`));
|
||||
}
|
||||
|
||||
function nodesWithState(): Array<[string, string]> {
|
||||
return ((vo.value?.nodes || []) as any[])
|
||||
.filter((n) => n.state)
|
||||
.map((n) => [String(n.id), String(n.state)]);
|
||||
}
|
||||
|
||||
watch([showCanvas, bpmnDef], ([on]) => {
|
||||
if (on) void nextTick(initBpmn);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bpmnViewer?.destroy();
|
||||
bpmnViewer = null;
|
||||
});
|
||||
|
||||
function bpmnZoom(delta: number) {
|
||||
if (!bpmnViewer) return;
|
||||
const c = bpmnViewer.get("canvas");
|
||||
c.zoom(c.zoom() + delta);
|
||||
}
|
||||
|
||||
function bpmnFit() {
|
||||
bpmnViewer?.get("canvas").zoom("fit-viewport", "auto");
|
||||
}
|
||||
|
||||
// ==================== 导出 PNG(方案 §4.4) ====================
|
||||
|
||||
/** 当前形态的 SVG 源:示意图取 DOM,画布视图取 bpmn saveSVG(补 width/height 供 Image 取自然尺寸) */
|
||||
/** 当前形态的 SVG 源:示意图取 DOM */
|
||||
async function currentSvg(): Promise<string | null> {
|
||||
if (chartStyle.value === "canvas") {
|
||||
if (!bpmnViewer) return null;
|
||||
const { svg } = await bpmnViewer.saveSVG();
|
||||
if (!/width="/.test(svg)) {
|
||||
const m = svg.match(/viewBox="([\d.\s-]+)"/);
|
||||
if (m) {
|
||||
const parts = m[1].trim().split(/\s+/);
|
||||
return svg.replace(/<svg /, `<svg width="${parts[2]}" height="${parts[3]}" `);
|
||||
}
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
if (!svgRef.value) return null;
|
||||
return new XMLSerializer().serializeToString(svgRef.value);
|
||||
}
|
||||
@@ -330,7 +248,6 @@ watch(() => route.query, () => load());
|
||||
{{ DEF_STATUS[vo.status] || vo.status }}</Tag>
|
||||
<RadioGroup :model-value="chartStyle" size="small" type="button" @on-change="switchChartStyle">
|
||||
<Radio label="auto">示意图</Radio>
|
||||
<Radio label="canvas">画布视图</Radio>
|
||||
<Radio label="img">进度图</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup v-if="!showImg" :model-value="viewMode" size="small" @on-change="switchMode">
|
||||
@@ -346,15 +263,6 @@ watch(() => route.query, () => load());
|
||||
<div class="canvas img-box" v-if="vo && showImg">
|
||||
<img :src="pngSrc" alt="流程示意图" />
|
||||
</div>
|
||||
<!-- 画布视图:bpmn-js 只读渲染(bpmn.io 水印为协议要求,勿删) -->
|
||||
<div class="canvas bpmn-box" v-else-if="vo && showCanvas">
|
||||
<div ref="bpmnHost" class="bpmn-host"></div>
|
||||
<div class="bpmn-ops">
|
||||
<Button size="small" @click="bpmnZoom(-0.1)">−</Button>
|
||||
<Button size="small" @click="bpmnFit">复位</Button>
|
||||
<Button size="small" @click="bpmnZoom(0.1)">+</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 示意图:手写 SVG 自动布局 -->
|
||||
<div class="canvas" v-else-if="vo">
|
||||
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
|
||||
@@ -473,17 +381,4 @@ watch(() => route.query, () => load());
|
||||
0%, 100% { filter: none; }
|
||||
50% { filter: drop-shadow(0 0 6px rgba(237, 64, 20, 0.8)); }
|
||||
}
|
||||
|
||||
/* 画布视图 */
|
||||
.bpmn-box { position: relative; }
|
||||
.bpmn-host { height: 560px; }
|
||||
.bpmn-ops { position: absolute; top: 10px; right: 12px; display: flex; gap: 6px; z-index: 5; }
|
||||
/* bpmn.io 水印按协议保留 */
|
||||
.bpmn-box :deep(.bjs-powered-by) { display: block; }
|
||||
/* 运行时状态着色(instance 口径 marker:done/active/rejected/pending) */
|
||||
.bpmn-box :deep(.fc-done > .djs-visual > :nth-child(1)) { stroke: #19be6b !important; }
|
||||
.bpmn-box :deep(.fc-active > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; stroke-width: 2.5px !important; }
|
||||
.bpmn-box :deep(.fc-rejected > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; fill: #fff1f0 !important; }
|
||||
.bpmn-box :deep(.fc-pending > .djs-visual > :nth-child(1)) { stroke: #b7bcc4 !important; }
|
||||
.bpmn-box :deep(.fc-done > .djs-visual > .djs-label) { fill: #4f5b66; }
|
||||
</style>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
// 必需:diagram-js 画布/连线/调色板样式 + bpmn 字体图标;漏引会导致连线变黑条、黑锚点外露、左侧工具栏不可见
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-js.css";
|
||||
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
|
||||
|
||||
/** 汉化词表(palette/contextPad/常用按钮;未命中键原样透回) */
|
||||
const TRANSLATIONS: Record<string, string> = {
|
||||
"Create StartEvent": "创建开始",
|
||||
"Create EndEvent": "创建结束",
|
||||
"Create Task": "创建审批节点",
|
||||
"Create User Task": "创建审批节点",
|
||||
"Create Service Task": "创建抄送节点",
|
||||
"Create Gateway": "创建并行分支",
|
||||
"Create Gateways": "创建并行分支",
|
||||
"Create ParallelGateway": "创建并行分支",
|
||||
"Create expanded ParallelGateway": "创建并行分支",
|
||||
"Create ExclusiveGateway": "创建条件分支",
|
||||
"Create expanded ExclusiveGateway": "创建条件分支",
|
||||
"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-notify": action("bpmn:ServiceTask", "创建抄送节点", "bpmn-icon-send-task"),
|
||||
"lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"),
|
||||
"lc-condition": action("bpmn:ExclusiveGateway", "创建条件分支", "bpmn-icon-gateway-xor"),
|
||||
};
|
||||
};
|
||||
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()],
|
||||
});
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* 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",
|
||||
NOTIFY: "bpmn:ServiceTask",
|
||||
PARALLEL_SPLIT: "bpmn:ParallelGateway",
|
||||
PARALLEL_JOIN: "bpmn:ParallelGateway",
|
||||
CONDITION: "bpmn:ExclusiveGateway",
|
||||
};
|
||||
|
||||
/** 把 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:ServiceTask": {
|
||||
return "NOTIFY";
|
||||
}
|
||||
case "bpmn:ParallelGateway": {
|
||||
// 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型)
|
||||
return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT";
|
||||
}
|
||||
case "bpmn:ExclusiveGateway": {
|
||||
// CONDITION 无 join 对应物(排他网关不用于汇聚):多入口也按条件分支读出
|
||||
return "CONDITION";
|
||||
}
|
||||
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" || type === "NOTIFY" || type === "CONDITION") {
|
||||
// 审批人/抄送对象/判断服务等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置)
|
||||
const prev = base.nodes.find((x) => String(x.id) === String(node.id));
|
||||
if (prev) {
|
||||
const keys = type === "APPROVAL"
|
||||
? ["assignee", "countersign", "deadlineHours"]
|
||||
: type === "NOTIFY" ? ["receivers"] : ["routerKey"];
|
||||
keys.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;
|
||||
}
|
||||
|
||||
/** 查看器 BPMN DI 形状尺寸(事件 36、网关 50、任务两行文本 150x76) */
|
||||
const DI_SIZE: Record<string, { w: number; h: number }> = {
|
||||
START: { w: 36, h: 36 },
|
||||
END: { w: 36, h: 36 },
|
||||
APPROVAL: { w: 150, h: 76 },
|
||||
NOTIFY: { w: 150, h: 76 },
|
||||
PARALLEL_SPLIT: { w: 50, h: 50 },
|
||||
PARALLEL_JOIN: { w: 50, h: 50 },
|
||||
CONDITION: { w: 50, h: 50 },
|
||||
};
|
||||
|
||||
/**
|
||||
* contentJson(+layout) → 可导入只读 Viewer 的 BPMN XML(含 DI 布局)。
|
||||
* 画布视图专用:bpmn-js Viewer 没有 modeling 模块,不能走 toBpmn 的程序化布点,
|
||||
* 直接生成带 BPMNShape/BPMNEdge 的 XML 一次导入;连线给正交 waypoints(右中→中线→左中)。
|
||||
*/
|
||||
export function toBpmnXml(def: CanvasJson): string {
|
||||
const nodes: any[] = def.nodes || [];
|
||||
const edges: any[] = def.edges || [];
|
||||
// 元素中心点:layout 覆盖全部节点用 layout(编辑器保存的是中心坐标),否则回退自动布局
|
||||
const saved = def.layout || {};
|
||||
const useSaved = nodes.length > 0 && nodes.every((n) => saved[String(n.id)]);
|
||||
const centers: Record<string, { x: number; y: number }> = {};
|
||||
if (useSaved) {
|
||||
nodes.forEach((n) => { centers[String(n.id)] = saved[String(n.id)]; });
|
||||
} else {
|
||||
const auto = autoLayout({ nodes, edges });
|
||||
nodes.forEach((n) => {
|
||||
const p = auto[String(n.id)];
|
||||
if (p) centers[String(n.id)] = { x: p.x + p.w / 2, y: p.y + p.h / 2 };
|
||||
});
|
||||
}
|
||||
const esc = (s: unknown): string =>
|
||||
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const body = nodes.filter((n) => BPMN_TYPE[String(n.type)] && centers[String(n.id)]);
|
||||
const flows = edges.filter((e) => centers[String(e.from)] && centers[String(e.to)]);
|
||||
|
||||
const elems = body
|
||||
.map((n) => {
|
||||
const tag = BPMN_TYPE[String(n.type)].replace("bpmn:", "");
|
||||
const name = n.name ? ` name="${esc(n.name)}"` : "";
|
||||
return ` <bpmn:${tag} id="${esc(n.id)}"${name} />`;
|
||||
})
|
||||
.join("\n");
|
||||
const flowElems = flows
|
||||
.map((e, i) => {
|
||||
const name = e.name ? ` name="${esc(e.name)}"` : "";
|
||||
return ` <bpmn:sequenceFlow id="flow_${i}"${name} sourceRef="${esc(e.from)}" targetRef="${esc(e.to)}" />`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const bounds = (id: string): { x: number; y: number; w: number; h: number } => {
|
||||
const c = centers[id];
|
||||
const type = String(nodes.find((n) => String(n.id) === id)?.type || "APPROVAL");
|
||||
const s = DI_SIZE[type] || DI_SIZE.APPROVAL;
|
||||
return { x: Math.round(c.x - s.w / 2), y: Math.round(c.y - s.h / 2), w: s.w, h: s.h };
|
||||
};
|
||||
const shapes = body
|
||||
.map((n) => {
|
||||
const b = bounds(String(n.id));
|
||||
return ` <bpmndi:BPMNShape id="di_${esc(n.id)}" bpmnElement="${esc(n.id)}">
|
||||
<dc:Bounds x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" />
|
||||
</bpmndi:BPMNShape>`;
|
||||
})
|
||||
.join("\n");
|
||||
const edgeDi = flows
|
||||
.map((e, i) => {
|
||||
const fb = bounds(String(e.from));
|
||||
const tb = bounds(String(e.to));
|
||||
const x1 = fb.x + fb.w;
|
||||
const y1 = fb.y + fb.h / 2;
|
||||
const x2 = tb.x;
|
||||
const y2 = tb.y + tb.h / 2;
|
||||
const mid = Math.round((x1 + x2) / 2);
|
||||
const pts = Math.abs(y1 - y2) < 2
|
||||
? [[x1, y1], [x2, y2]]
|
||||
: [[x1, y1], [mid, y1], [mid, y2], [x2, y2]];
|
||||
const wp = pts.map(([x, y]) => ` <di:waypoint x="${Math.round(x)}" y="${Math.round(y)}" />`).join("\n");
|
||||
return ` <bpmndi:BPMNEdge id="di_flow_${i}" bpmnElement="flow_${i}">\n${wp}\n </bpmndi:BPMNEdge>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
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"
|
||||
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||
targetNamespace="http://bpmn.io/schema/bpmn">
|
||||
<bpmn:process id="lc_process" isExecutable="true">
|
||||
${elems}
|
||||
${flowElems}
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process">
|
||||
${shapes}
|
||||
${edgeDi}
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>`;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { computed, inject, reactive, ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import { ApprovalNode, EDITOR_CTX } from "../model";
|
||||
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -28,6 +28,7 @@ const form = reactive({
|
||||
userIds: [] as number[],
|
||||
countersign: "ANY" as "ALL" | "ANY",
|
||||
deadlineHours: null as number | null,
|
||||
multiOutlet: false,
|
||||
});
|
||||
|
||||
const userCheckModal = ref<any>();
|
||||
@@ -47,6 +48,7 @@ function syncFromNode(): void {
|
||||
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
|
||||
form.countersign = n.countersign;
|
||||
form.deadlineHours = n.deadlineHours;
|
||||
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -119,6 +121,12 @@ function onOk(): void {
|
||||
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||
n.countersign = form.countersign;
|
||||
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
|
||||
// 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点;
|
||||
// 关闭不在抽屉做——泳道头部"恢复单出口"负责(要处理泳道内容的去向)
|
||||
if (form.multiOutlet && !(n.outlets && n.outlets.length >= 2)) {
|
||||
n.outlets = ["同意", "退回"];
|
||||
n.branches = [[createApproval()], [createApproval()]];
|
||||
}
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
@@ -197,6 +205,18 @@ function onRemove(): void {
|
||||
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
||||
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<label>出口路由</label>
|
||||
<div class="mo-row">
|
||||
<i-switch v-model="form.multiOutlet" />
|
||||
<span class="mo-label">多出口审批(办理人按下按钮选路)</span>
|
||||
</div>
|
||||
<div class="hint">
|
||||
开启后卡片下出现出口泳道(默认 同意/退回),出口名即办理按钮文案;泳道尾自动汇合到后继节点。
|
||||
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="dw-foot">
|
||||
@@ -249,6 +269,14 @@ function onRemove(): void {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
.mo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mo-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
.dw-foot {
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<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" />
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process" />
|
||||
</bpmndi:BPMNDiagram>
|
||||
</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>
|
||||
@@ -5,6 +5,7 @@ import NodeCard from "./NodeCard.vue";
|
||||
import NotifyCard from "./NotifyCard.vue";
|
||||
import ParallelBox from "./ParallelBox.vue";
|
||||
import ConditionBox from "./ConditionBox.vue";
|
||||
import OutletLanes from "./OutletLanes.vue";
|
||||
import PlusButton from "./PlusButton.vue";
|
||||
|
||||
defineProps<{
|
||||
@@ -19,9 +20,10 @@ defineProps<{
|
||||
<PlusButton :container="container" :index="0" />
|
||||
<template v-for="(item, i) in items" :key="item.uid">
|
||||
<NodeCard v-if="item.kind === 'approval'" :node="item" />
|
||||
<NotifyCard v-else-if="item.kind === 'notify'" :node="item" />
|
||||
<OutletLanes v-if="item.kind === 'approval' && item.outlets && item.outlets.length >= 2" :node="item" />
|
||||
<NotifyCard v-if="item.kind === 'notify'" :node="item" />
|
||||
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
|
||||
<ConditionBox v-else :node="item" />
|
||||
<ConditionBox v-else-if="item.kind === 'condition'" :node="item" />
|
||||
<PlusButton :container="container" :index="i + 1" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
// 多出口审批的出口泳道容器(2026-10-03 P2):蓝系区分条件紫。与条件分支容器完全同构——
|
||||
// 无 JOIN 语义,办理人按下哪个出口按钮走哪条泳道,分支尾隐式汇合到容器后继。
|
||||
// 出口名 Input 即 contentJson 里 APPROVAL 命名出边的 name(按钮文案,必填唯一)。
|
||||
import { computed, inject } from "vue";
|
||||
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ApprovalNode } from "../model";
|
||||
import NodeList from "./NodeList.vue";
|
||||
|
||||
const props = defineProps<{ node: ApprovalNode }>();
|
||||
|
||||
const ctx = inject(EDITOR_CTX)!;
|
||||
|
||||
const issues = computed(() => ctx.issueOf(props.node.uid));
|
||||
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
|
||||
const canRemoveBranch = computed(() => (props.node.branches?.length || 0) > MIN_BRANCHES);
|
||||
const canAddBranch = computed(() => (props.node.branches?.length || 0) < MAX_BRANCHES);
|
||||
|
||||
/** 出口名就地编辑(重复/空由校验提示;与 branches 下标一一对应) */
|
||||
function renameOutlet(i: number, v: string): void {
|
||||
props.node.outlets![i] = v;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="outlet-lanes" :class="{ error: hasError }">
|
||||
<div class="ol-head">
|
||||
<div class="ol-title" @click="ctx.openNode(node.uid)">
|
||||
出口路由(按下按钮选路)
|
||||
<span class="ol-hint">(出口名即办理按钮文案;点击审批卡片配置审批人)</span>
|
||||
</div>
|
||||
<Poptip
|
||||
confirm
|
||||
title="退出多出口将删除出口②起的全部泳道节点,仅保留出口①泳道?"
|
||||
ok-text="退出多出口"
|
||||
cancel-text="取消"
|
||||
transfer
|
||||
@on-ok="ctx.exitOutlets(node.uid)"
|
||||
>
|
||||
<span class="ol-exit">恢复单出口</span>
|
||||
</Poptip>
|
||||
</div>
|
||||
<div class="ol-cols">
|
||||
<div v-for="(branch, i) in node.branches" :key="i" class="ol-col">
|
||||
<div class="ol-col-head">
|
||||
<span class="ol-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
|
||||
<input
|
||||
class="ol-outlet"
|
||||
:value="node.outlets![i]"
|
||||
placeholder="出口名(按钮文案,必填唯一)"
|
||||
maxlength="32"
|
||||
@input="renameOutlet(i, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<Poptip
|
||||
v-if="canRemoveBranch"
|
||||
confirm
|
||||
title="删除该出口及其泳道节点?"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
transfer
|
||||
@on-ok="ctx.removeBranch(node.uid, i)"
|
||||
>
|
||||
<span class="ol-col-del" title="删除出口">×</span>
|
||||
</Poptip>
|
||||
</div>
|
||||
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canAddBranch" class="ol-add" size="small" long @click="ctx.addBranch(node.uid)">
|
||||
+ 添加出口
|
||||
</Button>
|
||||
<div v-for="(issue, k) in issues" :key="k" class="ol-issue" :class="issue.level">⚠ {{ issue.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outlet-lanes {
|
||||
border: 1.5px dashed #5cadff;
|
||||
border-radius: 10px;
|
||||
padding: 14px 14px 12px;
|
||||
background: rgba(240, 249, 255, 0.45);
|
||||
}
|
||||
.outlet-lanes.error {
|
||||
border-color: #ed4014;
|
||||
}
|
||||
.ol-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ol-title {
|
||||
text-align: center;
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ol-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ol-hint {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: #82a8d8;
|
||||
}
|
||||
.ol-exit {
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #ed4014;
|
||||
}
|
||||
.ol-exit:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ol-cols {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.ol-col {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 12px 10px 14px;
|
||||
min-width: 230px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.ol-col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.ol-col-no {
|
||||
flex: none;
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ol-outlet {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #dfe6ee;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ol-outlet:focus {
|
||||
outline: none;
|
||||
border-color: #2d8cf0;
|
||||
}
|
||||
.ol-col-del {
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
color: #c5c9d1;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.ol-col-del:hover {
|
||||
color: #ed4014;
|
||||
}
|
||||
.ol-add {
|
||||
margin-top: 12px;
|
||||
border-style: dashed;
|
||||
color: #2d8cf0;
|
||||
border-color: #2d8cf0;
|
||||
background: transparent;
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.ol-add:hover {
|
||||
background: #f0faff;
|
||||
color: #2b85e4;
|
||||
border-color: #2d8cf0;
|
||||
}
|
||||
.ol-issue {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.ol-issue.error {
|
||||
color: #ed4014;
|
||||
}
|
||||
.ol-issue.warn {
|
||||
color: #ff9900;
|
||||
}
|
||||
</style>
|
||||
@@ -73,7 +73,18 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||||
nodes.push(node);
|
||||
linkTo(id);
|
||||
cursor = id;
|
||||
if (item.outlets && item.outlets.length >= 2 && item.branches) {
|
||||
// 多出口(2026-10-03 P2):APPROVAL 的多条命名出边即按钮选路,
|
||||
// 分支尾隐式汇合到后继(与条件分支容器完全同构)
|
||||
const tails: string[] = [];
|
||||
item.branches.forEach((branch, bi) => {
|
||||
tails.push(...walk(branch, id, item.outlets![bi]));
|
||||
});
|
||||
cursor = null;
|
||||
pending = tails;
|
||||
} else {
|
||||
cursor = id;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (item.kind === "notify") {
|
||||
@@ -323,11 +334,39 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
if (!n) throw new Error(`引用了不存在的节点:${cur}`);
|
||||
|
||||
if (n.type === "APPROVAL") {
|
||||
if ((out[cur] || []).length !== 1) {
|
||||
throw new Error(`审批节点「${n.name || cur}」的出边必须恰好 1 条`);
|
||||
const heads = out[cur] || [];
|
||||
if (heads.length === 1) {
|
||||
list.push(toApprovalNode(n));
|
||||
cur = heads[0];
|
||||
} else if (heads.length >= 2) {
|
||||
// 多出口审批:出口名=出边 name(按钮文案),复用 CONDITION 的汇合点算法
|
||||
const mergeId = findMerge(cur);
|
||||
const branches = heads.map((head) => {
|
||||
const r = walkList(head, mergeId);
|
||||
if (r.endedAt !== mergeId) {
|
||||
throw new Error("多出口审批未汇聚到同一个节点,无法还原为卡片视图");
|
||||
}
|
||||
return r.list;
|
||||
});
|
||||
const outlets = heads.map((head) => {
|
||||
const e = edges.find((x) => x.from === cur && x.to === head);
|
||||
return e && e.name ? String(e.name) : "";
|
||||
});
|
||||
if (outlets.some((o) => !o || !o.trim())) {
|
||||
throw new Error(`多出口审批「${n.name || cur}」的每个出口必须命名(按钮文案)`);
|
||||
}
|
||||
const dup = outlets.find((o, i) => outlets.indexOf(o) !== i);
|
||||
if (dup) {
|
||||
throw new Error(`多出口审批出口名重复:「${dup}」(按钮文案必须唯一)`);
|
||||
}
|
||||
const node = toApprovalNode(n);
|
||||
node.outlets = outlets;
|
||||
node.branches = branches.map((b) => (b.length ? b : [createApproval()]));
|
||||
list.push(node);
|
||||
cur = mergeId;
|
||||
} else {
|
||||
throw new Error(`审批节点「${n.name || cur}」出边数非法`);
|
||||
}
|
||||
list.push(toApprovalNode(n));
|
||||
cur = out[cur][0];
|
||||
} else if (n.type === "NOTIFY") {
|
||||
if ((out[cur] || []).length !== 1) {
|
||||
throw new Error(`抄送节点「${n.name || cur}」的出边必须恰好 1 条`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 流程编辑器(方案 §3):钉钉式卡片流——纵向节点卡片 + 节点间"+"插入 + 点卡片右侧抽屉配置,免连线。
|
||||
// 流程编辑器(飞书流派,2026-10-03 画布退役):卡片树是唯一编辑形态与数据源;
|
||||
// 多出口=审批节点出口按钮+泳道(OutletLanes),结构域封死 → 出图确定性布局。
|
||||
//
|
||||
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
|
||||
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
createEmptyModel,
|
||||
createNotify,
|
||||
createParallel,
|
||||
branchesOf,
|
||||
findItem,
|
||||
removeItem,
|
||||
resolveContainer,
|
||||
@@ -38,7 +40,6 @@ import NotifyDrawer from "./components/NotifyDrawer.vue";
|
||||
import ConditionDrawer from "./components/ConditionDrawer.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();
|
||||
@@ -63,174 +64,9 @@ const userNames = reactive<Record<number, string>>({});
|
||||
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||||
const routers = reactive<RouterOption[]>([]);
|
||||
|
||||
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
|
||||
/**
|
||||
* 旧实现画布/卡片各持一份基准(sourceJson vs model),切换后互相读不到对方改动,"切回去数据变初始化"。
|
||||
* 现统一为:EditorModel 永远持有业务配置(审批人/会签/时限)与卡片结构;
|
||||
* - 进画布:canvasSource = toContentJson(model),节点 id 直接用卡片 uid(convert.ts),天然一一对应;
|
||||
* - 出画布(切卡片/保存/源码应用后):serializeUnified 以 model 为 base 让 bpmn 画布结构性改动
|
||||
* (新增节点/改连线/布局)叠加其上,再经 fromContentJson 反解回卡片树;卡片树不支持的结构(多出口
|
||||
* DAG 等)拒绝切卡片,留在画布编辑。
|
||||
*/
|
||||
/** 编辑形态:canvas=bpmn-js 画布(支持多出口/跨段连线),cards=卡片流 */
|
||||
const editMode = ref<"canvas" | "cards">("canvas");
|
||||
/** 画布侧数据是否可反解为卡片树(载入时探测,决定卡片单选是否可用) */
|
||||
const cardsBroken = ref(false);
|
||||
const bpmnWrap = ref<InstanceType<typeof BpmnCanvas> | null>(null);
|
||||
/** 画布数据源:载入用原始 contentJson;由卡片切回画布时用卡片树投影覆盖 */
|
||||
const canvasSource = ref("");
|
||||
/** 载入时的原始 contentJson(画布上"卡片树不存在"的新画节点配置兜底) */
|
||||
const canvasBase = ref<any>(null);
|
||||
/** 画布上新画节点的配置快照(element id → ApprovalNode 配置),保存时并入 nodes */
|
||||
const canvasConfigs = reactive<Record<string, any>>({});
|
||||
/** 原始 contentJson 快照(画布裁剪兜底与 JsonDrawer 应用基准) */
|
||||
const sourceJson = ref("");
|
||||
|
||||
function onCanvasNode(_el: any): void {
|
||||
// selection.changed 自画布 emit:选中审批/抄送/条件分支节点 → 打开对应配置抽屉
|
||||
const isApproval = _el && _el.type === "bpmn:UserTask";
|
||||
const isNotify = _el && _el.type === "bpmn:ServiceTask";
|
||||
const isCondition = _el && _el.type === "bpmn:ExclusiveGateway";
|
||||
if (!isApproval && !isNotify && !isCondition) return;
|
||||
const id = String(_el.businessObject.id || _el.id);
|
||||
// 条件分支的画布元素 id 是 JSON id(uid+"c" 后缀),卡片树按 uid 查(uid 由本编辑器生成,不带 c 尾)
|
||||
const lookupId = isCondition && id.endsWith("c") ? id.slice(0, -1) : id;
|
||||
const item = findItem(model, lookupId);
|
||||
if (item && (item.kind === "approval" || item.kind === "notify" || item.kind === "condition")) {
|
||||
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
|
||||
openNode(item.uid);
|
||||
return;
|
||||
}
|
||||
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
|
||||
const kind = isNotify ? "notify" : isCondition ? "condition" : "approval";
|
||||
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
||||
if (!canvasConfigs[id]) {
|
||||
canvasConfigs[id] =
|
||||
kind === "notify"
|
||||
? {
|
||||
uid: id,
|
||||
kind: "notify",
|
||||
name: _el.businessObject.name || baseNode.name || "抄送",
|
||||
receivers: baseNode.receivers || { type: "USERS", userIds: [] },
|
||||
}
|
||||
: kind === "condition"
|
||||
? {
|
||||
uid: id,
|
||||
kind: "condition",
|
||||
name: _el.businessObject.name || baseNode.name || "条件判断",
|
||||
routerKey: baseNode.routerKey || "",
|
||||
outlets: [],
|
||||
branches: [],
|
||||
}
|
||||
: {
|
||||
uid: id,
|
||||
kind: "approval",
|
||||
name: _el.businessObject.name || baseNode.name || "审批",
|
||||
assignee: baseNode.assignee || null,
|
||||
countersign: baseNode.countersign || "ANY",
|
||||
deadlineHours: baseNode.deadlineHours || null,
|
||||
};
|
||||
}
|
||||
editingUid.value = id;
|
||||
if (canvasConfigs[id].kind === "notify") {
|
||||
notifyDrawer.value = true;
|
||||
} else if (canvasConfigs[id].kind === "condition") {
|
||||
conditionDrawer.value = true;
|
||||
} else {
|
||||
nodeDrawer.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function canvasRemoveNode(uid: string): void {
|
||||
bpmnWrap.value?.removeNode(uid);
|
||||
delete canvasConfigs[uid];
|
||||
}
|
||||
|
||||
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
|
||||
* 业务配置(审批人/会签/时限/抄送对象)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
||||
function serializeUnified(): string {
|
||||
const canvasJson = snapshotCanvasObject();
|
||||
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
|
||||
(canvasJson.nodes || []).forEach((n: any) => {
|
||||
const cfg = canvasConfigs[String(n.id)];
|
||||
if (!cfg) return;
|
||||
if (cfg.kind === "notify" && n.type === "NOTIFY") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.receivers) n.receivers = cfg.receivers;
|
||||
} else if (cfg.kind === "approval" && n.type === "APPROVAL") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.assignee) n.assignee = cfg.assignee;
|
||||
if (cfg.countersign) n.countersign = cfg.countersign;
|
||||
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
||||
} else if (cfg.kind === "condition" && n.type === "CONDITION") {
|
||||
n.name = cfg.name || n.name;
|
||||
if (cfg.routerKey) n.routerKey = cfg.routerKey;
|
||||
}
|
||||
});
|
||||
return JSON.stringify(canvasJson);
|
||||
}
|
||||
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
|
||||
const defJson = computed(() => toContentJson(model));
|
||||
|
||||
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
|
||||
* code/name/settings 与卡片树审批/抄送节点配置(审批人/会签/时限/抄送对象)以卡片树为准覆盖 */
|
||||
function buildSerializeBase(): any {
|
||||
const modelDef = toContentJson(model);
|
||||
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
|
||||
base.code = model.code.trim();
|
||||
base.name = model.name.trim();
|
||||
base.settings = JSON.parse(JSON.stringify(modelDef.settings));
|
||||
const byId: Record<string, any> = {};
|
||||
(base.nodes || []).forEach((n: any) => {
|
||||
byId[String(n.id)] = n;
|
||||
});
|
||||
// 全层级遍历(含并行分支内节点):只覆盖 id 能对上的,画布独有节点不受影响
|
||||
walkItems(model.list, (item) => {
|
||||
if (item.kind === "approval") {
|
||||
const merged: any = { ...(byId[item.uid] || {}) };
|
||||
merged.id = item.uid;
|
||||
merged.type = "APPROVAL";
|
||||
merged.name = item.name || "审批";
|
||||
if (item.assignee) merged.assignee = JSON.parse(JSON.stringify(item.assignee));
|
||||
if (item.countersign === "ANY") merged.countersign = "ANY";
|
||||
else delete merged.countersign; // 后端缺省即会签,不回写
|
||||
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
|
||||
byId[item.uid] = merged;
|
||||
} else if (item.kind === "notify") {
|
||||
const merged: any = { ...(byId[item.uid] || {}) };
|
||||
merged.id = item.uid;
|
||||
merged.type = "NOTIFY";
|
||||
merged.name = item.name || "抄送";
|
||||
if (item.receivers) merged.receivers = JSON.parse(JSON.stringify(item.receivers));
|
||||
byId[item.uid] = merged;
|
||||
} else if (item.kind === "condition") {
|
||||
// 条件分支的 JSON id 带 c 后缀(convert.ts 生成约定),routerKey/名称随卡片树覆盖
|
||||
const jsonId = `${item.uid}c`;
|
||||
const merged: any = { ...(byId[jsonId] || {}) };
|
||||
merged.id = jsonId;
|
||||
merged.type = "CONDITION";
|
||||
merged.name = item.name || "条件判断";
|
||||
merged.routerKey = item.routerKey || "";
|
||||
byId[jsonId] = merged;
|
||||
}
|
||||
});
|
||||
base.nodes = Object.values(byId);
|
||||
return base;
|
||||
}
|
||||
|
||||
/** 画布未挂载(载入中/刚切换未挂好)时,快照退回卡片树投影 */
|
||||
function snapshotCanvasObject(): any {
|
||||
if (!bpmnWrap.value) return JSON.parse(JSON.stringify(defJson.value));
|
||||
const canvasJson = bpmnWrap.value?.serialize(
|
||||
buildSerializeBase(),
|
||||
model.code.trim(),
|
||||
model.name.trim()
|
||||
);
|
||||
if (!canvasJson) throw new Error("画布不可用");
|
||||
return canvasJson;
|
||||
}
|
||||
|
||||
/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
|
||||
function canonicalJson(content: any): string {
|
||||
const copy: any = JSON.parse(JSON.stringify(content));
|
||||
@@ -249,13 +85,9 @@ function canonicalJson(content: any): string {
|
||||
return JSON.stringify(copy);
|
||||
}
|
||||
|
||||
/** 脏检查基准(载入/保存后刷新);随编辑形态取同一内容的对应投影,切换模式后不因表示差异误报 */
|
||||
/** 脏检查基准(载入/保存后刷新) */
|
||||
const savedSnapshot = ref("");
|
||||
const currentSnapshot = computed(() =>
|
||||
editMode.value === "cards"
|
||||
? canonicalJson(JSON.parse(JSON.stringify(defJson.value)))
|
||||
: canonicalJson(snapshotCanvasObject())
|
||||
);
|
||||
const currentSnapshot = computed(() => canonicalJson(JSON.parse(JSON.stringify(defJson.value))));
|
||||
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
||||
|
||||
const nodeDrawer = ref(false);
|
||||
@@ -266,34 +98,16 @@ const jsonDrawer = ref(false);
|
||||
const editingUid = ref("");
|
||||
|
||||
const editingNode = computed<ApprovalNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "approval") return item;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "approval" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "approval" ? item : null;
|
||||
});
|
||||
|
||||
const editingNotify = computed<NotifyNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "notify") return item;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "notify" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "notify" ? item : null;
|
||||
});
|
||||
|
||||
const editingCondition = computed<ConditionNode | null>(() => {
|
||||
if (editMode.value === "canvas") {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
if (item && item.kind === "condition") return item;
|
||||
const cfg = canvasConfigs[editingUid.value];
|
||||
return cfg && cfg.kind === "condition" ? cfg : null;
|
||||
}
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
return item && item.kind === "condition" ? item : null;
|
||||
});
|
||||
@@ -366,8 +180,7 @@ async function load(): Promise<void> {
|
||||
if (queryId && !queryGuid) editId.value = queryId;
|
||||
|
||||
if (!queryId) {
|
||||
// 新建:画布源取卡片树投影(默认模板的审批节点 uid 与卡片一一对应),不用通用骨架
|
||||
canvasSource.value = JSON.stringify(defJson.value);
|
||||
// 新建:卡片树默认模板起步
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
return;
|
||||
}
|
||||
@@ -377,18 +190,17 @@ async function load(): Promise<void> {
|
||||
sourceVersion.value = d?.version ?? 0;
|
||||
model.code = d?.code || "";
|
||||
model.name = d?.name || "";
|
||||
sourceJson.value = d?.contentJson || "{}";
|
||||
canvasBase.value = safeJson(sourceJson.value);
|
||||
canvasSource.value = sourceJson.value;
|
||||
let parsedOk = true;
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||
} catch (e: any) {
|
||||
// 卡片树反解失败(多出口等结构):留在画布模式,卡片单选禁用
|
||||
parsedOk = false;
|
||||
void e;
|
||||
Modal.error({
|
||||
title: "该定义无法用卡片编辑器打开",
|
||||
content: `内容包含卡片视图不支持的结构(${e?.message || e})。此类结构多为历史画布草稿,请从流程定义列表处理。`,
|
||||
onOk: () => router.replace("/admin/defs"),
|
||||
});
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
cardsBroken.value = !parsedOk;
|
||||
await resolveUserNames();
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
} finally {
|
||||
@@ -396,66 +208,6 @@ async function load(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(s: string): any {
|
||||
try {
|
||||
return JSON.parse(s || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模式切换(双向互通) ====================
|
||||
|
||||
/** 卡片 → 画布:画布源换成卡片树投影(画布重挂载即拿到卡片最新结构/配置) */
|
||||
function toCanvas(): void {
|
||||
canvasSource.value = JSON.stringify(defJson.value);
|
||||
editMode.value = "canvas";
|
||||
bumpSnapshot();
|
||||
}
|
||||
|
||||
/** 画布 → 卡片:画布结构经 fromContentJson 反解回卡片树;不支持的结构拒绝切换 */
|
||||
function toCards(): void {
|
||||
let content = "";
|
||||
try {
|
||||
content = serializeUnified();
|
||||
} catch (e: any) {
|
||||
Message.error(`画布数据读取失败:${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
Message.warning("画布为空,无法切换到卡片视图");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(content)));
|
||||
} catch (e: any) {
|
||||
Message.error(`画布含卡片视图不支持的结构(${e?.message || e}),请继续用画布编辑或先简化结构`);
|
||||
return;
|
||||
}
|
||||
Object.keys(canvasConfigs).forEach((k) => delete canvasConfigs[k]);
|
||||
editMode.value = "cards";
|
||||
bumpSnapshot();
|
||||
}
|
||||
|
||||
/** 切换后按"切换前是否已脏"重置脏基准:干净切换 representations 等价,直接对齐;脏切换保留脏态 */
|
||||
function bumpSnapshot(): void {
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
}
|
||||
|
||||
function onModeSwitch(next: string | number | boolean): void {
|
||||
if (loading.value) return;
|
||||
if (next === editMode.value) return;
|
||||
if (next === "cards" && cardsBroken.value) {
|
||||
Message.warning("该定义包含卡片视图不支持的结构(如多出口/退回环路),请用画布编辑");
|
||||
return;
|
||||
}
|
||||
if (next === "canvas") {
|
||||
toCanvas();
|
||||
} else {
|
||||
toCards();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
|
||||
|
||||
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
|
||||
@@ -485,12 +237,6 @@ function openNode(uid: string): void {
|
||||
}
|
||||
|
||||
function removeNode(uid: string): void {
|
||||
if (editMode.value === "canvas") {
|
||||
// 卡片树是唯一数据源:卡片已有节点从两侧一起删;画布新画节点只删画布+配置快照
|
||||
if (findItem(model, uid)) removeItem(model, uid);
|
||||
canvasRemoveNode(uid);
|
||||
return;
|
||||
}
|
||||
const siblings = containerOfItem(uid);
|
||||
if (siblings && siblings.length <= 1) {
|
||||
Message.warning("每个流程/分支至少保留一个节点");
|
||||
@@ -521,7 +267,7 @@ function insertItem(container: string, index: number, kind: "approval" | "parall
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
||||
/** 添加分支(并行/条件分支/多出口审批容器通用;条件分支与出口同步补一个占位出口名) */
|
||||
function addBranch(containerUid: string): void {
|
||||
const item = findItem(model, containerUid);
|
||||
if (!item) return;
|
||||
@@ -531,18 +277,42 @@ function addBranch(containerUid: string): void {
|
||||
const no = item.branches.length + 1;
|
||||
item.branches.push([createApproval()]);
|
||||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||||
} else if (item.kind === "approval" && item.outlets && item.outlets.length >= 2) {
|
||||
const no = (item.branches?.length || 0) + 1;
|
||||
(item.branches = item.branches || []).push([createApproval()]);
|
||||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBranch(containerUid: string, index: number): void {
|
||||
const item = findItem(model, containerUid);
|
||||
if (!item || (item.kind !== "parallel" && item.kind !== "condition")) return;
|
||||
if (item.branches.length <= 2) {
|
||||
const isOutlet = item.kind === "approval" && !!(item.outlets && item.outlets.length >= 2);
|
||||
if (!item || (item.kind !== "parallel" && item.kind !== "condition" && !isOutlet)) return;
|
||||
const branches = item.branches!;
|
||||
if (branches.length <= 2) {
|
||||
Message.warning("分支至少保留 2 个");
|
||||
return;
|
||||
}
|
||||
item.branches.splice(index, 1);
|
||||
if (item.kind === "condition") item.outlets.splice(index, 1);
|
||||
branches.splice(index, 1);
|
||||
if (item.kind === "condition" || isOutlet) item.outlets!.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 退出多出口:仅保留出口①泳道内容,替换为「本节点(去 outlets) + ①泳道节点序列」 */
|
||||
function exitOutlets(uid: string): void {
|
||||
const rec = (items: EditorItem[]): boolean => {
|
||||
const i = items.findIndex((it) => it.uid === uid);
|
||||
if (i >= 0) {
|
||||
const node = items[i] as ApprovalNode;
|
||||
const lane0 = node.branches?.[0] || [];
|
||||
const clean: ApprovalNode = { ...node };
|
||||
delete clean.outlets;
|
||||
delete clean.branches;
|
||||
items.splice(i, 1, clean, ...lane0);
|
||||
return true;
|
||||
}
|
||||
return items.some((it) => branchesOf(it)?.some((b) => rec(b)) || false);
|
||||
};
|
||||
rec(model.list);
|
||||
}
|
||||
|
||||
provide<EditorCtx>(EDITOR_CTX, {
|
||||
@@ -556,6 +326,7 @@ provide<EditorCtx>(EDITOR_CTX, {
|
||||
insertItem,
|
||||
addBranch,
|
||||
removeBranch,
|
||||
exitOutlets,
|
||||
});
|
||||
|
||||
// ==================== 保存 / 离开 ====================
|
||||
@@ -564,30 +335,9 @@ function onUrgeSave(urge: UrgeSetting): void {
|
||||
model.settings.urge = urge;
|
||||
}
|
||||
|
||||
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
|
||||
watch([nodeDrawer, notifyDrawer, conditionDrawer], ([aOpen, nOpen, cOpen], [aWas, nWas, cWas]) => {
|
||||
const closed = (open: boolean, was: boolean) => !open && was;
|
||||
if (!closed(aOpen, aWas) && !closed(nOpen, nWas) && !closed(cOpen, cWas)) return;
|
||||
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
|
||||
const item = findItem(model, editingUid.value);
|
||||
if (item && item.kind === "approval") {
|
||||
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||
}
|
||||
if (item && item.kind === "notify") {
|
||||
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||
}
|
||||
if (item && item.kind === "condition") {
|
||||
bpmnWrap.value.renameNode(`${item.uid}c`, item.name);
|
||||
}
|
||||
});
|
||||
|
||||
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
|
||||
/** 源码抽屉应用:写回卡片树(应用源码后 isDirty 自然为 true,预期行为) */
|
||||
function onJsonApplied(p: ParsedDef): void {
|
||||
applyParsed(p);
|
||||
if (editMode.value === "canvas") {
|
||||
canvasSource.value = JSON.stringify(defJson.value);
|
||||
bumpSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
@@ -600,36 +350,11 @@ async function onSave(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
const errors = countErrors(issues.value);
|
||||
if (errors.length && editMode.value === "cards") {
|
||||
if (errors.length) {
|
||||
Message.error(`校验未通过:${errors[0].text}`);
|
||||
return;
|
||||
}
|
||||
let content: string;
|
||||
if (editMode.value === "canvas") {
|
||||
try {
|
||||
content = serializeUnified();
|
||||
} catch (e: any) {
|
||||
Message.error(`画布数据读取失败:${e?.message || e}`);
|
||||
return;
|
||||
}
|
||||
const problems = checkCanvas(content);
|
||||
if (problems.length) {
|
||||
Message.error(problems[0]);
|
||||
return;
|
||||
}
|
||||
if (!content || content === "{}") {
|
||||
Message.warning("画布为空,无法保存");
|
||||
return;
|
||||
}
|
||||
// 保存内容同步回卡片树(画布=数据源投影,保存后两侧一致,脏基准重算不误报)
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(content)));
|
||||
} catch {
|
||||
// 理论不可达(checkCanvas 通过即可反解);失败不阻断保存
|
||||
}
|
||||
} else {
|
||||
content = JSON.stringify(toContentJson(model));
|
||||
}
|
||||
const content = JSON.stringify(toContentJson(model));
|
||||
saving.value = true;
|
||||
try {
|
||||
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
|
||||
@@ -648,98 +373,6 @@ 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) + "」缺少审批人配置");
|
||||
}
|
||||
if (n.type === "NOTIFY") {
|
||||
if (!n.name) problems.push("抄送节点未命名");
|
||||
if (!n.receivers) problems.push("抄送节点「" + (n.name || n.id) + "」缺少抄送对象配置");
|
||||
else if (n.receivers.type === "USERS" && !(n.receivers.userIds || []).length) {
|
||||
problems.push("抄送节点「" + (n.name || n.id) + "」未选择用户");
|
||||
}
|
||||
}
|
||||
if (n.type === "CONDITION") {
|
||||
// 条件分支(镜像后端 DefValidator 的 CONDITION 口径):routerKey 必填 + 出口命名唯一 + 出边 ≥2
|
||||
if (!n.routerKey) problems.push("条件分支「" + (n.name || n.id) + "」未配置判断服务(routerKey)");
|
||||
const outs = edges.filter((e: any) => e.from === n.id);
|
||||
if (outs.length < 2) {
|
||||
problems.push("条件分支「" + (n.name || n.id) + "」至少 2 条出边(当前 " + outs.length + ")");
|
||||
} else {
|
||||
const names = new Set<string>();
|
||||
outs.forEach((e: any) => {
|
||||
const label = e.name?.trim() || "";
|
||||
if (!label) {
|
||||
problems.push(`条件分支「${n.name || n.id}」每个出口必须命名(出口名即判断依据)`);
|
||||
} else if (names.has(label)) {
|
||||
problems.push(`条件分支「${n.name || n.id}」出口名重复:「${label}」`);
|
||||
} else {
|
||||
names.add(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
@@ -815,10 +448,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</label>
|
||||
</div>
|
||||
<div class="hd-right">
|
||||
<RadioGroup :model-value="editMode" size="small" type="button" @on-change="onModeSwitch">
|
||||
<Radio label="canvas">画布</Radio>
|
||||
<Radio label="cards" :disabled="cardsBroken">卡片</Radio>
|
||||
</RadioGroup>
|
||||
<Button @click="settingsDrawer = true">流程设置</Button>
|
||||
<Button @click="jsonDrawer = true">源码</Button>
|
||||
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||||
@@ -826,11 +455,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 画布模式:bpmn-js 建模(多出口/并联/退回环路均可画) -->
|
||||
<BpmnCanvas v-if="!loading && editMode === 'canvas'" ref="bpmnWrap"
|
||||
:source="sourceJson" @node-config="onCanvasNode" />
|
||||
|
||||
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
||||
<main v-if="!loading" class="ed-canvas">
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
|
||||
<div class="ed-flow">
|
||||
<div class="capsule start">发起开始</div>
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface ApprovalNode {
|
||||
countersign: Countersign;
|
||||
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
||||
deadlineHours: number | null;
|
||||
/** 多出口(≥2 才有多出口语义):出口按钮文案,办理人按下哪个按钮走哪条泳道;单出口不写字段 */
|
||||
outlets?: string[];
|
||||
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
|
||||
branches?: EditorItem[][];
|
||||
}
|
||||
|
||||
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */
|
||||
@@ -192,9 +196,12 @@ export interface ContainerRef {
|
||||
items: EditorItem[];
|
||||
}
|
||||
|
||||
/** 容器型节点(并行/条件分支)的分支数组取用(resolveContainer/removeItem 等共用口径) */
|
||||
/** 容器型节点(并行/条件分支/多出口审批)的分支数组取用(resolveContainer/removeItem 等共用口径) */
|
||||
export function branchesOf(item: EditorItem | null): EditorItem[][] | null {
|
||||
if (item && (item.kind === "parallel" || item.kind === "condition")) return item.branches;
|
||||
if (item && (item.kind === "parallel" || item.kind === "condition"
|
||||
|| (item.kind === "approval" && item.branches && item.branches.length))) {
|
||||
return item.branches;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -336,6 +343,8 @@ export interface EditorCtx {
|
||||
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
||||
addBranch: (containerUid: string) => void;
|
||||
removeBranch: (containerUid: string, index: number) => void;
|
||||
/** 退出多出口(多出口审批泳道头部按钮):仅保留出口①泳道内容 */
|
||||
exitOutlets: (uid: string) => void;
|
||||
}
|
||||
|
||||
export const EDITOR_CTX: InjectionKey<EditorCtx> = Symbol("flowDefEditorCtx");
|
||||
|
||||
@@ -55,6 +55,26 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
} else if (!a.userIds.length) {
|
||||
push(item.uid, "error", path, "指定用户不能为空");
|
||||
}
|
||||
if (item.outlets && item.outlets.length >= 2) {
|
||||
if (item.outlets.length > MAX_BRANCHES) {
|
||||
push(item.uid, "error", path, `出口数最多 ${MAX_BRANCHES} 个`);
|
||||
}
|
||||
item.outlets.forEach((o, i) => {
|
||||
if (!o.trim()) {
|
||||
push(item.uid, "error", path, `第 ${i + 1} 个出口的按钮文案不能为空`);
|
||||
} else if (item.outlets!.indexOf(o) !== i) {
|
||||
push(item.uid, "error", path, `出口文案重复:「${o}」(按钮文案必须唯一)`);
|
||||
}
|
||||
});
|
||||
(item.branches || []).forEach((branch, i) => {
|
||||
const p = `${path}·出口「${item.outlets![i] || i + 1}」`;
|
||||
if (!branch.length) {
|
||||
push(item.uid, "error", p, "出口泳道为空,请添加节点");
|
||||
return;
|
||||
}
|
||||
rec(branch, p);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,13 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
||||
target: "http://localhost:8085",
|
||||
changeOrigin: true,
|
||||
},
|
||||
// 免签面(嵌入页数据 /open/flow/embed/data、示意图 /open/flow/diagram/img):
|
||||
// 生产前后端同域(Caddy /flow-center 下同源相对路径即达),本地 dev 前后端分端口,
|
||||
// 同理需代理——serviceHost 为空时浏览器请求落在 5173/8088 上无此路由
|
||||
"/open": {
|
||||
target: "http://localhost:8085",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// css预处理器
|
||||
|
||||
Reference in New Issue
Block a user