Compare commits

...
11 Commits
Author SHA1 Message Date
chenxing 5b83fd7144 feat(embed): 嵌入页新增审批记录完整流水表
build / build (push) Successful in 3m42s
2026-10-03 23:52:11 +08:00
chenxing d3a2b08ba2 feat(embed): 新增办件进度嵌入页与无鉴权旁路 2026-10-03 16:51:29 +08:00
chenxing 79866a05e1 fix(flow-editor): NodeList 插入的 OutletLanes 独立 v-if,修被截断的 NotifyCard 条件链(浏览器冒烟发现:无出口审批落进 ConditionBox v-else 分支致 vnode 崩溃) 2026-10-03 16:12:00 +08:00
chenxing 138cb36800 chore(flow-editor)!: 移除 bpmn-js 依赖与画布文件 2026-10-03 15:13:35 +08:00
chenxing ffa196954e refactor(flow-editor)!: 删除画布模式,卡片树成为唯一编辑形态 2026-10-03 14:13:37 +08:00
chenxing 656c0d1bf5 feat(flow-editor): 多出口泳道 UI(抽屉开关+出口路由容器+分支增删/恢复单出口) 2026-10-03 14:09:02 +08:00
chenxing 16717a0867 feat(flow-editor): 多出口审批实时校验(非空唯一/数量上限/泳道递归) 2026-10-03 14:06:19 +08:00
chenxing 2dd7135753 feat(flow-editor): convert 双向支持多出口审批(命名出边⇄出口泳道) 2026-10-03 14:05:39 +08:00
chenxing e057e3bd8b feat(flow-editor): ApprovalNode 增多出口 outlets/branches,遍历链路打通 2026-10-03 13:59:20 +08:00
chenxing 678cb96801 feat(flow-editor): 默认卡片模式,画布降级为高级入口 2026-10-03 13:58:35 +08:00
chenxing 2c65e5adc9 feat(flow): 图示页砍画布视图档,布局改后端 positions 单源 2026-10-03 13:57:49 +08:00
17 changed files with 682 additions and 1396 deletions
-223
View File
@@ -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",
-1
View File
@@ -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
View File
@@ -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">
+8
View File
@@ -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" },
+277
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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>
+44 -5
View File
@@ -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 条`);
+47 -422
View File
@@ -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>
+11 -2
View File
@@ -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;
}
+7
View File
@@ -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预处理器