Compare commits
47
Commits
a4cc8ea2fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed57846f4 | ||
|
|
c93313cdb7 | ||
|
|
f2841e2740 | ||
|
|
cdea9c559b | ||
|
|
5534cd107a | ||
|
|
1e852c78be | ||
|
|
8392635571 | ||
|
|
4849b53541 | ||
|
|
8853c0faa3 | ||
|
|
ba448fc7a0 | ||
|
|
04fd54e945 | ||
|
|
c7e7a7aa85 | ||
|
|
33500e23d6 | ||
|
|
650665224c | ||
|
|
98ea069691 | ||
|
|
1369b103ee | ||
|
|
0542279636 | ||
|
|
fbd428b913 | ||
|
|
859210bf07 | ||
|
|
6998152c88 | ||
|
|
26d93148dc | ||
|
|
070f2f5ee8 | ||
|
|
8962dac7d2 | ||
|
|
0a060ab22a | ||
|
|
ec810afd14 | ||
|
|
e01ef87380 | ||
|
|
94f263d489 | ||
|
|
adcdd1217e | ||
|
|
b96d3d6451 | ||
|
|
dda7336323 | ||
|
|
f0ec500348 | ||
|
|
5cf1ce8d1f | ||
|
|
ecd082bb99 | ||
|
|
2a1ec48b25 | ||
|
|
955c1e55f6 | ||
|
|
9f80630db3 | ||
|
|
fdc661f509 | ||
|
|
ff1c50931a | ||
|
|
8506ad80d7 | ||
|
|
ecd4b9f4ea | ||
|
|
a998e455d2 | ||
|
|
affb1bdeb7 | ||
|
|
43753f4f59 | ||
|
|
ed5f818c1a | ||
|
|
f835965e29 | ||
|
|
e8f9af534b | ||
|
|
acbf601e7a |
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,21 +41,29 @@ jobs:
|
||||
- name: 构建前端(node 容器)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f frontbuild 2>/dev/null || true
|
||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." frontbuild:/build/
|
||||
docker exec frontbuild sh -c '
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c '
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
echo "=== dist 产物 ==="
|
||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||
# 从容器 cp dist 到宿主工作目录
|
||||
docker cp frontbuild:/build/dist ./dist
|
||||
docker rm -f frontbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
echo "BUILD CHECK OK"
|
||||
|
||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||
|
||||
Generated
+1576
-155
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -12,7 +12,12 @@
|
||||
"build:ld": "vite build --mode ld",
|
||||
"build:prod": "vite build --mode prod",
|
||||
"preview": "vite preview",
|
||||
"api:gen": "alova gen -f"
|
||||
"api:gen": "alova gen -f",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test:line-offline": "esbuild tests/line-offline.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outfile=node_modules/.cache/line-offline.test.mjs && node node_modules/.cache/line-offline.test.mjs",
|
||||
"test:unit": "esbuild tests/line-offline.test.ts tests/app-url.test.ts tests/flow-error.test.ts tests/assign-preview.test.ts tests/convert-roundtrip.test.ts tests/flow2-contract.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outbase=tests --outdir=node_modules/.cache/unit && node --test node_modules/.cache/unit",
|
||||
"test:component": "vitest run",
|
||||
"test": "npm run test:unit && npm run test:component"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-file-viewer/core": "^0.1.43",
|
||||
@@ -28,7 +33,7 @@
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"pinia": "^2.1.4",
|
||||
"sass": "^1.64.1",
|
||||
"view-ui-plus": "^1.3.16",
|
||||
"view-ui-plus": "1.3.24",
|
||||
"vite-plugin-mock": "2.9.6",
|
||||
"vue": "^3.4.19",
|
||||
"vue-router": "4",
|
||||
@@ -40,13 +45,16 @@
|
||||
"@unocss/preset-rem-to-px": "^66.6.0",
|
||||
"@unocss/preset-uno": "^66.6.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"fast-glob": "^3.3.2",
|
||||
"jsdom": "25.0.1",
|
||||
"typescript": "^5.2.2",
|
||||
"unocss": "^66.6.0",
|
||||
"unplugin-auto-import": "0.18.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vitest": "2.1.9",
|
||||
"vue-tsc": "3.3.13"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+21
-10
@@ -5,22 +5,29 @@ import { useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { isBareTaskRoute } from "@/utils/taskEmbed";
|
||||
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 isTokenEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
// 去壳任务页(/task/{taskId}?embed=1,v2.1 完整办理内嵌):只去菜单壳,CAS 一律保留——
|
||||
// 办理是写操作,旁路鉴权等于把审批动作挂在免签 URL 上。与 token 页共享"无壳"渲染,
|
||||
// 但鉴权分支只走 isTokenEmbed
|
||||
const isBareTask = computed(() =>
|
||||
isBareTaskRoute(router.currentRoute.value.path, router.currentRoute.value.query.embed));
|
||||
const isShellless = computed(() => isTokenEmbed.value || isBareTask.value);
|
||||
|
||||
// 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
|
||||
// 上面 isEmbed 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
|
||||
function syncEmbedLayout(embed: boolean) {
|
||||
if (embed) document.body.style.minWidth = "0";
|
||||
else document.body.style.removeProperty("min-width");
|
||||
}
|
||||
watch(isEmbed, syncEmbedLayout, { immediate: true });
|
||||
watch(isShellless, syncEmbedLayout, { immediate: true });
|
||||
onBeforeUnmount(() => syncEmbedLayout(false));
|
||||
|
||||
const layoutData = reactive({
|
||||
@@ -37,8 +44,9 @@ onMounted(async () => {
|
||||
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||
await router.isReady();
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
||||
if (isEmbed.value) {
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
|
||||
// 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
|
||||
if (isTokenEmbed.value) {
|
||||
isLogin.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +105,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 嵌入页:裸渲染,不套 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 }">
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
|
||||
token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
|
||||
否则未登录时 iframe 里先闪一个能点的办理面板 -->
|
||||
<router-view v-if="isTokenEmbed" />
|
||||
<router-view v-else-if="isBareTask && isLogin" />
|
||||
<template v-else-if="isLogin">
|
||||
<layout :layoutData="layoutData" />
|
||||
<div class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
+195
-43
@@ -13,16 +13,18 @@ function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
|
||||
// alova 的请求体是 Method 构造的第 5 个参数(instance.data),不是 config 上的键:
|
||||
// 写进 config 的 body/data 都会被适配器忽略,POST 将发出空 body。
|
||||
function post<T = any>(url: string, body?: any): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url, {}, body);
|
||||
// 第 4 个参数 config 可带 silentBizToast:调用方自己承担错误提示时,不再叠加全局 toast。
|
||||
function post<T = any>(url: string, body?: any, config?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url, config || {}, body);
|
||||
}
|
||||
|
||||
/** 状态枚举(后端 FlwEnums 口径) */
|
||||
/** 状态枚举(后端 FlwEnums 口径;50=D14 退回待修改,仍是在途非终态) */
|
||||
export const INSTANCE_STATUS: Record<number, string> = {
|
||||
10: "审批中",
|
||||
20: "已完成",
|
||||
30: "已拒绝",
|
||||
40: "已撤回",
|
||||
50: "退回待修改",
|
||||
};
|
||||
|
||||
/** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */
|
||||
@@ -31,6 +33,7 @@ export const INSTANCE_STATUS_COLOR: Record<number, string> = {
|
||||
20: "green",
|
||||
30: "red",
|
||||
40: "gray",
|
||||
50: "orange",
|
||||
};
|
||||
|
||||
export const TASK_STATUS: Record<number, string> = {
|
||||
@@ -59,20 +62,20 @@ export const TASK_STATUS_COLOR: Record<number, string> = {
|
||||
|
||||
class FlowApi {
|
||||
// ==================== 用户面 ====================
|
||||
/** 我的待办 */
|
||||
todo(params: { page?: number; size?: number }) {
|
||||
/** 我的待办(appKey=按应用筛选,可空) */
|
||||
todo(params: { page?: number; size?: number; appKey?: string }) {
|
||||
return get(`${ROOT}/todo`, params);
|
||||
}
|
||||
/** 我的已办 */
|
||||
done(params: { page?: number; size?: number }) {
|
||||
/** 我的已办(appKey=按应用筛选,可空) */
|
||||
done(params: { page?: number; size?: number; appKey?: string }) {
|
||||
return get(`${ROOT}/done`, params);
|
||||
}
|
||||
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
|
||||
ccMy(params: { page?: number; size?: number }) {
|
||||
ccMy(params: { page?: number; size?: number; appKey?: string }) {
|
||||
return get(`${ROOT}/cc/my`, params);
|
||||
}
|
||||
/** 我提交的办件 */
|
||||
my(params: { status?: number; page?: number; size?: number }) {
|
||||
my(params: { status?: number; page?: number; size?: number; appKey?: string }) {
|
||||
return get(`${ROOT}/my`, params);
|
||||
}
|
||||
/** 办件详情(提交人/办理人/本校管理员可见) */
|
||||
@@ -84,28 +87,39 @@ class FlowApi {
|
||||
return get(`${ROOT}/task/${taskId}/detail`);
|
||||
}
|
||||
/** 同意(多出口节点必须传 edgeName=所选出口名,单出口可省) */
|
||||
approve(taskId: number | string, opinion?: string, edgeName?: string) {
|
||||
return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null, edgeName: edgeName || null });
|
||||
approve(taskId: number | string, opinion?: string, edgeName?: string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null, edgeName: edgeName || null }, config);
|
||||
}
|
||||
/** 退回(targetNodeId 可选=缺省最近已办主线节点;opinion 可选) */
|
||||
returnBack(taskId: number | string, targetNodeId?: string, opinion?: string) {
|
||||
return post(`${ROOT}/task/${taskId}/returnBack`, { targetNodeId: targetNodeId || null, opinion: opinion || null });
|
||||
returnBack(taskId: number | string, targetNodeId?: string, opinion?: string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/task/${taskId}/returnBack`, { targetNodeId: targetNodeId || null, opinion: opinion || null }, config);
|
||||
}
|
||||
/**
|
||||
* D14 退回发起人修改(2026-10-29):与普通"退回到上游审批节点"是两件事。
|
||||
* 服务端把办件置 50 退回待修改并整体失效在途代次;发起人重提后同实例同轮次从第一级重审。
|
||||
*/
|
||||
returnToSubmitter(taskId: number | string, opinion: string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/task/${taskId}/returnToSubmitter`, { opinion }, config);
|
||||
}
|
||||
/** 发起人重提(仅 50 退回待修改状态、仅发起人本人;同实例同轮次) */
|
||||
resubmit(instanceId: number | string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/instance/${instanceId}/resubmit`, {}, config);
|
||||
}
|
||||
/** 移交(toUserId 必填;reason 可选) */
|
||||
transfer(taskId: number | string, toUserId: number, reason?: string) {
|
||||
return post(`${ROOT}/task/${taskId}/transfer`, { toUserId, reason: reason || null });
|
||||
transfer(taskId: number | string, toUserId: number, reason?: string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/task/${taskId}/transfer`, { toUserId, reason: reason || null }, config);
|
||||
}
|
||||
/** 拒绝(opinion 必填) */
|
||||
reject(taskId: number | string, opinion: string) {
|
||||
return post(`${ROOT}/task/${taskId}/reject`, { opinion });
|
||||
reject(taskId: number | string, opinion: string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/task/${taskId}/reject`, { opinion }, config);
|
||||
}
|
||||
/** 撤回(提交人;仅尚无任务被办理时) */
|
||||
withdraw(instanceId: number | string) {
|
||||
return post(`${ROOT}/instance/${instanceId}/withdraw`);
|
||||
withdraw(instanceId: number | string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/instance/${instanceId}/withdraw`, {}, config);
|
||||
}
|
||||
/** 催办(提交人/管理员) */
|
||||
urge(instanceId: number | string) {
|
||||
return post(`${ROOT}/instance/${instanceId}/urge`);
|
||||
urge(instanceId: number | string, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/instance/${instanceId}/urge`, {}, config);
|
||||
}
|
||||
|
||||
// ==================== 用户面:流程示意图 ====================
|
||||
@@ -126,12 +140,54 @@ class FlowApi {
|
||||
defAppKeys() {
|
||||
return get(`${ROOT}/admin/def/appkeys`);
|
||||
}
|
||||
|
||||
// ==================== 管理面:应用设置(2026-10-10) ====================
|
||||
|
||||
/** 应用管理列表(flw_app 白名单;附带学校池静默同步,UserBase 不可达降级本地行) */
|
||||
appList() {
|
||||
return get(`${ROOT}/admin/app/list`);
|
||||
}
|
||||
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用) */
|
||||
appEnable(id: number | string, enabled: boolean) {
|
||||
return post(`${ROOT}/admin/app/enable`, { id, enabled });
|
||||
}
|
||||
/** 应用辅助统计:appKey → 回调注册数(本校) */
|
||||
appStats() {
|
||||
return get(`${ROOT}/admin/app/stats`);
|
||||
}
|
||||
defGet(id: number | string) {
|
||||
return get(`${ROOT}/admin/def/get`, { id });
|
||||
}
|
||||
defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string; appKey?: string }) {
|
||||
/**
|
||||
* 表单中心目录(v3/TASK_1):编辑器"关联表单"选择器的数据源。
|
||||
* 走本应用同源代理,浏览器不持 appKey/secret、也不出现 form-center 地址;
|
||||
* **没有 schId 入参**——管理域由后端从 CAS 会话派生,这是有意的,别"顺手加一个"。
|
||||
*/
|
||||
formCenterForms(params: { keyword?: string; page?: number; pageSize?: number } = {}) {
|
||||
return get<{ items: any[]; total: number }>(`${ROOT}/admin/form-center/forms`, params);
|
||||
}
|
||||
/**
|
||||
* 保存定义。`formUuid`/`formVersion`(v3)是**行字段**、不进 contentJson:
|
||||
* 成对提交(后端单传一个即拒);`formUuid=""`=取消关联并同时清空版本;不传=保持原关联。
|
||||
*/
|
||||
defSave(body: {
|
||||
id?: number;
|
||||
/** 内部编码:可缺省=由后端生成稳定唯一码(A3);编辑时回传既有编码保持不变 */
|
||||
code?: string;
|
||||
name: string;
|
||||
contentJson: string;
|
||||
baseGuid?: string;
|
||||
appKey?: string;
|
||||
changeLog?: string;
|
||||
formUuid?: string;
|
||||
formVersion?: number;
|
||||
}) {
|
||||
return post(`${ROOT}/admin/def/save`, body);
|
||||
}
|
||||
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
|
||||
defUpdateMeta(body: { id: number; name: string; appKey?: string }) {
|
||||
return post(`${ROOT}/admin/def/updateMeta`, body);
|
||||
}
|
||||
defPublish(id: number | string) {
|
||||
return post(`${ROOT}/admin/def/publish/${id}`);
|
||||
}
|
||||
@@ -142,28 +198,108 @@ class FlowApi {
|
||||
defEnable(id: number | string) {
|
||||
return post(`${ROOT}/admin/def/enable/${id}`);
|
||||
}
|
||||
/**
|
||||
* 流程线级停发(T03):整条 flowGuid 停止新发起/预览,在途实例按原版本跑完;
|
||||
* 与版本级 status 正交,返回受影响版本行数。
|
||||
*/
|
||||
defOfflineLine(flowGuid: string, config?: Record<string, any>) {
|
||||
return post<number>(`${ROOT}/admin/def/offlineLine`, { flowGuid }, config);
|
||||
}
|
||||
/** 流程线级恢复(T03):只恢复线可用性,不改写任何版本状态或发布内容;返回受影响版本行数 */
|
||||
defEnableLine(flowGuid: string, config?: Record<string, any>) {
|
||||
return post<number>(`${ROOT}/admin/def/enableLine`, { flowGuid }, config);
|
||||
}
|
||||
defVersions(flowGuid: string) {
|
||||
return get(`${ROOT}/admin/def/versions`, { flowGuid });
|
||||
}
|
||||
/** 在途办件数(议题二④):flowGuid → 审批中办件数,无在途的流程线也返回 0 键 */
|
||||
defInflight(flowGuids: string[]) {
|
||||
return get(`${ROOT}/admin/def/inflight`, { flowGuids: flowGuids.join(",") });
|
||||
}
|
||||
/** 流程定义示意图(管理面:defId 任意版本,草稿可预览) */
|
||||
adminDefDiagram(params: { id: number | string; mode?: string }) {
|
||||
return get(`${ROOT}/admin/def/diagram`, params);
|
||||
}
|
||||
|
||||
// ==================== 管理面:办理人映射 ====================
|
||||
mapList(deptKey?: string) {
|
||||
return get(`${ROOT}/admin/map/list`, deptKey ? { deptKey } : undefined);
|
||||
// ==================== 管理面:部门目录(C1,UserBase 权威来源) ====================
|
||||
/** 第一级「部门组」:系统组织(SYSTEM)+ 该校自定义分类(CUSTOM) */
|
||||
deptGroups() {
|
||||
return get<{ departmentType: string; categoryId: number | null; name: string; departmentCount: number }[]>(
|
||||
`${ROOT}/admin/department/groups`);
|
||||
}
|
||||
mapSave(body: { deptKey: string; deptName?: string; userId: number; userName?: string }) {
|
||||
return post(`${ROOT}/admin/map/save`, body);
|
||||
/** 第二级可选部门(缺省只返回启用且未软删者) */
|
||||
deptOptions(params: { type?: string; categoryId?: number; withChildren?: boolean } = {}) {
|
||||
return get<{ departmentType: string; departmentId: number; name: string; parentId: number | null;
|
||||
categoryId: number | null; categoryName: string | null; enabled: boolean; deleted: boolean;
|
||||
selectable: boolean; memberCount: number }[]>(`${ROOT}/admin/department/options`, params);
|
||||
}
|
||||
mapDisable(id: number | string) {
|
||||
return post(`${ROOT}/admin/map/disable/${id}`);
|
||||
/** 已存引用的名称/状态回显(含停用与软删=UserBase resolve-batch 历史口径) */
|
||||
deptResolve(type: string, departmentId: number) {
|
||||
return get<any[]>(`${ROOT}/admin/department/resolve`, { type, departmentId });
|
||||
}
|
||||
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
|
||||
mapEnable(id: number | string) {
|
||||
return post(`${ROOT}/admin/map/enable/${id}`);
|
||||
/** 部门成员(只读预览;scope=select 新指派严档 / history 既有实例宽档) */
|
||||
deptDirectoryMembers(type: string, departmentId: number, scope?: "select" | "history") {
|
||||
return get<any[]>(`${ROOT}/admin/department/members`, { type, departmentId, scope });
|
||||
}
|
||||
|
||||
// ==================== 管理面:应用使用许可(D02/D09/D13 独立授权) ====================
|
||||
/** 某流程线的授权清单(含已撤销行,审计可见) */
|
||||
grantList(flowGuid: string) {
|
||||
return get<any[]>(`${ROOT}/admin/usage-grant/list`, { flowGuid });
|
||||
}
|
||||
/** 本校全部授权行(校域由管理员会话派生,前端不传 schId) */
|
||||
grantSchoolList() {
|
||||
return get<any[]>(`${ROOT}/admin/usage-grant/schoolList`);
|
||||
}
|
||||
/** 流程线 → 生效授权应用数(列表页授权摘要列) */
|
||||
grantSummary() {
|
||||
return get<Record<string, number>>(`${ROOT}/admin/usage-grant/summary`);
|
||||
}
|
||||
/** 授予/重申使用许可(appKey 由后端解析成稳定主体;学校来自管理员会话) */
|
||||
grantSet(body: { flowGuid: string; appKey: string; remark?: string }, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/admin/usage-grant/grant`, body, config);
|
||||
}
|
||||
/** 撤销使用许可(enabled=0 行保留;只拦新发起,在途与已受理回执不受影响) */
|
||||
grantRevoke(body: { flowGuid: string; appId: number; remark?: string }, config?: Record<string, any>) {
|
||||
return post(`${ROOT}/admin/usage-grant/revoke`, body, config);
|
||||
}
|
||||
|
||||
// ==================== 管理面:科室管理(2026-10-10,取代旧 /map/* 映射 CRUD) ====================
|
||||
/** 科室列表(deptKey=自增 id 字符串 + 启用成员数;编辑器 ctx.depts 同结构) */
|
||||
deptList() {
|
||||
return get(`${ROOT}/admin/dept/list`);
|
||||
}
|
||||
deptCreate(deptName: string) {
|
||||
return post(`${ROOT}/admin/dept/create`, { deptName });
|
||||
}
|
||||
deptRename(id: number | string, deptName: string) {
|
||||
return post(`${ROOT}/admin/dept/rename`, { id, deptName });
|
||||
}
|
||||
deptStatus(id: number | string, enabled: boolean) {
|
||||
return post(`${ROOT}/admin/dept/status`, { id, enabled });
|
||||
}
|
||||
deptDelete(id: number | string) {
|
||||
return post(`${ROOT}/admin/dept/delete`, { id });
|
||||
}
|
||||
deptMembers(deptId: number | string) {
|
||||
return get(`${ROOT}/admin/dept/members`, { deptId });
|
||||
}
|
||||
/** 批量加成员(返回新增数) */
|
||||
deptMemberAdd(deptId: number | string, userIds: number[]) {
|
||||
return post(`${ROOT}/admin/dept/memberAdd`, { deptId, userIds });
|
||||
}
|
||||
deptMemberRemove(memberId: number | string) {
|
||||
return post(`${ROOT}/admin/dept/memberRemove`, { memberId });
|
||||
}
|
||||
/** 一键拉取教师组·预览(不落库,确认后导入) */
|
||||
deptGroupPreview() {
|
||||
return get(`${ROOT}/admin/dept/groupPreview`);
|
||||
}
|
||||
/** 一键拉取教师组·导入(source_group_id 幂等;重复拉取=刷新组名与成员集) */
|
||||
deptImportGroups() {
|
||||
return post(`${ROOT}/admin/dept/importGroups`);
|
||||
}
|
||||
/** 成员检索(UserBase byId;编辑器选人/姓名补全共用,原 /map/lookupUser 通道保留) */
|
||||
mapLookupUser(userId: number) {
|
||||
return get(`${ROOT}/admin/map/lookupUser`, { userId });
|
||||
}
|
||||
@@ -175,14 +311,6 @@ class FlowApi {
|
||||
adminTeacherTree() {
|
||||
return get(`${ROOT}/admin/user/teacherTree`);
|
||||
}
|
||||
/** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */
|
||||
mapGroupPreview() {
|
||||
return get(`${ROOT}/admin/map/groupPreview`);
|
||||
}
|
||||
/** 一键拉取教师组·导入(批量落映射,已存在跳过) */
|
||||
mapImportGroups() {
|
||||
return post(`${ROOT}/admin/map/importGroups`);
|
||||
}
|
||||
|
||||
// ==================== 管理面:回调注册/日志/死信 ====================
|
||||
regList() {
|
||||
@@ -194,6 +322,10 @@ class FlowApi {
|
||||
regResetSecret(id: number | string) {
|
||||
return post(`${ROOT}/admin/callback/resetSecret/${id}`);
|
||||
}
|
||||
/** 回调探测:对该注册同步推一条 flow.ping(未知事件码),返回 {success,httpStatus,elapsedMs,respSnippet,error} */
|
||||
callbackProbe(id: number | string) {
|
||||
return post(`${ROOT}/admin/callback/probe/${id}`);
|
||||
}
|
||||
callbackLogs(params: { status?: number; event?: string; instanceId?: number; page?: number; size?: number }) {
|
||||
return get(`${ROOT}/admin/callback/logs`, params);
|
||||
}
|
||||
@@ -211,6 +343,10 @@ class FlowApi {
|
||||
callbackRepush(ids: number[]) {
|
||||
return post(`${ROOT}/admin/callback/repush`, ids);
|
||||
}
|
||||
/** 对接健康度体检(2026-10-15 P2):每应用一行六项检查 + 综合评分,零新增表 */
|
||||
callbackHealth() {
|
||||
return get(`${ROOT}/admin/callback/health`);
|
||||
}
|
||||
|
||||
// ==================== 管理面:条件路由注册 / 重跑 ====================
|
||||
|
||||
@@ -232,15 +368,31 @@ class FlowApi {
|
||||
routerStatus(id: number | string, enabled: boolean) {
|
||||
return post(`${ROOT}/admin/router/status/${id}?enabled=${enabled}`);
|
||||
}
|
||||
/** 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走) */
|
||||
instanceReroute(instanceId: number | string) {
|
||||
return post(`${ROOT}/admin/instance/${instanceId}/reroute`);
|
||||
/**
|
||||
* 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走)。
|
||||
* useLatestDef=true(F5-4 僵尸逃生门):按该流程当前已发布最高版本重跑并刷新办件定义快照。
|
||||
*/
|
||||
instanceReroute(instanceId: number | string, useLatestDef?: boolean) {
|
||||
const suffix = useLatestDef ? "?useLatestDef=true" : "";
|
||||
return post(`${ROOT}/admin/instance/${instanceId}/reroute${suffix}`);
|
||||
}
|
||||
/** 表达式调试求值(2026-10-10:与运行时同一求值核心;不落库不走流程) */
|
||||
routerEval(body: { expression: string; variablesJson?: string }) {
|
||||
return post(`${ROOT}/admin/router/eval`, body);
|
||||
}
|
||||
|
||||
// ==================== 管理面:办件查询 ====================
|
||||
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
|
||||
return get(`${ROOT}/admin/instance/page`, params);
|
||||
}
|
||||
/**
|
||||
* 发起时指派预览(F6 共享选择器数据源;与 oapi /oapi/flow/apply/preview 同形:
|
||||
* {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]})。
|
||||
* 纯预置:后端 /api 代理端点属 F6 正式接入批次,接线时同批点亮(FlowAssignSelector 当前为备用组件)。
|
||||
*/
|
||||
applyPreview(flowGuid: string) {
|
||||
return get(`${ROOT}/apply/preview`, { flowGuid });
|
||||
}
|
||||
}
|
||||
|
||||
export const flowApi = new FlowApi();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -98,20 +98,100 @@ h4.subtitle {
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||
.table-spin-load {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
padding-top: 60px;
|
||||
background-image: url(../image/loading.gif);
|
||||
background-size: 60px 60px;
|
||||
background-position: top center;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
color: #577afb;
|
||||
color: var(--brand);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.table-spin-load .ysch-spin {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-left: -30px;
|
||||
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
|
||||
}
|
||||
|
||||
.ivu-table-cell-tree {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ================= 表格操作列(utils/tableActions.ts)=================
|
||||
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
|
||||
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
|
||||
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
|
||||
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
|
||||
.op-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
|
||||
.operating-link {
|
||||
padding-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 固定列层不吞下层事件 =================
|
||||
根因(2026-10-14 生产实测):iView 分组表外层「操作」列 fixed:right 时,固定层是全表覆盖的
|
||||
独立副本;展开行处它只渲染一个 visibility:hidden 的占位单元格(用来对齐行高)。该处命中
|
||||
测试落到固定层的 <table> 上(占位 td 因 visibility:hidden 不可命中),于是展开子表落在
|
||||
固定条带内的「更多」等控件 hover/click 全部失效——表现为「看得见、点不动」。
|
||||
固定层只做视觉覆盖,容器层 pointer-events:none、真实单元格恢复 auto,空白处(含展开行
|
||||
占位)事件自然穿透到下层;滚轮/行 hover 靠事件冒泡与 vue 侧 scroll 同步,不受影响。 */
|
||||
.ivu-table-fixed,
|
||||
.ivu-table-fixed-right {
|
||||
pointer-events: none;
|
||||
|
||||
th,
|
||||
td,
|
||||
.ivu-table-cell {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 省略号单元格内的 Tooltip =================
|
||||
iView 的 Tooltip 根节点与 .ivu-tooltip-rel 都是 inline-block(见 tooltip.less),
|
||||
放进 column.ellipsis 单元格时会把 text-overflow:ellipsis 变成**硬裁切**(省略号不渲染,
|
||||
2026-10-14 无头 Edge 实测 A/B 对照:裸文本有「…」,两层 inline-block 包裹则无)。
|
||||
故仅在省略号单元格内把它们降级为 inline —— 保持省略号,popper 定位仍按 rect 计算不受影响。 */
|
||||
.ivu-table-cell-ellipsis {
|
||||
.ivu-tooltip,
|
||||
.ivu-tooltip-rel {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
|
||||
.op-more {
|
||||
.op-more-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.op-more-arrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「更多」菜单内危险项(红色 + hover 红底,与 text-error 同源 $color-error) */
|
||||
.ivu-dropdown-item.op-item-danger {
|
||||
color: $color-error !important;
|
||||
|
||||
&:hover {
|
||||
color: $color-error !important;
|
||||
background-color: rgba(232, 104, 74, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* FL 侧选人 iframe 宿主(验收 P2-2 协议收口)。
|
||||
*
|
||||
* 用法(业务页,不是 FL 自己的产品入口):
|
||||
* <FlowAssignEmbed ref="picker" :flow-guid="guid" @confirmed="onPicked" @cancelled="onCancel" />
|
||||
* picker.value.open()
|
||||
*
|
||||
* 语义(与既定架构一致):
|
||||
* - 确认=把 iframe 内选择的最终结果交回宿主页面内存;宿主何时提交仍由业务调 FL 正式接口决定,
|
||||
* 前端选择结果不是授权凭据,最终资格由 FL 服务端复核。
|
||||
* - 取消/直接关框=只通知宿主,不改宿主已有的旧选择。
|
||||
* - 重新打开后确认=以最近一次为准(B 替换 A)。
|
||||
* - 收消息四道校验:event.origin、event.source(必须是本组件那个 iframe 的窗口)、
|
||||
* 协议信封(proto/v/type)、本次 contextId;任一不过直接丢弃(旧会话迟到消息不会串台)。
|
||||
* - 关闭即摘监听,重开换新 contextId。
|
||||
*/
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { Modal } from "view-ui-plus";
|
||||
import { isValidAssignMessage } from "@/utils/assignEmbed";
|
||||
import type { AssignEmbedMessage } from "@/utils/assignEmbed";
|
||||
import { toAppHashUrl } from "@/utils/appUrl";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 要预览/选择的流程线 */
|
||||
flowGuid: string;
|
||||
/** 弹框标题 */
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 用户在 iframe 内点了确认:assigns 为最终选择,defVersion 为预览时的版本锚 */
|
||||
confirmed: [payload: { flowGuid: string; defVersion?: number; assigns: { nodeId: string; userIds: number[] }[] }];
|
||||
/** 取消或宿主关框:宿主应保持原有选择,不做任何覆盖 */
|
||||
cancelled: [];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const frameUrl = ref("");
|
||||
const contextId = ref("");
|
||||
const frameEl = ref<HTMLIFrameElement | null>(null);
|
||||
/** 本次打开锁定的父↔子通道条件;非响应式:只在 open 时刷新 */
|
||||
let expectedOrigin = "";
|
||||
let expectedFlowGuid = "";
|
||||
let listening = false;
|
||||
/** 本次会话是否已经收口(确认或取消各只允许一次),用于区分"宿主自己关框"与重复关闭 */
|
||||
let settled = false;
|
||||
/** 框是否处于本次打开的生命周期内:close/X/遮罩/程序关闭统一走同一 settle 路径 */
|
||||
let opened = false;
|
||||
|
||||
function newContextId(): string {
|
||||
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function closeListener(): void {
|
||||
if (listening) {
|
||||
window.removeEventListener("message", onMessage);
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(ev: MessageEvent): void {
|
||||
if (!opened || settled || !listening) return;
|
||||
const msg: AssignEmbedMessage | null = isValidAssignMessage(
|
||||
ev,
|
||||
expectedOrigin,
|
||||
frameEl.value?.contentWindow ?? null,
|
||||
contextId.value,
|
||||
expectedFlowGuid,
|
||||
);
|
||||
if (!msg) return;
|
||||
settled = true;
|
||||
opened = false;
|
||||
closeListener();
|
||||
visible.value = false;
|
||||
if (msg.type === "assign-cancelled") {
|
||||
emit("cancelled");
|
||||
return;
|
||||
}
|
||||
emit("confirmed", {
|
||||
// buildMessage 已保证确认消息必带非空 flowGuid;兜底 props 只为满足类型,不是信任来源
|
||||
flowGuid: msg.flowGuid || props.flowGuid,
|
||||
defVersion: msg.defVersion,
|
||||
assigns: msg.assigns ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
if (!props.flowGuid) return;
|
||||
// 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效
|
||||
closeListener();
|
||||
settled = false;
|
||||
expectedFlowGuid = props.flowGuid;
|
||||
opened = true;
|
||||
contextId.value = newContextId();
|
||||
const query = `flowGuid=${encodeURIComponent(props.flowGuid)}&contextId=${encodeURIComponent(contextId.value)}`;
|
||||
frameUrl.value = toAppHashUrl(`#/assign-preview?${query}`);
|
||||
expectedOrigin = new URL(frameUrl.value, window.location.href).origin;
|
||||
visible.value = true;
|
||||
window.addEventListener("message", onMessage);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 唯一收口路径(F07):宿主程序关闭、点 X、点遮罩都进这里。
|
||||
* 已确认/已取消的会话不再补发第二次;未答复过的关闭按"取消"通知宿主(不覆盖原有选择)。
|
||||
*/
|
||||
function settleAsCancel(): void {
|
||||
closeListener();
|
||||
visible.value = false;
|
||||
if (opened && !settled) {
|
||||
settled = true;
|
||||
opened = false;
|
||||
emit("cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
// v-model 把 X/遮罩置 false 时也会走这里;只保证监听被摘,emit 由 settleAsCancel 负责,避免双发
|
||||
watch(visible, (val) => {
|
||||
if (!val) settleAsCancel();
|
||||
});
|
||||
|
||||
watch(() => props.flowGuid, settleAsCancel);
|
||||
onBeforeUnmount(settleAsCancel);
|
||||
|
||||
defineExpose({ open, close: settleAsCancel, contextId });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visible"
|
||||
:title="title || '选择办理人'"
|
||||
:width="760"
|
||||
:footer-hide="true"
|
||||
class="assign-embed-modal"
|
||||
@on-cancel="settleAsCancel"
|
||||
>
|
||||
<iframe
|
||||
v-if="frameUrl"
|
||||
ref="frameEl"
|
||||
:src="frameUrl"
|
||||
class="assign-embed-frame"
|
||||
frameborder="0"
|
||||
title="选择办理人"
|
||||
/>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-embed-frame {
|
||||
width: 100%;
|
||||
height: 460px;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F6 发起时指派·共享选择器(纯备用组件,2026-10-18 P6.6;未接入任何页面)。
|
||||
*
|
||||
* 用法(未来接入发起页时):
|
||||
* <FlowAssignSelector ref="assignSelector" :flow-guid="boundFlowGuid" />
|
||||
* 提交前:const assigns = assignSelector.value?.collect(); // [{nodeId, userIds}],中心 ApplyBody.assigns 同构
|
||||
* 校验:const err = assignSelector.value?.validate(); // 非空字符串=未通过(必填/数量约束)
|
||||
* 重提/换线场景:flowGuid 变化自动重拉(watch),已选清空并提示重选。
|
||||
*
|
||||
* 数据源:api/module/flow.ts 的 applyPreview(后端 /api 代理端点属 F6 正式接入批次,
|
||||
* 当前组件与 API 方法均为预置,页面接线时后端同批补端点即可点亮)。
|
||||
* 渲染规则:只渲染开启指派的节点(preview.nodes 已过滤);countersign/selection.mode
|
||||
* 决定单选(SINGLE)/多选(MULTI);required 必填标 *;min/max 数量约束在 validate() 收口。
|
||||
*/
|
||||
import { ref, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
const props = defineProps<{ flowGuid: string }>();
|
||||
|
||||
interface PreviewUser { userId: number; userName: string }
|
||||
interface PreviewSelection { mode: string; min?: number; max?: number; required?: boolean }
|
||||
interface PreviewNode {
|
||||
nodeId: string;
|
||||
nodeName?: string;
|
||||
countersign?: string;
|
||||
selection?: PreviewSelection;
|
||||
candidates: PreviewUser[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const nodes = ref<PreviewNode[]>([]);
|
||||
/** 已选:nodeId → userIds(单选也用数组,collect 统一输出) */
|
||||
const selected = ref<Record<string, number[]>>({});
|
||||
|
||||
async function reload() {
|
||||
if (!props.flowGuid) {
|
||||
nodes.value = [];
|
||||
selected.value = {};
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
const prev = selected.value;
|
||||
try {
|
||||
const res: any = await flowApi.applyPreview(props.flowGuid).send();
|
||||
nodes.value = res?.nodes || [];
|
||||
selected.value = {};
|
||||
if (Object.keys(prev).length > 0) {
|
||||
Message.info("流程定义已切换,指派选择已重置,请重新选择审批人");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.flowGuid, reload, { immediate: true });
|
||||
|
||||
/** 输出标准 assigns:[{nodeId, userIds}](只含有选择的节点;无指派节点=空数组) */
|
||||
function collect(): { nodeId: string; userIds: number[] }[] {
|
||||
return nodes.value
|
||||
.filter((n) => (selected.value[n.nodeId] || []).length > 0)
|
||||
.map((n) => ({ nodeId: n.nodeId, userIds: [...selected.value[n.nodeId]] }));
|
||||
}
|
||||
|
||||
/** 校验:返回错误文案;空串=通过。必填与 min/max 数量约束在此收口 */
|
||||
function validate(): string {
|
||||
for (const n of nodes.value) {
|
||||
const picked = selected.value[n.nodeId] || [];
|
||||
const required = n.selection?.required;
|
||||
const min = n.selection?.min;
|
||||
const max = n.selection?.max;
|
||||
if (required && picked.length === 0) {
|
||||
return `「${n.nodeName || n.nodeId}」为必选节点,请选择审批人`;
|
||||
}
|
||||
if (min != null && picked.length > 0 && picked.length < min) {
|
||||
return `「${n.nodeName || n.nodeId}」至少选择 ${min} 人`;
|
||||
}
|
||||
if (max != null && picked.length > max) {
|
||||
return `「${n.nodeName || n.nodeId}」最多选择 ${max} 人`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
defineExpose({ collect, validate, reload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flow-assign-selector">
|
||||
<p v-if="loading" style="color: #808695">正在加载指派节点…</p>
|
||||
<p v-else-if="nodes.length === 0" style="color: #808695">
|
||||
该流程未开启发起时指派,无需选择审批人
|
||||
</p>
|
||||
<div v-for="node in nodes" :key="node.nodeId" class="assign-node">
|
||||
<label class="assign-label">
|
||||
{{ node.nodeName || node.nodeId }}
|
||||
<span v-if="node.selection?.required" style="color: #ed4014">*</span>
|
||||
<span style="color: #808695; font-weight: normal">
|
||||
({{ node.countersign === "ANY" ? "或签" : "会签" }}{{ node.selection?.mode === "SINGLE" ? "·单选" : "·多选" }})
|
||||
</span>
|
||||
</label>
|
||||
<Select v-if="node.selection?.mode === 'SINGLE'" :model-value="selected[node.nodeId]?.[0]"
|
||||
clearable placeholder="选择审批人" style="width: 260px"
|
||||
@on-change="(v: number) => (selected[node.nodeId] = v == null ? [] : [v])">
|
||||
<Option v-for="c in node.candidates" :key="c.userId" :value="c.userId" :label="c.userName" />
|
||||
</Select>
|
||||
<CheckboxGroup v-else :model-value="selected[node.nodeId]"
|
||||
@on-change="(v: number[]) => (selected[node.nodeId] = v || [])">
|
||||
<Checkbox v-for="c in node.candidates" :key="c.userId" :label="c.userId">
|
||||
{{ c.userName }}
|
||||
</Checkbox>
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-node { margin-bottom: 12px; }
|
||||
.assign-label { display: block; margin-bottom: 4px; font-weight: 600; }
|
||||
</style>
|
||||
@@ -116,7 +116,7 @@ defineExpose({
|
||||
no-data-text="暂无数据"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="page-panel">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle">
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots, watch } from 'vue'
|
||||
import Utils from '@/utils'
|
||||
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
const slots = useSlots()
|
||||
|
||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
||||
const regionRef = ref<HTMLElement | null>(null)
|
||||
const measuredH = useElementHeight(regionRef)
|
||||
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||
|
||||
const selectList = ref<number[]>([])
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||
src?: string | null;
|
||||
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||
name?: string | null;
|
||||
/** 直径 px;font-size = round(size * 0.44) */
|
||||
size?: number;
|
||||
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||
placement?: "light" | "brand";
|
||||
}>(),
|
||||
{ src: null, name: null, size: 28, placement: "light" },
|
||||
);
|
||||
|
||||
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||
const appearanceTick = ref(0);
|
||||
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||
const style = computed(() => {
|
||||
appearanceTick.value;
|
||||
return resolveAvatarStyle();
|
||||
});
|
||||
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||
const showText = computed(() => style.value === "text" && !!text.value);
|
||||
|
||||
const rootClass = computed(() => [
|
||||
"yh-avatar",
|
||||
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||
{ "is-text": showText.value },
|
||||
]);
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="rootClass" :style="rootStyle">
|
||||
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||
<template v-else-if="showText">{{ text }}</template>
|
||||
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||
<circle cx="512" cy="399" r="174" />
|
||||
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.yh-avatar {
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.yh-avatar__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||
.yh-avatar__sil {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
fill: currentColor;
|
||||
}
|
||||
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||
.yh-avatar--light {
|
||||
background: var(--brand, #577afb);
|
||||
color: #fff;
|
||||
}
|
||||
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||
.yh-avatar--brand {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
.yh-avatar--brand.is-text {
|
||||
background: #fff;
|
||||
color: var(--brand, #577afb);
|
||||
}
|
||||
</style>
|
||||
@@ -46,7 +46,7 @@
|
||||
>
|
||||
<path
|
||||
d="M138.935 256.078v511.844h63.42V256.078h-63.42z m126.842 0v511.844h127.215V256.078H265.777zM885.064 512L583.254 64.323v191.755H456.414v511.844h126.842v191.755L885.064 512z"
|
||||
fill="#2d8cf0"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -488,6 +488,7 @@ export default {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
margin-top: -17px;
|
||||
color: var(--brand);
|
||||
}
|
||||
.auto-box {
|
||||
width: 200px;
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<!-- 单选用户输入(参照 Notice 自定义组选人范式):readonly Input + 选择按钮 + 清空;
|
||||
v-model=用户 id;display-name 回显姓名快照(调用方已知姓名时随值下传) -->
|
||||
<div class="sel-user-box">
|
||||
<Input
|
||||
type="text"
|
||||
v-model="nameText"
|
||||
readonly
|
||||
placeholder="点击「选择」指定用户"
|
||||
class="input"
|
||||
></Input>
|
||||
<Button icon="md-add" @click="openModal">选择</Button>
|
||||
<Button icon="md-trash" v-if="modelValue" @click="reset"></Button>
|
||||
<UserCheckModal
|
||||
ref="userCheckModal"
|
||||
:sources="sources"
|
||||
:multiple="false"
|
||||
@on-submit="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
export default {
|
||||
name: "UserSelectInput",
|
||||
components: { UserCheckModal },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
/** 当前 id 的姓名快照(编辑回显/选中即得,避免 #id 兜底) */
|
||||
displayName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "update:displayName", "on-change"],
|
||||
data() {
|
||||
return {
|
||||
nameText: this.displayName,
|
||||
sources: [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openModal() {
|
||||
(this.$refs.userCheckModal as any).show();
|
||||
},
|
||||
/** 单选弹窗提交:数组恒为 1 条(多选复用同组件) */
|
||||
onSubmit(arr: any[]) {
|
||||
if (!arr || !arr.length) return;
|
||||
const u = arr[0];
|
||||
this.nameText = u.name + "(" + u.id + ")";
|
||||
this.$emit("update:modelValue", u.id);
|
||||
this.$emit("update:displayName", u.name);
|
||||
this.$emit("on-change", u);
|
||||
},
|
||||
reset() {
|
||||
this.nameText = "";
|
||||
this.$emit("update:modelValue", null);
|
||||
this.$emit("update:displayName", "");
|
||||
this.$emit("on-change", null);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(val: number) {
|
||||
// 外部清空/重置时同步清姓名
|
||||
if (!val) this.nameText = "";
|
||||
else if (!this.nameText) this.nameText = "#" + val;
|
||||
},
|
||||
displayName(val: string) {
|
||||
this.nameText = val;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sel-user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+21
-14
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
|
||||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||||
const route = useRoute();
|
||||
@@ -75,10 +76,15 @@ const routeChange = (to: any) => {
|
||||
const navArr: any[] = [];
|
||||
|
||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||
// 匹配不到再退回 modId 对照
|
||||
// 匹配不到再退回 modId 对照。
|
||||
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
|
||||
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
|
||||
// 不相干的「流程编辑器」(验收报告 P3)。
|
||||
const find: any =
|
||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||
menuArr.value.find((x: any) => x.modId === modId);
|
||||
(modId !== undefined && modId !== null
|
||||
? menuArr.value.find((x: any) => x.modId === modId)
|
||||
: null);
|
||||
if (find) {
|
||||
currPath.value = find.page || "";
|
||||
if (find.pid) {
|
||||
@@ -108,7 +114,10 @@ const routeChange = (to: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||
// 同上:modId 缺失时不做这条兜底,否则又是"第一条无 modId 路由"中奖
|
||||
const r = (modId === undefined || modId === null)
|
||||
? undefined
|
||||
: router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||
if (r) {
|
||||
currPath.value = r.path;
|
||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||
@@ -116,7 +125,10 @@ const routeChange = (to: any) => {
|
||||
}
|
||||
|
||||
currPath.value = "";
|
||||
navPath.value = [];
|
||||
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
|
||||
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
|
||||
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
|
||||
navPath.value = title ? [{ name: title }] : [];
|
||||
};
|
||||
|
||||
const linkMenu = (item: any) => {
|
||||
@@ -154,7 +166,7 @@ const childChange = (index: number) => {
|
||||
};
|
||||
|
||||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||||
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
|
||||
* 兜底保留旧版 modId 对照。图标为通道 iconfont 掩码类(A 类接入后由 YschTheme 发版 icons.css 提供,本地副本已回收) */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const page: string = item?.page || "";
|
||||
if (page === "/list/new") return "icon-xiugaijl";
|
||||
@@ -190,16 +202,11 @@ function linkHomePage() {
|
||||
<header class="header-container fix">
|
||||
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
||||
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="首页" class="icon-btn" @click="linkHomePage">
|
||||
<i class="iconfont icon-shouye"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
@@ -291,14 +298,14 @@ function linkHomePage() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
|
||||
/* 仅覆盖/补充必要的动态样式,其余样式由主题通道 base.layout.css 提供 */
|
||||
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
|
||||
.left-menu a .iconfont {
|
||||
margin-right: 8px;
|
||||
}
|
||||
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
|
||||
/* 面包屑「首页」垂直居中:通道 base.layout.css 的 .path-nav .icon-category{position:relative;top:1px}
|
||||
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
|
||||
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内) */
|
||||
这里用 inline-flex 真居中并关掉该补偿(不改通道产物,覆盖收在本组件内) */
|
||||
.crumb-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,10 +13,6 @@ import mittBus from "@/utils/mitt";
|
||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||
|
||||
import "@/assets/styles/main.scss";
|
||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
||||
import "@/assets/styles/layout.css";
|
||||
// 规范 iconfont 图标本地实现(icon-browse / icon-edit / icon-ashbin 等,平台主题不含字体)
|
||||
import "@/assets/styles/icons.css";
|
||||
//导入自定义指令
|
||||
import directive from "@/utils/directive/index";
|
||||
|
||||
|
||||
+34
-13
@@ -9,7 +9,7 @@ import type { RouteRecordRaw } from "vue-router";
|
||||
* 办理页 /task/:taskId 为 UserMsg jumpPath 落地页(不挂菜单)。
|
||||
* <p>
|
||||
* 详情/深链页(不挂菜单)通过 meta.parentId 关联父级菜单(UserBase 同款范式):左侧菜单保持
|
||||
* 父级高亮、面包屑补全"父页>本页"。同一详情页可能被多个列表进入(如办件详情既可从"我的办件"
|
||||
* 父级高亮、面包屑补全"父页>本页"。同一详情页可能被多个列表进入(如办件详情既可从"我提交的"
|
||||
* 也可从"办件记录"),此时列表页跳转带 ?pid=<父菜单modId> 动态覆盖 meta.parentId 静态默认。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -19,12 +19,6 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/flow/Workbench.vue"),
|
||||
meta: { title: "工作台", modId: 100 },
|
||||
},
|
||||
{
|
||||
path: "/my",
|
||||
name: "flow_my",
|
||||
component: () => import("@/views/flow/MyInstances.vue"),
|
||||
meta: { title: "我的办件", modId: 101 },
|
||||
},
|
||||
{
|
||||
path: "/task/:taskId",
|
||||
name: "flow_task_handle",
|
||||
@@ -38,6 +32,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/flow/InstanceDetail.vue"),
|
||||
meta: { title: "办件详情", parentId: 102 },
|
||||
},
|
||||
{
|
||||
// T07 托管选人 iframe(F6 接线):业务页 iframe 内嵌,CAS 会话预览候选并回传最终选择
|
||||
path: "/assign-preview",
|
||||
name: "flow_assign_preview",
|
||||
component: () => import("@/views/flow/AssignPreview.vue"),
|
||||
meta: { title: "选择办理人" },
|
||||
},
|
||||
{
|
||||
// 流程示意图深链页(不挂菜单,同 /task/:taskId 模式);跨应用入口 window.open 整页跳转
|
||||
path: "/diagram",
|
||||
@@ -66,12 +67,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: "流程编辑器", parentId: 103 },
|
||||
},
|
||||
{
|
||||
path: "/admin/maps",
|
||||
name: "flow_admin_maps",
|
||||
component: () => import("@/views/flow/admin/Maps.vue"),
|
||||
meta: { title: "办理人映射", modId: 104 },
|
||||
},
|
||||
{
|
||||
// 回调管理(原 104 办理人映射已于 2026-10-10 下线,由 应用设置-科室管理 承载)
|
||||
path: "/admin/callbacks",
|
||||
name: "flow_admin_callbacks",
|
||||
component: () => import("@/views/flow/admin/Callbacks.vue"),
|
||||
@@ -84,6 +80,31 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/flow/admin/Routers.vue"),
|
||||
meta: { title: "条件路由", modId: 106 },
|
||||
},
|
||||
{
|
||||
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单,学校池同步);
|
||||
// tab2 科室管理(Part E 填充)。菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=107)
|
||||
path: "/admin/app-setting",
|
||||
name: "flow_admin_app_setting",
|
||||
component: () => import("@/views/flow/admin/AppSetting.vue"),
|
||||
meta: { title: "应用设置", modId: 107 },
|
||||
},
|
||||
{
|
||||
// 接入向导(2026-10-15 议题二 P1):五步引导 + 配置生成器(源头消灭配置形态静默断点)。
|
||||
// 与「应用设置-对接健康度」互补:向导管接入时一次引导,健康度管对接后持续体检。
|
||||
// 菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=108)
|
||||
path: "/admin/onboarding",
|
||||
name: "flow_admin_onboarding",
|
||||
component: () => import("@/views/flow/admin/Onboarding.vue"),
|
||||
meta: { title: "接入向导", modId: 108 },
|
||||
},
|
||||
{
|
||||
// 应用使用许可(E6):flw_usage_grant 独立授权面,与流程定义归属(flw_def.app_key)、实例来源应用三者正交。
|
||||
// 菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=109);本表只登记路由,菜单数据不在本工程
|
||||
path: "/admin/usage-grants",
|
||||
name: "flow_admin_usage_grants",
|
||||
component: () => import("@/views/flow/admin/UsageGrants.vue"),
|
||||
meta: { title: "使用许可", modId: 109 },
|
||||
},
|
||||
{
|
||||
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
|
||||
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createAlova, Method, StatesHook } from "alova";
|
||||
import fetchAdapter from "alova/fetch";
|
||||
import VueHook from "alova/vue";
|
||||
import Utils from "..";
|
||||
import { describeFlowError } from "@/utils/flowError";
|
||||
/* ============= 类型定义 ============= */
|
||||
export interface RequestHooks {
|
||||
/** 前置票据/通用头注入 */
|
||||
@@ -10,10 +11,10 @@ export interface RequestHooks {
|
||||
onLoadingStart?: () => void;
|
||||
/** 加载结束 */
|
||||
onLoadingEnd?: () => void;
|
||||
/** 业务错误提示(code !== 0)*/
|
||||
onBizError?: (msg: string) => void;
|
||||
/** http 错误提示(status !== 200)*/
|
||||
onHttpError?: (msg: string) => void;
|
||||
/** 业务错误提示(code !== 0);第二参=带 bizCode/httpStatus 凭据的原始错误,供可信度判定(F05) */
|
||||
onBizError?: (msg: string, err?: unknown) => void;
|
||||
/** http 错误提示(status !== 200) */
|
||||
onHttpError?: (msg: string, err?: unknown) => void;
|
||||
}
|
||||
|
||||
/** 和后端约定的通用响应格式 */
|
||||
@@ -34,6 +35,13 @@ const defHooks: Required<RequestHooks> = {
|
||||
};
|
||||
|
||||
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
||||
/** 抛错时把契约里的码/状态挂到 Error 上:调用方可按码分支,而不是回头猜文案 */
|
||||
function fail(message: string, extra: Record<string, unknown>): never {
|
||||
const err = new Error(message) as Error & Record<string, unknown>;
|
||||
Object.assign(err, extra);
|
||||
throw err;
|
||||
}
|
||||
|
||||
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||
// http 层错误
|
||||
if (status !== 200) {
|
||||
@@ -46,9 +54,9 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||
msg = "请求的资源不存在!";
|
||||
break;
|
||||
default:
|
||||
msg = res?.msg || "内部服务错误!";
|
||||
msg = "服务响应异常,请刷新核对操作结果";
|
||||
}
|
||||
throw new Error(msg);
|
||||
fail(msg, { httpStatus: status });
|
||||
}
|
||||
|
||||
// 业务层错误
|
||||
@@ -64,9 +72,13 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||
window.location.href = res.urlToRedirectTo!;
|
||||
return new Promise(() => {}) as T;
|
||||
case 4:
|
||||
throw new Error(res.msg || "参数错误!");
|
||||
case 2:
|
||||
case 3:
|
||||
case 5:
|
||||
throw Object.assign(new Error(describeFlowError({ message: res.msg || "操作被拒绝", bizCode: res.code, httpStatus: 200 })),
|
||||
{ bizCode: res.code, httpStatus: 200 });
|
||||
default:
|
||||
throw new Error(res.msg || "业务错误");
|
||||
throw Object.assign(new Error("服务响应异常,请刷新核对操作结果"), { bizCode: res.code, httpStatus: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +120,12 @@ export function createBschAlova(
|
||||
}
|
||||
|
||||
/* ---- 响应拦截 ---- */
|
||||
async function onSuccess(response: Response) {
|
||||
/** 调用方自带错误面(如线级启停要报"停发失败:xxx")时,用它跳过全局 toast,避免同一失败弹两条 */
|
||||
function isSilentBizToast(method?: Method<any>): boolean {
|
||||
return !!(method as unknown as { config?: Record<string, any> })?.config?.silentBizToast;
|
||||
}
|
||||
|
||||
async function onSuccess(response: Response, method?: Method<any>) {
|
||||
reqCount--;
|
||||
if (reqCount === 0) {
|
||||
clearTimeout(timer);
|
||||
@@ -120,18 +137,18 @@ export function createBschAlova(
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
onBizError(e.message);
|
||||
if (!isSilentBizToast(method)) onBizError(e?.message, e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onError(err: any) {
|
||||
async function onError(err: any, method?: Method<any>) {
|
||||
reqCount = 0;
|
||||
clearTimeout(timer);
|
||||
onLoadingEnd();
|
||||
|
||||
const msg = err?.message || "网络请求出错";
|
||||
onHttpError(msg);
|
||||
if (!isSilentBizToast(method)) onHttpError(msg, err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createBschAlova } from "./alova-core";
|
||||
import { Message } from "view-ui-plus";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import { describeFlowError } from "@/utils/flowError";
|
||||
|
||||
/* 全局 loading 遮罩实例 */
|
||||
let spinInstance: any = null;
|
||||
@@ -30,11 +31,14 @@ export const alovaInstance = createBschAlova(baseUrl, {
|
||||
spinInstance = Message.loading("加载中...");
|
||||
},
|
||||
onLoadingEnd: closeLoading,
|
||||
onBizError(msg: string) {
|
||||
if (!!msg) Message.error(msg);
|
||||
/* 全局兜底提示(F05):只有带 bizCode 的业务信封原文才回显,
|
||||
HTTP 状态文本/异常形态/无凭据文本一律换成不误导的通用话术(堆栈/SQL/令牌不进页面) */
|
||||
onBizError(msg: string, err?: unknown) {
|
||||
const text = describeFlowError(err ?? msg);
|
||||
if (!!text) Message.error(text);
|
||||
},
|
||||
|
||||
onHttpError(msg: string) {
|
||||
Message.error({ title: "网络错误", content: msg });
|
||||
onHttpError(msg: string, err?: unknown) {
|
||||
Message.error({ title: "请求未完成", content: describeFlowError(err ?? msg) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 整页跳转用的应用内地址(hash 路由)。
|
||||
*
|
||||
* 线上缺陷(FL上线独立验收报告 P1-3):入口把 `router.resolve(...).href`(自带前导 `#`)当成
|
||||
* path 再拼一次,得到 `index.html#/task/339#/diagram?…` 双 hash——浏览器只认第一个 `#`,
|
||||
* 页面停在原路由且无任何报错;另一处用 `origin + BASE_URL` 拼基址,BASE_URL 构建期是 "./",
|
||||
* 拼出 `https://host./#/diagram?…`(域名尾点)。
|
||||
* 统一口径:先丢掉当前地址里已有的 hash 段,再接上目标路由解析出的唯一 hash。
|
||||
*/
|
||||
export function toAppHashUrl(hashHref: string, currentHref: string = window.location.href): string {
|
||||
const hash = hashHref.startsWith("#") ? hashHref : `#${hashHref}`;
|
||||
return `${currentHref.split("#")[0]}${hash}`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 托管选人 iframe 消息协议(2026-10-25 T07)。
|
||||
*
|
||||
* 传输层 = 现有原生 postMessage(与 taskEmbed.ts 同栈);独立 yschmessage 库未定位,
|
||||
* 不假称存在——本模块是 flow 域的最小通信适配(传输与窗口绑定),业务语义(选人确认/取消)
|
||||
* 由 flow 自己定义,不与 FC 等中心合并。
|
||||
*
|
||||
* 安全四件套(父子双方各自校验):
|
||||
* 1. 精确 origin(发往 referrer origin;收到消息先核 event.origin);
|
||||
* 2. event.source === 窗口引用(父页核 iframe.contentWindow,本页核 window.parent);
|
||||
* 3. 协议形状(proto/v/type/contextId 闭集校验);
|
||||
* 4. 本次 contextId——关闭/重开/换线后旧 frame 的迟到消息按 contextId 失效。
|
||||
*/
|
||||
|
||||
export const ASSIGN_EMBED_PROTO = 'flow';
|
||||
export const ASSIGN_EMBED_VERSION = 1;
|
||||
|
||||
export type AssignEmbedType = 'assign-confirmed' | 'assign-cancelled';
|
||||
|
||||
export interface AssignEmbedMessage {
|
||||
proto: typeof ASSIGN_EMBED_PROTO;
|
||||
v: number;
|
||||
type: AssignEmbedType;
|
||||
/** 本次打开的关联标识:迟到/异 context 消息据此丢弃,不落库不参与幂等 */
|
||||
contextId: string;
|
||||
flowGuid?: string;
|
||||
/** 版本锚:预览时返回的定义版本,正式提交回带 expectedDefVersion */
|
||||
defVersion?: number;
|
||||
assigns?: { nodeId: string; userIds: number[] }[];
|
||||
}
|
||||
|
||||
/** 从 query/referrer 解析父页 origin(iframe 自身的锁定目标) */
|
||||
export function parentOrigin(): string {
|
||||
try {
|
||||
if (document.referrer && document.referrer !== location.href) {
|
||||
const origin = new URL(document.referrer).origin;
|
||||
return origin === 'null' ? '' : origin;
|
||||
}
|
||||
} catch {
|
||||
/* referrer 不可解析=无锁定目标,只收不发 */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 发送前形状自检:确认消息必须带 flowGuid/defVersion/assigns;取消只带 contextId */
|
||||
export function buildMessage(msg: AssignEmbedMessage): AssignEmbedMessage | null {
|
||||
if (!msg || msg.proto !== ASSIGN_EMBED_PROTO || msg.v !== ASSIGN_EMBED_VERSION) {
|
||||
return null;
|
||||
}
|
||||
if (!isNonEmptyString(msg.contextId) || !['assign-confirmed', 'assign-cancelled'].includes(msg.type)) {
|
||||
return null;
|
||||
}
|
||||
if (msg.type === 'assign-confirmed') {
|
||||
if (!isNonEmptyString(msg.flowGuid) || !isPositiveInteger(msg.defVersion)) {
|
||||
return null;
|
||||
}
|
||||
if (!isValidAssigns(msg.assigns)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收侧(父页)校验(F03 收口,2026-10-29)。
|
||||
*
|
||||
* <p>原实现对 `expectedOrigin`/`expectedSource` 为空的场景"跳过该道校验",属放行口径;
|
||||
* 现要求三条件(精确 origin、iframe 窗口引用、本次 context)全部实到位才处理消息,
|
||||
* 任一缺失或不符即丢弃——宿主拿不到未经验证的消息去覆盖节点配置。</p>
|
||||
*
|
||||
* <p>载荷逐字段校验:`flowGuid` 必须等于打开 iframe 时锁定的流程;`defVersion` 是正整数;
|
||||
* `assigns` 是 `{nodeId: 非空字符串, userIds: 正整数 ID 数组}` 的数组,畸形载荷整条拒绝。</p>
|
||||
*/
|
||||
export function isValidAssignMessage(
|
||||
ev: MessageEvent,
|
||||
expectedOrigin: string,
|
||||
expectedSource: Window | null,
|
||||
expectedContextId: string,
|
||||
expectedFlowGuid = '',
|
||||
): AssignEmbedMessage | null {
|
||||
if (!isNonEmptyString(expectedOrigin) || expectedOrigin === '*' || expectedOrigin === 'null'
|
||||
|| !expectedSource || !isNonEmptyString(expectedContextId) || !isNonEmptyString(expectedFlowGuid)) {
|
||||
return null;
|
||||
}
|
||||
if (ev.origin !== expectedOrigin || ev.source !== expectedSource) {
|
||||
return null;
|
||||
}
|
||||
const data = ev.data as Partial<AssignEmbedMessage> | undefined;
|
||||
if (!data || data.proto !== ASSIGN_EMBED_PROTO || data.v !== ASSIGN_EMBED_VERSION) {
|
||||
return null;
|
||||
}
|
||||
if (data.type !== 'assign-confirmed' && data.type !== 'assign-cancelled') {
|
||||
return null;
|
||||
}
|
||||
if (data.contextId !== expectedContextId) {
|
||||
// 旧 frame / 换线后的迟到消息:按 context 失效
|
||||
return null;
|
||||
}
|
||||
const msg = buildMessage(data as AssignEmbedMessage);
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
if (msg.type === 'assign-confirmed' && expectedFlowGuid && msg.flowGuid !== expectedFlowGuid) {
|
||||
// 异流程回传:可能是换线竞态或伪造,不覆盖本节点配置
|
||||
return null;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidAssigns(value: unknown): boolean {
|
||||
if (!Array.isArray(value)) return false;
|
||||
return value.every((row) => {
|
||||
if (!row || typeof row !== 'object') return false;
|
||||
const { nodeId, userIds } = row as { nodeId?: unknown; userIds?: unknown };
|
||||
if (!isNonEmptyString(nodeId)) return false;
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) return false;
|
||||
return userIds.every(isPositiveInteger) && new Set(userIds).size === userIds.length;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||
* > "text"(系统默认)
|
||||
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||
*/
|
||||
|
||||
export type AvatarStyle = "text" | "silhouette";
|
||||
|
||||
interface StorageLike {
|
||||
getItem(k: string): string | null;
|
||||
setItem(k: string, v: string): void;
|
||||
removeItem(k: string): void;
|
||||
}
|
||||
|
||||
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||
|
||||
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||
|
||||
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||
|
||||
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||
|
||||
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||
}
|
||||
|
||||
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||
function readAvatarOverride(): AvatarStyle | null {
|
||||
try {
|
||||
const search = (globalThis.location && globalThis.location.search) || "";
|
||||
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||
} catch {
|
||||
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||
* > DEFAULT_AVATAR_STYLE。
|
||||
*/
|
||||
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||
let style: AvatarStyle | null = null;
|
||||
if (storage) {
|
||||
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||
}
|
||||
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||
return readAvatarOverride() || style;
|
||||
}
|
||||
|
||||
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||
} catch {
|
||||
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||
try {
|
||||
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||
} catch {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||
*/
|
||||
export function pickAvatarText(name: string | null | undefined): string {
|
||||
let s = String(name ?? "");
|
||||
const at = s.indexOf("@");
|
||||
if (at > -1) s = s.slice(0, at);
|
||||
const ch = [...s.trim()][0];
|
||||
if (ch === undefined) return "";
|
||||
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||
if (/^[0-9]$/.test(ch)) return "";
|
||||
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||
return ch;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 服务端错误呈现边界(FL上线独立验收报告 P0-1 附带项 + Codex 终审 F05/F04 收口,2026-10-29)。
|
||||
*
|
||||
* 口径:
|
||||
* 1. **可信来源才回显原文**。只有本应用与后端约定的业务信封({@code {code,msg,data}},code≠0)
|
||||
* 携带的 msg 才可能是"给人看的业务话",其错误对象由 alova 层挂上 {@code bizCode}。
|
||||
* HTTP 状态文本("Internal Server Error")、异常/SQL/Token/堆栈、以及任何无法判定来源的裸文本
|
||||
* 一律换成通用安全提示——不是"命中黑名单才挡",而是"拿不到可信凭据就不放行"。
|
||||
* 2. **失败 ≠ 未生效**(F04)。服务端明确拒绝与"请求已发出但结果未知"必须分开说:
|
||||
* 超时/断网/响应丢失时不能断言"本次办理未生效、任务仍是你的待办"。
|
||||
*/
|
||||
|
||||
/** 通用占位文案:出现即说明没有可用诊断信息 */
|
||||
const GENERIC_HINTS = [
|
||||
"内部服务错误",
|
||||
"服务器错误",
|
||||
"系统错误",
|
||||
"系统异常",
|
||||
"业务错误",
|
||||
"未知错误",
|
||||
"服务异常",
|
||||
"请求失败",
|
||||
"null",
|
||||
];
|
||||
|
||||
/** 异常原文形态:即使来自业务信封也不回显 */
|
||||
const INTERNAL_SHAPES = [
|
||||
/exception/i,
|
||||
/(?:[A-Z]:\\|\/(?:srv|var|home|opt|etc)\/)/i,
|
||||
/\bat\s+[a-z]+\.[a-z]+\./i,
|
||||
/java\./i,
|
||||
/sql/i,
|
||||
/error code \[?\d+/i,
|
||||
/caused by/i,
|
||||
/stack/i,
|
||||
/token|bearer|authorization|secret|password/i,
|
||||
/<\s*(html|!doctype)/i,
|
||||
/^\s*\{[\s\S]*["']?(status|error|traceback)["']?\s*[:=]/i,
|
||||
];
|
||||
|
||||
/** 网络/会话层形态:alova 封装把 fetch 失败与部分 401/403 收敛成这几句 */
|
||||
const NETWORK_SHAPES = [/^网络错误/, /网络请求出错/, /failed to fetch/i, /fetch failed/i, /network/i, /timeout/i];
|
||||
|
||||
export const FLOW_GENERIC_FALLBACK =
|
||||
"服务未能处理本次请求。请稍后重试;多次失败请联系流程管理员,并提供操作时间。";
|
||||
|
||||
export const FLOW_NETWORK_FALLBACK = "网络连接异常,或登录状态已失效,请刷新页面后重试。";
|
||||
|
||||
/** F04:结果未知时的话术——不断言未生效,也不断言成功 */
|
||||
export const FLOW_UNKNOWN_FALLBACK =
|
||||
"本次操作的结果未能确认(请求可能已送达但响应丢失)。请先刷新页面核实当前状态,不要连续重试。";
|
||||
|
||||
export type FlowErrorKind = "business" | "network" | "unknown";
|
||||
|
||||
interface FlowErrorInfo {
|
||||
kind: FlowErrorKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定错误来源可信度。
|
||||
* {@code bizCode} 存在=后端业务信封明确拒绝;{@code httpStatus}/无凭据=结果不可信或未知。
|
||||
*/
|
||||
export function classifyFlowError(raw: unknown): FlowErrorInfo {
|
||||
if (raw === null || raw === undefined) {
|
||||
return { kind: "unknown", text: "" };
|
||||
}
|
||||
const err = raw as Record<string, any>;
|
||||
const text = typeof raw === "string"
|
||||
? raw
|
||||
: typeof err.message === "string"
|
||||
? err.message
|
||||
: String(err ?? "");
|
||||
const trimmed = text.trim();
|
||||
if (typeof err.httpStatus === "number" && err.httpStatus !== 200) {
|
||||
return { kind: NETWORK_SHAPES.some(re => re.test(trimmed)) ? "network" : "unknown", text: trimmed };
|
||||
}
|
||||
if (NETWORK_SHAPES.some((re) => re.test(trimmed))) {
|
||||
return { kind: "network", text: trimmed };
|
||||
}
|
||||
// 业务信封明确拒绝:只有带 bizCode 才认定"服务端明确答复了这次请求"
|
||||
if ([2, 3, 4, 5].includes(err.bizCode)) {
|
||||
return { kind: "business", text: trimmed };
|
||||
}
|
||||
return { kind: "unknown", text: trimmed };
|
||||
}
|
||||
|
||||
function isUnsafeBusinessText(text: string): boolean {
|
||||
if (!text) return true;
|
||||
if (GENERIC_HINTS.some((g) => text === g || text === `${g}!` || text === `${g}!`)) return true;
|
||||
return INTERNAL_SHAPES.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误 → 用户可读文案。业务拒绝回显服务端原文(且过滤异常形态);网络失败走网络话术;
|
||||
* 其余(HTTP 5xx 原文、无法判定来源的文本、丢响应)统一通用安全提示,不回显任意后端文本。
|
||||
*/
|
||||
export function describeFlowError(raw: unknown): string {
|
||||
const { kind, text } = classifyFlowError(raw);
|
||||
if (kind === "network") return FLOW_NETWORK_FALLBACK;
|
||||
if (kind === "business") return isUnsafeBusinessText(text) ? FLOW_GENERIC_FALLBACK : text;
|
||||
return FLOW_GENERIC_FALLBACK;
|
||||
}
|
||||
|
||||
/** 服务端明确拒绝时的读法(F04 与"结果未知"配对使用) */
|
||||
export function isDefiniteBusinessRejection(raw: unknown): boolean {
|
||||
return classifyFlowError(raw).kind === "business";
|
||||
}
|
||||
|
||||
/** 只读查询/列表加载失败:不声称写操作结果,只说明没读到 */
|
||||
export function flowFailTip(action: string, raw: unknown): string {
|
||||
return `${action}未完成:${describeFlowError(raw)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理/提交类动作失败(F04):明确拒绝说拒绝;结果未知说"待确认",
|
||||
* 不写"本次办理未生效、任务仍是你的待办"——请求可能已提交,前端无从推导回滚。
|
||||
*/
|
||||
export function flowSubmitFailTip(action: string, raw: unknown): string {
|
||||
const { kind } = classifyFlowError(raw);
|
||||
if (kind === "business") {
|
||||
const { text } = classifyFlowError(raw);
|
||||
return `${action}被拒绝:${isUnsafeBusinessText(text) ? FLOW_GENERIC_FALLBACK : text}`;
|
||||
}
|
||||
return `${action}结果待确认:${kind === "network" ? FLOW_NETWORK_FALLBACK : FLOW_UNKNOWN_FALLBACK}`;
|
||||
}
|
||||
|
||||
/** 动作已成功、但随后的列表/详情刷新失败:不能说成整体失败 */
|
||||
export function flowReloadFailTip(action: string): string {
|
||||
return `${action}已提交成功,但页面数据刷新失败,请手动刷新查看最新状态。`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 轨迹口径工具(三视图 + 嵌入页共用,2026-10-12):
|
||||
* 抄送(CC)在库里与轨迹里是"逐接收人一条"(FlowEngine.notifyReceivers 逐人落 flw_cc)。
|
||||
* 人员视图下正需如此(能看出抄送给了谁);但科室视图不列姓名,N 个接收人若逐行渲染就是
|
||||
* N 行一模一样的文案,故 dept 档按「节点 + 轮次」折叠为一行并记人数——口径对齐
|
||||
* /oapi/flow/progress 的 ccSteps(本就按 nodeId 聚合)。
|
||||
*/
|
||||
|
||||
/** dept 档折叠抄送行:同 nodeId + 同轮次的 CC 行合并为一行(ccCount 记人数);其余行原样、顺序不变。 */
|
||||
export function collapseCcRows(trace: any[], mode?: string): any[] {
|
||||
if (mode !== "dept") return trace;
|
||||
const out: any[] = [];
|
||||
const at: Record<string, number> = {};
|
||||
for (const r of trace) {
|
||||
if (r.kind !== "CC") {
|
||||
out.push(r);
|
||||
continue;
|
||||
}
|
||||
const key = `${r.nodeId}#${r.round || 1}`;
|
||||
const i = at[key];
|
||||
if (i === undefined) {
|
||||
at[key] = out.length;
|
||||
out.push({ ...r, ccCount: 1 });
|
||||
} else {
|
||||
out[i].ccCount += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** dept 档抄送行角色词:科室抄送显科室名(解析不出退短码)、指定人抄送显"抄送对象",多人加人数后缀。
|
||||
* ⚠️ 刻意不回落 assigneeName——本函数存在的意义就是不出现人名。 */
|
||||
export function ccActorText(row: any): string {
|
||||
const base = row.assigneeType === "DEPT_KEY"
|
||||
? (row.deptName || row.deptKey || "抄送对象")
|
||||
: "抄送对象";
|
||||
return (row.ccCount || 1) > 1 ? `${base} ${row.ccCount} 人` : base;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||
|
||||
/**
|
||||
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||
*
|
||||
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||
* 都不再需要同步改任何魔法数字。
|
||||
*
|
||||
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||
*/
|
||||
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||
const height = ref(0);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
const measure = () => {
|
||||
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||
};
|
||||
|
||||
const observe = () => {
|
||||
ro?.disconnect();
|
||||
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||
measure();
|
||||
ro = new ResizeObserver(measure);
|
||||
ro.observe(el.value);
|
||||
};
|
||||
|
||||
onMounted(observe);
|
||||
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||
watch(el, observe);
|
||||
onBeforeUnmount(() => ro?.disconnect());
|
||||
|
||||
return height;
|
||||
}
|
||||
+12
-33
@@ -1,5 +1,5 @@
|
||||
import { IObj, UseTableSetting } from "@/types/Basics";
|
||||
import { resolveComponent } from "vue";
|
||||
import { actionIcon, actionText } from "@/utils/tableActions";
|
||||
export default class Utils {
|
||||
/**
|
||||
* 格式化日期
|
||||
@@ -76,7 +76,8 @@ export default class Utils {
|
||||
}
|
||||
|
||||
/**
|
||||
* iview Tooltip封装
|
||||
* iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
|
||||
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
|
||||
*/
|
||||
public static operatingHandle(
|
||||
h: any,
|
||||
@@ -85,30 +86,10 @@ export default class Utils {
|
||||
callback: Function,
|
||||
disabled: boolean = false,
|
||||
) {
|
||||
if (disabled) iconClass += " disabled";
|
||||
|
||||
return h(
|
||||
resolveComponent("Tooltip"),
|
||||
{
|
||||
content: info,
|
||||
placement: "bottom",
|
||||
transfer: true,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-btn iconfont ${iconClass}`,
|
||||
onClick: callback,
|
||||
},
|
||||
null,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
|
||||
}
|
||||
|
||||
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
|
||||
public static operatingTextHandle(
|
||||
h: any,
|
||||
info: string,
|
||||
@@ -116,15 +97,13 @@ export default class Utils {
|
||||
callback: Function,
|
||||
disabled: boolean = false,
|
||||
) {
|
||||
if (disabled) className += " disabled";
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-link ${className}`,
|
||||
onClick: disabled ? null : callback,
|
||||
},
|
||||
info,
|
||||
);
|
||||
return actionText(h, {
|
||||
key: info,
|
||||
label: info,
|
||||
danger: (className || "").includes("text-error"),
|
||||
on: () => callback(),
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 流程线级停发/恢复的请求编排(T03)。
|
||||
* 与版本级 status 正交:所有操作按 flowGuid 下发,后端批量更新该线全部版本行。
|
||||
*/
|
||||
export type LineAction = "offline" | "enable";
|
||||
|
||||
export type LineSkipReason = "no-guid" | "already-offline" | "not-offline";
|
||||
|
||||
export interface LineActionResult {
|
||||
kind: "skipped" | "updated" | "empty";
|
||||
reason?: LineSkipReason;
|
||||
affected: number;
|
||||
/** 操作已成功但列表刷新失败:调用方须提示“成功但未刷新”,不能报整体失败 */
|
||||
reloadFailed: boolean;
|
||||
}
|
||||
|
||||
export interface LineActionOptions {
|
||||
flowGuid: string;
|
||||
/** 行上的 lineOffline 原值(后端 Integer,1=已停发) */
|
||||
lineOffline: number;
|
||||
action: LineAction;
|
||||
request: (flowGuid: string) => Promise<number>;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 把 alova/后端错误统一成可读文案;HTTP 401/403 在当前 alova 封装里落到“网络错误” */
|
||||
export function lineActionErrorDetail(raw: string): string {
|
||||
const networkLike = !raw || raw === "网络错误" || /failed to fetch|fetch failed|network|timeout/i.test(raw);
|
||||
return networkLike ? "网络异常,或登录状态/流程管理权限校验未通过,请刷新后重试" : raw;
|
||||
}
|
||||
|
||||
export async function runLineAction(opts: LineActionOptions): Promise<LineActionResult> {
|
||||
const flowGuid = (opts.flowGuid || "").trim();
|
||||
if (!flowGuid) {
|
||||
return { kind: "skipped", reason: "no-guid", affected: 0, reloadFailed: false };
|
||||
}
|
||||
const offline = opts.action === "offline";
|
||||
if (offline && opts.lineOffline === 1) {
|
||||
return { kind: "skipped", reason: "already-offline", affected: 0, reloadFailed: false };
|
||||
}
|
||||
if (!offline && opts.lineOffline !== 1) {
|
||||
return { kind: "skipped", reason: "not-offline", affected: 0, reloadFailed: false };
|
||||
}
|
||||
|
||||
const affected = Number(await opts.request(flowGuid)) || 0;
|
||||
let reloadFailed = false;
|
||||
try {
|
||||
await opts.reload();
|
||||
} catch {
|
||||
reloadFailed = true;
|
||||
}
|
||||
return { kind: affected > 0 ? "updated" : "empty", affected, reloadFailed };
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
|
||||
*
|
||||
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
|
||||
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
|
||||
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
|
||||
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
|
||||
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
|
||||
*
|
||||
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
|
||||
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
|
||||
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
|
||||
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
|
||||
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
|
||||
*/
|
||||
import { h, resolveComponent } from "vue";
|
||||
|
||||
export interface ActionItem {
|
||||
/** 稳定标识:溢出分桶按 key 去重,必填 */
|
||||
key: string;
|
||||
label: string;
|
||||
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
|
||||
on: (row: any) => void;
|
||||
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
|
||||
icon?: string;
|
||||
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
|
||||
danger?: boolean;
|
||||
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
|
||||
when?: (row: any) => boolean;
|
||||
/** 直接置灰 */
|
||||
disabled?: boolean;
|
||||
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
|
||||
disabledWhen?: (row: any) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionCellOpts {
|
||||
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
|
||||
mode?: "icon" | "text";
|
||||
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
|
||||
collapsible?: boolean;
|
||||
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
|
||||
maxInline?: number;
|
||||
/** 列宽;缺省 icon = n*40+40 / text = 180 */
|
||||
width?: number;
|
||||
align?: "center" | "left" | "right";
|
||||
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
|
||||
fixed?: "right" | "left" | false;
|
||||
}
|
||||
|
||||
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
|
||||
|
||||
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
|
||||
export function actionIcon(h: any, item: ActionItem) {
|
||||
return h(
|
||||
resolveComponent("Tooltip"),
|
||||
{ content: item.label, placement: "bottom", transfer: true },
|
||||
{
|
||||
default: () => [
|
||||
h("div", {
|
||||
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
|
||||
onClick: item.disabled ? null : item.on,
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
|
||||
export function actionText(h: any, item: ActionItem) {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
|
||||
onClick: item.disabled ? null : item.on,
|
||||
},
|
||||
item.label,
|
||||
);
|
||||
}
|
||||
|
||||
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
|
||||
export function actionMore(h: any, items: ActionItem[]) {
|
||||
return h(
|
||||
resolveComponent("Dropdown"),
|
||||
{ class: "op-more", placement: "bottom-end", transfer: true },
|
||||
{
|
||||
default: () => [
|
||||
h("div", { class: "operating-link op-more-label" }, [
|
||||
"更多",
|
||||
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
|
||||
]),
|
||||
],
|
||||
list: () => [
|
||||
h(resolveComponent("DropdownMenu"), {}, {
|
||||
default: () => items.map((it) =>
|
||||
h(
|
||||
resolveComponent("DropdownItem"),
|
||||
{
|
||||
key: it.key,
|
||||
divided: !!(it.danger && items.length > 1),
|
||||
disabled: !!it.disabled,
|
||||
class: it.danger ? "op-item-danger" : "",
|
||||
onClick: () => it.on(),
|
||||
},
|
||||
{ default: () => it.label },
|
||||
),
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/* ============ 轴 2:归一化(纯数据) ============ */
|
||||
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
|
||||
return items
|
||||
.filter((it) => (it.when ? it.when(row) : true))
|
||||
.map((it) => ({
|
||||
...it,
|
||||
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
|
||||
on: () => it.on(row),
|
||||
}))
|
||||
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
|
||||
}
|
||||
|
||||
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
|
||||
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
|
||||
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
|
||||
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
|
||||
const inlineKeys = new Set(inline.map((it) => it.key));
|
||||
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
|
||||
}
|
||||
|
||||
/* ============ 组装层 ============ */
|
||||
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
|
||||
|
||||
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||
const mode = opts.mode ?? "text";
|
||||
const { inline, overflow } = (opts.collapsible ?? true)
|
||||
? splitActions(items, opts.maxInline ?? 2)
|
||||
: { inline: items, overflow: [] };
|
||||
|
||||
const render = RENDERERS[mode] || actionText;
|
||||
const children = inline.map((it) => render(h, it));
|
||||
if (overflow.length) children.push(actionMore(h, overflow));
|
||||
|
||||
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
|
||||
}
|
||||
|
||||
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
|
||||
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||
const mode = opts.mode ?? "text";
|
||||
return {
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
|
||||
align: opts.align ?? "center",
|
||||
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
|
||||
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 去壳任务页(/task/{taskId}?embed=1)与父应用之间的 postMessage 协议(v2.1 应用内审批·完整办理)。
|
||||
*
|
||||
* 协议常量集中在此,父侧(Notice/svc-hall 的 FlowTaskEmbed)按同样字面量校验;跨应用不发包,
|
||||
* 改动须双端同步。
|
||||
*
|
||||
* origin 纪律:子页只向 document.referrer 的 origin 发(父文档地址),拿不到就不发——
|
||||
* 禁止 `*`,否则任意宿主页面嵌我们任务页都能收到办理结果。父侧必须同样校验 event.origin。
|
||||
*/
|
||||
|
||||
export const FLOW_EMBED_PROTO = "flow";
|
||||
export const FLOW_EMBED_VERSION = 1;
|
||||
|
||||
export type FlowEmbedAction = "approve" | "reject" | "return" | "transfer";
|
||||
|
||||
export interface FlowEmbedMessage {
|
||||
proto: typeof FLOW_EMBED_PROTO;
|
||||
v: typeof FLOW_EMBED_VERSION;
|
||||
type: "handled" | "open-biz" | "height";
|
||||
taskId?: string;
|
||||
action?: FlowEmbedAction;
|
||||
bizId?: string | null;
|
||||
bizUrl?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** 本是否作为"去壳任务页"运行:路径在 /task/ 下且 embed=1(与 App.vue 的壳判定同一条规则) */
|
||||
export function isBareTaskRoute(path: string, embed: unknown): boolean {
|
||||
return path.startsWith("/task/") && String(embed ?? "") === "1";
|
||||
}
|
||||
|
||||
/** 当前是否运行在 iframe 内(顶层 window 与 parent 不同) */
|
||||
export function isInIframe(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
// 跨域访问 window.top 本身会抛——抛了说明就在 iframe 里
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 父页面 origin:iframe 的 document.referrer 即宿主文档地址,且在整个 iframe 生命周期内不变
|
||||
* (SPA 内部路由不影响它)。直接输地址进 iframe(无 referrer)时返回 null——宁可不发,
|
||||
* 也不猜一个 targetOrigin。
|
||||
*/
|
||||
export function parentOrigin(): string | null {
|
||||
const ref = document.referrer;
|
||||
if (!ref) return null;
|
||||
try {
|
||||
const origin = new URL(ref).origin;
|
||||
return origin && origin !== "null" ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 向父页面发一条协议消息;非 iframe 或父 origin 未知时静默跳过(独立直开任务页即现状行为) */
|
||||
export function postToParent(msg: Omit<FlowEmbedMessage, "proto" | "v">): void {
|
||||
if (!isInIframe()) return;
|
||||
const target = parentOrigin();
|
||||
if (!target) return;
|
||||
window.parent.postMessage({ proto: FLOW_EMBED_PROTO, v: FLOW_EMBED_VERSION, ...msg }, target);
|
||||
}
|
||||
|
||||
/** 父侧校验:只认本协议的消息体(origin 白名单由调用方自己按 iframe 地址判,这里管信封) */
|
||||
export function isFlowEmbedMessage(data: unknown): data is FlowEmbedMessage {
|
||||
const d = data as FlowEmbedMessage | null;
|
||||
return !!d && d.proto === FLOW_EMBED_PROTO && d.v === FLOW_EMBED_VERSION && !!d.type;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="assign-preview-page">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-else-if="!flowGuid" type="warning" show-icon message="缺少 flowGuid 查询参数" />
|
||||
<template v-else>
|
||||
<FlowAssignSelector ref="selectorRef" :flow-guid="flowGuid" />
|
||||
<div class="actions">
|
||||
<Button type="primary" :loading="submitting" @click="onConfirm">确认选人</Button>
|
||||
<Button @click="onCancel">取消</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 托管选人 iframe 页(2026-10-25 T07,F6 正式接线):
|
||||
* 业务页面以 iframe 打开本页(query: flowGuid & contextId),本页 CAS 会话预览候选;
|
||||
* 确认=只向父页回传最终选择+版本锚(postMessage,精确 origin),<b>不调用 apply、
|
||||
* 不建实例、不建临时流程</b>;取消=只通知关闭。正式提交由父页所在业务按 apply 终审。
|
||||
* 重新确认以最近一次为准(父页内存覆盖;本页每次确认即时回传)。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Button } from 'view-ui-plus';
|
||||
import FlowAssignSelector from '@/components/FlowAssignSelector.vue';
|
||||
import { flowApi } from '@/api/module/flow';
|
||||
import { AssignEmbedMessage, buildMessage, parentOrigin, ASSIGN_EMBED_PROTO, ASSIGN_EMBED_VERSION } from '@/utils/assignEmbed';
|
||||
|
||||
const route = useRoute();
|
||||
const flowGuid = computed(() => String(route.query.flowGuid || ''));
|
||||
/** 本次打开的关联标识(父页生成);迟到/异 context 消息按它失效 */
|
||||
const contextId = computed(() => String(route.query.contextId || ''));
|
||||
const lockedOrigin = parentOrigin();
|
||||
|
||||
const selectorRef = ref();
|
||||
const error = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (!contextId.value) {
|
||||
error.value = '缺少 contextId(父页必须生成本次打开的关联标识)';
|
||||
}
|
||||
});
|
||||
|
||||
async function onConfirm(): Promise<void> {
|
||||
const selector = selectorRef.value;
|
||||
if (!selector) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const invalid = selector.validate();
|
||||
// validate() 返回的是错误文案字符串(空串=通过):早先按数组取值会把提示截成第一个字符
|
||||
if (invalid) {
|
||||
error.value = invalid;
|
||||
return;
|
||||
}
|
||||
const preview = await flowApi.applyPreview(flowGuid.value).send();
|
||||
const assigns = selector.collect();
|
||||
const msg = buildMessage({
|
||||
proto: ASSIGN_EMBED_PROTO,
|
||||
v: ASSIGN_EMBED_VERSION,
|
||||
type: 'assign-confirmed',
|
||||
contextId: contextId.value,
|
||||
flowGuid: flowGuid.value,
|
||||
defVersion: preview?.defVersion,
|
||||
assigns,
|
||||
});
|
||||
if (!msg || !lockedOrigin) {
|
||||
error.value = !msg ? '消息构造失败(形状不完整)' : '无法锁定父页 origin(referrer 缺失),拒绝回传';
|
||||
return;
|
||||
}
|
||||
// 精确 origin 回传(同 taskEmbed.postToParent 纪律);source 由父页按 contentWindow 核
|
||||
window.parent.postMessage(msg, lockedOrigin);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel(): void {
|
||||
if (!lockedOrigin || !contextId.value) {
|
||||
return; // 无法安全回传就静默(父页有关闭超时兜底)
|
||||
}
|
||||
const msg: AssignEmbedMessage = {
|
||||
proto: ASSIGN_EMBED_PROTO,
|
||||
v: ASSIGN_EMBED_VERSION,
|
||||
type: 'assign-cancelled',
|
||||
contextId: contextId.value,
|
||||
};
|
||||
window.parent.postMessage(msg, lockedOrigin);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.assign-preview-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,17 +3,14 @@
|
||||
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
|
||||
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
|
||||
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
|
||||
// v2.4 版式(2026-10-10):原「流程轨迹」+「审批记录」合并为「办理进度」三视图(按节点/时间线/列表),
|
||||
// 流程示意图与业务信息改为默认收缩的折叠块;业务信息只渲染约定键 bizSnapshot.summaryFields,
|
||||
// 无值整块不显示;bizSnapshot 其余键与 formDataJson 一律不再平铺(原始数据不外露)。
|
||||
// v2.5(2026-10-10):口径切换器(人员视图/科室视图)与视图切换器同排在标题行、三视图常驻——口径实际对
|
||||
// 三个视图都生效(时间线走 :mode、列表走办理人列),原先只在「按节点」模板内会切视图即消失。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
// 2026-10-11 整合:正文(基本信息/办理进度三视图/流程示意图折叠/业务信息摘要)抽至共用组件
|
||||
// InstanceBody.vue,与管理面办件详情 /instance/:instanceId 同源同款;本页只留 token 取数、
|
||||
// 标题吸顶壳与空态——嵌入口径(只认 summaryFields、无流程信息/表单数据)由组件缺省 variant 保证。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -22,265 +19,12 @@ const loading = ref(true);
|
||||
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
|
||||
const expired = ref(false);
|
||||
|
||||
/** 办理进度视图:node=按节点(缺省)/ timeline=时间线 / list=列表 */
|
||||
const viewMode = ref<string>("node");
|
||||
function switchView(val: string) {
|
||||
viewMode.value = val;
|
||||
}
|
||||
|
||||
/** 显示口径: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 diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function onDiagramLoad() {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
// 折叠时容器 clientWidth=0,fitDiagram 取不到收缩上限;展开这一刻再适配一次
|
||||
function onDiagramToggle(e: Event) {
|
||||
if ((e.target as HTMLDetailsElement).open) fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 业务信息唯一内容 = 约定键 bizSnapshot.summaryFields;形状错 / 无值 ⇒ [] ⇒ 整块不渲染 */
|
||||
const summaryFields = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sf = obj?.summaryFields;
|
||||
if (!Array.isArray(sf) || !sf.length) return [];
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
label: String(x.label),
|
||||
value: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
});
|
||||
|
||||
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 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",
|
||||
};
|
||||
|
||||
/** 留痕事件行:不参与节点聚合,每行独立成一步(口径同流程中心 toProgressVo) */
|
||||
const EVENT_KINDS = ["SUBMIT", "RESUBMIT", "WITHDRAW", "CC", "ROUTE"];
|
||||
|
||||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 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+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 行发生时间:留痕行取 time,任务行取 handleTime,未办取 arriveTime */
|
||||
function rowTime(r: any): string {
|
||||
return r.time || r.handleTime || r.arriveTime || "";
|
||||
}
|
||||
|
||||
/** 操作者(口径=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 || "-");
|
||||
}
|
||||
|
||||
/** 嵌入页展示层状态文案(设计图口径:已提交 / 已同意 / 待审核…)。
|
||||
* ⚠️ 刻意不复用全局 TASK_STATUS(0 待办 / 1 同意 / …)——它被 TraceTimeline 与管理面详情页共用,
|
||||
* 改它会把全站文案一起改掉;此处只做本页的展示层映射,三视图共用一份。 */
|
||||
const TRACE_STATUS_TEXT: Record<number, string> = {
|
||||
0: "待审核", 1: "已同意", 2: "已拒绝", 3: "失效",
|
||||
4: "已退回", 5: "已移交", 6: "已提交", 7: "已撤回",
|
||||
};
|
||||
|
||||
/** 动作标签(口径同 TraceTimeline.actionText:CC/ROUTE 专用文案,任务行查展示层映射) */
|
||||
function recordAction(t: any): string {
|
||||
if (t.kind === "CC") return "已抄送";
|
||||
if (t.kind === "ROUTE") return "系统判断";
|
||||
return TRACE_STATUS_TEXT[t.status] || String(t.status ?? "");
|
||||
}
|
||||
|
||||
/** 节点步结论:任一拒绝 > 任一同意 > 全失效 > 待审核(口径=流程中心 OapiFlowService.stepLabel) */
|
||||
function stepLabel(rows: any[]): string {
|
||||
let denied = false, approved = false, invalid = true;
|
||||
rows.forEach((r) => {
|
||||
const s = r.status === null || r.status === undefined ? -1 : r.status;
|
||||
if (s === 2) denied = true;
|
||||
if (s === 1) approved = true;
|
||||
if (s === 0 || s === 1 || s === 2 || s === 4 || s === 5) invalid = false;
|
||||
});
|
||||
if (denied) return "已拒绝";
|
||||
if (approved) return "已同意";
|
||||
return invalid ? "失效" : "待审核";
|
||||
}
|
||||
|
||||
function labelTagClass(label: string): string {
|
||||
if (label === "已同意") return "green";
|
||||
if (label === "已拒绝") return "red";
|
||||
if (label === "待审核") return "blue";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
||||
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
||||
*/
|
||||
const nodeSteps = computed(() => {
|
||||
const trace: any[] = detail.value?.trace || [];
|
||||
const act: string[] = String(detail.value?.currentNodes || "").split(",").filter(Boolean);
|
||||
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
||||
const steps: any[] = [];
|
||||
const byNode: Record<string, any> = {};
|
||||
rows.forEach((r) => {
|
||||
if (EVENT_KINDS.includes(r.kind)) {
|
||||
steps.push({ type: "event", nodeId: r.nodeId, nodeName: r.nodeName, rows: [r], t: r._t });
|
||||
return;
|
||||
}
|
||||
let s = byNode[r.nodeId];
|
||||
if (!s) {
|
||||
s = { type: "node", nodeId: r.nodeId, nodeName: r.nodeName, rows: [], t: r._t };
|
||||
byNode[r.nodeId] = s;
|
||||
steps.push(s);
|
||||
}
|
||||
s.rows.push(r);
|
||||
if ((r._t || "") > (s.t || "")) s.t = r._t;
|
||||
});
|
||||
steps.forEach((s) => {
|
||||
if (s.type === "node") {
|
||||
s.active = act.includes(s.nodeId);
|
||||
s.label = s.active ? "待审核" : stepLabel(s.rows);
|
||||
} else {
|
||||
s.label = recordAction(s.rows[0]);
|
||||
}
|
||||
});
|
||||
return steps.sort((a, b) => (a.t || "").localeCompare(b.t || ""));
|
||||
});
|
||||
|
||||
function stepClass(s: any): string {
|
||||
if (s.type !== "node") return "";
|
||||
if (s.active) return "is-active";
|
||||
if (s.label === "已拒绝") return "is-reject";
|
||||
if (s.label === "已同意") return "is-done";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stepTagColor(s: any): string {
|
||||
return s.type === "node" ? labelTagClass(s.label) : (RECORD_KIND_TAG[s.rows[0].kind] || "default");
|
||||
}
|
||||
|
||||
/** 办理人行色:1 绿 / 2 红 / 3 失效置灰删除线 / 0 待办 */
|
||||
function rowClass(r: any): string {
|
||||
if (r.status === 1) return "is-done";
|
||||
if (r.status === 2) return "is-reject";
|
||||
if (r.status === 3) return "is-invalid";
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowTimeText(r: any): string {
|
||||
if (r.handleTime) return `办理于 ${fmtTime(r.handleTime)}`;
|
||||
if (r.arriveTime) return `到达 ${fmtTime(r.arriveTime)}`;
|
||||
return fmtTime(rowTime(r));
|
||||
}
|
||||
|
||||
/** 基本信息「当前节点」:currentNodes 命中的节点名 + 办理中后缀 */
|
||||
const currentNodeText = computed(() => {
|
||||
const act = String(detail.value?.currentNodes || "").split(",").filter(Boolean);
|
||||
const names = nodeSteps.value
|
||||
.filter((s) => s.type === "node" && act.includes(s.nodeId))
|
||||
.map((s) => s.nodeName || s.nodeId);
|
||||
return names.length ? `${names.join("、")}(办理中)` : "-";
|
||||
});
|
||||
|
||||
const metaItems = computed(() => {
|
||||
const d = detail.value || {};
|
||||
const items = [
|
||||
{ k: "提交人", v: d.submitterName || "-" },
|
||||
{ k: "提交时间", v: fmtTime(d.submitTime) || "-" },
|
||||
{ k: "办结时间", v: fmtTime(d.finishTime) || "-" },
|
||||
];
|
||||
if (d.currentNodes) items.push({ k: "当前节点", v: currentNodeText.value });
|
||||
return items;
|
||||
});
|
||||
|
||||
/** 表格列:Drawer iframe 宽约 708px(740 抽屉),列宽按此设计;轮次列仅多轮时出现 */
|
||||
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;
|
||||
@@ -310,103 +54,22 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="embed-page" v-if="detail">
|
||||
<div class="head">
|
||||
<div class="head-row">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h"><span>基本信息</span></div>
|
||||
<div class="embed-meta">
|
||||
<div v-for="m in metaItems" :key="m.k" class="embed-meta-i">
|
||||
<div class="embed-meta-k">{{ m.k }}</div>
|
||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 流程简略信息(detail.nodeSummary=APPROVAL 节点名串联,后端早已下发):
|
||||
不展开"流程示意图"也能一眼看清走向,口径同 Notice 详情页的"流程:"摘要行 -->
|
||||
<div class="head-flow" v-if="detail.nodeSummary">流程:{{ detail.nodeSummary }}</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h">
|
||||
<span>办理进度</span>
|
||||
<RadioGroup class="embed-view-seg" :model-value="viewMode" size="small" type="button" @on-change="switchView">
|
||||
<Radio label="node">按节点</Radio>
|
||||
<Radio label="timeline">时间线</Radio>
|
||||
<Radio label="list">列表</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 口径对三视图均生效(时间线走 :mode、列表走办理人列)⇒ 与视图切换器同排常驻;
|
||||
原先放在「按节点」模板内,切到时间线/列表就消失,与实际生效范围不符 -->
|
||||
<RadioGroup class="embed-mode-seg" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">人员视图</Radio>
|
||||
<Radio label="dept">科室视图</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- ① 按节点:同节点多任务每人一行,步内保持后端任务序 -->
|
||||
<template v-if="viewMode === 'node'">
|
||||
<div class="embed-steps" v-if="nodeSteps.length">
|
||||
<div v-for="(s, i) in nodeSteps" :key="`${s.type}-${s.nodeId || 'x'}-${i}`"
|
||||
class="embed-step" :class="stepClass(s)">
|
||||
<span class="embed-step-dot"></span>
|
||||
<div class="embed-step-body">
|
||||
<div class="embed-step-head">
|
||||
<span class="embed-step-name">{{ s.nodeName || s.nodeId || "—" }}</span>
|
||||
<Tag size="small" :color="stepTagColor(s)">{{ s.label }}</Tag>
|
||||
<Tag v-if="s.active" size="small" color="blue">办理中</Tag>
|
||||
</div>
|
||||
<div v-for="(r, j) in s.rows" :key="r.rowKey || j" class="embed-assignee" :class="rowClass(r)">
|
||||
<span class="embed-an">{{ recordActor(r) }}</span>
|
||||
<span class="embed-am">{{ recordAction(r) }}</span>
|
||||
<span class="embed-am">{{ rowTimeText(r) }}</span>
|
||||
<span class="embed-ao" v-if="r.opinion">
|
||||
{{ r.kind === "REJECT" ? "拒绝意见" : "意见" }}:{{ r.opinion }}
|
||||
</span>
|
||||
<span class="embed-ao" v-if="r.edgeName">去向:{{ r.edgeName }}</span>
|
||||
<span class="embed-ao route-hung" v-if="r.kind === 'ROUTE' && !r.outlet">
|
||||
判断失败已挂起(第 {{ r.attempt || 1 }} 次),等待管理端重跑路由
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" v-else>暂无轨迹</div>
|
||||
</template>
|
||||
|
||||
<!-- ② 时间线:扁平行 + 多轮分隔(复用共用组件,口径不变) -->
|
||||
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
||||
:status-text="TRACE_STATUS_TEXT" />
|
||||
|
||||
<!-- ③ 列表:整个流程周期的完整事件流水 -->
|
||||
<Table v-else size="small" :columns="recordColumns" :data="detail.trace || []" />
|
||||
</div>
|
||||
|
||||
<details class="embed-fold" @toggle="onDiagramToggle">
|
||||
<summary>流程示意图</summary>
|
||||
<div class="embed-fold-b">
|
||||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details v-if="summaryFields.length" class="embed-fold">
|
||||
<summary>业务信息</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in summaryFields" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<InstanceBody :detail="detail" :diagram-src="diagramSrc" />
|
||||
</div>
|
||||
<div v-else-if="loading" class="embed-page muted">加载中…</div>
|
||||
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
|
||||
@@ -420,84 +83,11 @@ onMounted(async () => {
|
||||
/* 标题区吸顶(贴合设计基线):左右负外边距让白色背景铺满滚动容器,滚动时内容不从两侧留白露出 */
|
||||
.head {
|
||||
position: sticky; top: 0; z-index: 5; background: #fff;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin: 0 -20px 16px; padding: 14px 20px 12px; border-bottom: 1px solid #f2f4f7;
|
||||
}
|
||||
.head-row { display: flex; align-items: center; gap: 8px; }
|
||||
.head-flow { margin-top: 6px; font-size: 12px; line-height: 1.5; color: #8a94a6; word-break: break-all; }
|
||||
.title { font-size: 16px; font-weight: 600; flex: 1; min-width: 0; }
|
||||
.muted { color: #999; }
|
||||
.expired { padding: 48px 16px; text-align: center; }
|
||||
|
||||
/* 区块标题:左侧蓝条 + 右侧控件 */
|
||||
.embed-sec { margin-bottom: 18px; }
|
||||
.embed-sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
|
||||
.embed-sec-h > span:first-child { position: relative; padding-left: 9px; }
|
||||
.embed-sec-h > span:first-child::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: #2d8cf0;
|
||||
}
|
||||
/* 两处切换器(视图 / 口径)现同排在标题行,同为 iView small button group、几何与配色一致
|
||||
(活动态 #0d9488、非活动 #515a6e/#dcdee2)。⚠️ 两者都落在 .embed-sec-h(600) 内 ⇒ 会继承粗体,
|
||||
必须显式回常规(管理面同款控件 InstanceDetail/TaskHandle 的 .trace-mode 也是这个写法)。 */
|
||||
.embed-view-seg { margin-left: auto; font-weight: normal; }
|
||||
/* 口径紧随视图切换器:与 .embed-sec-h 的 10px gap 叠加为 24px,避免两组按钮被看成一排五选一 */
|
||||
.embed-mode-seg { margin-left: 14px; font-weight: normal; }
|
||||
|
||||
/* 基本信息:四列等宽(740 抽屉实测放得下时间戳,超出省略号 + title 兜底) */
|
||||
.embed-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #f2f4f7; border-radius: 8px; overflow: hidden; }
|
||||
.embed-meta-i { padding: 11px 14px; border-right: 1px solid #e8eaec; min-width: 0; }
|
||||
.embed-meta-i:last-child { border-right: none; }
|
||||
.embed-meta-k { font-size: 12px; color: #808695; margin-bottom: 4px; }
|
||||
.embed-meta-v { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 按节点视图 */
|
||||
.embed-steps { position: relative; }
|
||||
.embed-step { display: flex; gap: 11px; padding-bottom: 16px; position: relative; }
|
||||
.embed-step:last-child { padding-bottom: 2px; }
|
||||
.embed-step:not(:last-child)::before {
|
||||
content: ""; position: absolute; left: 5px; top: 16px; bottom: 0; width: 2px; background: #f2f4f7;
|
||||
}
|
||||
.embed-step-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; background: #c8ced8; margin-top: 5px; flex-shrink: 0;
|
||||
z-index: 1; border: 2px solid #fff; box-shadow: 0 0 0 1px #e8eaec;
|
||||
}
|
||||
.embed-step.is-done .embed-step-dot { background: #19be6b; box-shadow: 0 0 0 1px #b7ebc9; }
|
||||
.embed-step.is-reject .embed-step-dot { background: #ed4014; box-shadow: 0 0 0 1px #ffd2c9; }
|
||||
.embed-step.is-active .embed-step-dot { background: #2d8cf0; box-shadow: 0 0 0 3px rgba(45, 140, 240, .18); }
|
||||
.embed-step-body { flex: 1; min-width: 0; }
|
||||
.embed-step-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.embed-step-name { font-size: 14px; font-weight: 600; }
|
||||
.embed-assignee {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px;
|
||||
padding: 6px 10px; margin-bottom: 5px; border-radius: 6px; background: #fafbfc; border: 1px solid #f0f2f5;
|
||||
}
|
||||
.embed-assignee:last-child { margin-bottom: 0; }
|
||||
.embed-an { font-weight: 600; }
|
||||
.embed-assignee.is-done .embed-an { color: #19be6b; }
|
||||
.embed-assignee.is-reject .embed-an { color: #ed4014; }
|
||||
.embed-am { color: #808695; font-size: 12px; }
|
||||
.embed-ao { color: #515a6e; width: 100%; padding-top: 2px; }
|
||||
.embed-ao.route-hung { color: #ed4014; }
|
||||
.embed-assignee.is-invalid { opacity: .5; }
|
||||
.embed-assignee.is-invalid .embed-an { text-decoration: line-through; text-decoration-color: #b9bcc4; }
|
||||
|
||||
/* 折叠块:流程示意图 / 业务信息,默认收缩 */
|
||||
.embed-fold { border: 1px solid #f2f4f7; border-radius: 8px; background: #fff; margin-bottom: 18px; }
|
||||
.embed-fold > summary {
|
||||
cursor: pointer; list-style: none; padding: 12px 16px; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.embed-fold > summary::-webkit-details-marker { display: none; }
|
||||
.embed-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; transition: .2s; font-size: 12px; }
|
||||
.embed-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.embed-fold-b { padding: 2px 16px 16px; }
|
||||
.diagram-img {
|
||||
max-width: 100%; height: auto; display: block; margin: 0 auto;
|
||||
border: 1px solid #f2f4f7; border-radius: 6px;
|
||||
}
|
||||
/* 业务摘要:折叠块内再收一层卡,与摘要行成组(设计基线口径) */
|
||||
.sf { border: 1px solid #e6eefb; border-radius: 8px; overflow: hidden; background: #fcfdff; }
|
||||
.sf-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #eef3fb; font-size: 13px; }
|
||||
.sf-row:last-child { border-bottom: none; }
|
||||
.sf-k { width: 84px; flex-shrink: 0; color: #808695; }
|
||||
.sf-v { flex: 1; min-width: 0; word-break: break-all; font-weight: 500; }
|
||||
</style>
|
||||
|
||||
@@ -458,7 +458,7 @@ watch(() => route.query, () => load());
|
||||
rx="8"
|
||||
:class="{ 'node-active': nodeState(n) === 'active' }"
|
||||
:fill="nodeState(n) === 'rejected' ? '#fff1f0' : '#fff'"
|
||||
:stroke="STATE_STROKE[nodeState(n) || ''] || '#2d8cf0'"
|
||||
:style="{ stroke: STATE_STROKE[nodeState(n) || ''] || 'var(--brand)' }"
|
||||
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
|
||||
<text :x="pos(n).x + pos(n).w / 2"
|
||||
:y="pos(n).y + (nameLines(n).length >= 2 ? 25 : 27)"
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
|
||||
// 办件详情(管理面,2026-10-11 整合):正文升级为与嵌入页 /embed/instance 同源同款——共用
|
||||
// InstanceBody.vue(full 口径):流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号,
|
||||
// 仅管理面显示、嵌入页不外露)+ 基本信息(含业务详情链接)+ 办理进度三视图 + 折叠块
|
||||
// (流程示意图/业务信息/业务表单数据)。动作文案统一为展示层映射(已同意/待审核…),
|
||||
// 全局 TASK_STATUS 码表不动。
|
||||
// 轨迹两档显示(2026-09-18):mode=user(人员,缺省)|dept(科室),写进 query 便于分享/刷新保持,
|
||||
// 并随"流程示意图"跳转带给图示页(两边口径一致)。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
// 并随"流程示意图"跳转带给图示页(两边口径一致)——口径切换由组件 emit,本页落 URL。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -14,7 +18,7 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
|
||||
/** 显示口径:dept=按科室 / user=按人员(不传=人员) */
|
||||
/** 显示口径:dept=按科室 / user=按人员(不传=人员);组件 emit → 本页写 query 持久化 */
|
||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||
|
||||
function switchMode(val: string) {
|
||||
@@ -22,43 +26,6 @@ function switchMode(val: string) {
|
||||
router.replace({ query: { ...route.query, mode: val } });
|
||||
}
|
||||
|
||||
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 [];
|
||||
});
|
||||
|
||||
/** 业务表单数据(§3.4 只读渲染;解析失败降级一行原文;无 formData 时整块隐藏) */
|
||||
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) }];
|
||||
});
|
||||
|
||||
const branchText = computed(() => {
|
||||
const branches = detail.value?.branches || [];
|
||||
if (!branches.length) return "";
|
||||
@@ -68,17 +35,11 @@ const branchText = computed(() => {
|
||||
|
||||
/** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。
|
||||
* img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下
|
||||
* 裸 /api 会落到网关根导致 404。mode 跟随本页选择,缩略图与轨迹口径一致。 */
|
||||
* 裸 /api 会落到网关根导致 404。mode 跟随本页口径,缩略图与轨迹一致(src 随口径切换重新 load)。 */
|
||||
const diagramSrc = computed(
|
||||
() => `${config.serviceHost}/api/flow/diagram.png?instanceId=${route.params.instanceId}&mode=${mode.value}`,
|
||||
);
|
||||
|
||||
/** PNG 按 SCALE=2 出图:显示逻辑尺寸(自然宽一半),超宽收缩到容器(src 随口径切换会重新 load) */
|
||||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function onDiagramLoad() {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 跳流程图时带上当前口径(图示页读 mode,缺省人员) */
|
||||
function toDiagram() {
|
||||
router.push({ path: "/diagram", query: { instanceId: String(route.params.instanceId), mode: mode.value } });
|
||||
@@ -89,7 +50,7 @@ function statusName(s: number) {
|
||||
}
|
||||
|
||||
function statusColor(s: number) {
|
||||
return s === 10 ? "blue" : s === 20 ? "green" : s === 30 ? "red" : "gray";
|
||||
return INSTANCE_STATUS_COLOR[s] || "gray";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -99,10 +60,6 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -115,54 +72,12 @@ onMounted(async () => {
|
||||
<span class="muted" v-if="branchText">{{ branchText }}</span>
|
||||
</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 class="row" v-if="detail.bizUrl"><span class="k">业务详情</span>
|
||||
<a :href="detail.bizUrl" target="_blank">查看业务详情</a></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>
|
||||
<InstanceBody full :detail="detail" :diagram-src="diagramSrc" :initial-mode="mode" @mode-change="switchMode">
|
||||
<!-- 折叠块 summary 内的入口:stop 防止点链接同时触发 details 折叠 -->
|
||||
<template #diagram-link>
|
||||
<a class="fold-link" @click.stop.prevent="toDiagram()">查看大图</a>
|
||||
</template>
|
||||
<template v-if="formRows.length">
|
||||
<Divider size="small">业务表单数据</Divider>
|
||||
<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>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
流程示意图
|
||||
<a class="diagram-more" @click="toDiagram()">查看大图</a>
|
||||
</template>
|
||||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
</Card>
|
||||
</InstanceBody>
|
||||
</div>
|
||||
<div v-else class="page muted">加载中…</div>
|
||||
</template>
|
||||
@@ -172,13 +87,7 @@ onMounted(async () => {
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.head a { cursor: pointer; }
|
||||
.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; }
|
||||
.diagram-box { display: flex; justify-content: center; }
|
||||
.diagram-img { max-width: 100%; }
|
||||
.diagram-more { float: right; font-size: 12px; font-weight: normal; margin-right: 16px; }
|
||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||||
/* 折叠块 summary 右侧入口:summary 本体 600 粗体,链接回常规 */
|
||||
.fold-link { font-size: 12px; font-weight: normal; }
|
||||
</style>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 我的办件(提交人视角):列表 + 撤回/催办/详情
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const router = useRouter();
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
|
||||
const dataParams = ref<{ status?: number }>({ status: undefined });
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "业务类型", key: "bizType", width: 110 },
|
||||
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
|
||||
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: "当前节点", key: "currentNodes", width: 150 },
|
||||
{ title: "提交时间", width: 170, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||
{ title: "办结时间", width: 170, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") },
|
||||
{ title: "操作", width: 180, align: "center", fixed: "right", render: (h: any, p: any) => {
|
||||
const btns = [Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=101`))];
|
||||
if (p.row.status === 10) {
|
||||
btns.push(Utils.operatingHandle(h, "撤回", "icon-yaohao", () => onWithdraw(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "催办", "icon-fasong", () => onUrge(p.row)));
|
||||
}
|
||||
return btns;
|
||||
} },
|
||||
];
|
||||
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const tableApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.my({ status: dataParams.value.status || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
|
||||
function onSearch() {
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
async function onWithdraw(row: any) {
|
||||
await MyConfirm({
|
||||
title: "撤回",
|
||||
msg: `确定撤回办件「${row.bizTitle}」吗?`,
|
||||
info: "撤回后流程终止,不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await flowApi.withdraw(row.instanceId).send();
|
||||
Message.success("已撤回");
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onUrge(row: any) {
|
||||
try {
|
||||
await flowApi.urge(row.instanceId).send();
|
||||
Message.success("已催办");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "催办失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
|
||||
<Option :value="10" label="审批中" />
|
||||
<Option :value="20" label="已完成" />
|
||||
<Option :value="30" label="已拒绝" />
|
||||
<Option :value="40" label="已撤回" />
|
||||
</Select>
|
||||
</div>
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+317
-60
@@ -1,17 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
|
||||
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务信息区块(只认 summaryFields)+ "查看业务详情"跳 bizUrl
|
||||
// + 历史意见 + 同意/拒绝/退回/移交。业务表单数据(formDataJson)不在办理页渲染——托管模式下它是
|
||||
// 条件路由入参,只进管理面办件详情。
|
||||
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
|
||||
import { toAppHashUrl } from "@/utils/appUrl";
|
||||
import { flowSubmitFailTip, flowReloadFailTip } from "@/utils/flowError";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => String(route.params.taskId || ""));
|
||||
|
||||
/**
|
||||
* 去壳态(embed=1,v2.1 完整办理内嵌):同一个 /task/:taskId 组件、同一套 CAS 用户面接口,
|
||||
* 只把"办完后跳工作台""跳流程图""看业务详情"这三个会离开当前壳的动作为父页面接管——
|
||||
* iframe 里跳出工作台等于把 flow-center 菜单塞进子应用弹窗。
|
||||
*/
|
||||
const isBare = computed(() => isBareTaskRoute(route.path, route.query.embed));
|
||||
/** bare 下办理成功后的结果条文案(不跳走,父页面自己决定关不关) */
|
||||
const doneTip = ref("");
|
||||
/** 办理失败的内联提示:全局 toast 只说服务端给的那句,这里补「哪个动作没生效 + 现在该怎么办」 */
|
||||
const failTip = ref("");
|
||||
|
||||
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
|
||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||
|
||||
@@ -37,6 +52,18 @@ const showTransfer = ref(false);
|
||||
const transferUser = ref("");
|
||||
const transferName = ref("");
|
||||
|
||||
/**
|
||||
* 业务信息行:唯一内容 = 约定键 bizSnapshot.summaryFields(子应用决定展示哪些字段、用什么中文 label,
|
||||
* 口径同 InstanceBody「业务信息」)。不再逐字平铺 bizSnapshot 原始键——那会把 title/summaryFields 这类
|
||||
* 英文 key 直接摆给办理人看,对象值还会渲染成 [object Object]。
|
||||
* 无 summaryFields(老数据/未接入约定的子应用)只回退已知的人类可读键,未知键一律不外露。
|
||||
*/
|
||||
const SUMMARY_FALLBACK_LABELS: Array<[string, string]> = [
|
||||
["title", "标题"],
|
||||
["summary", "摘要"],
|
||||
["attachmentNames", "附件"],
|
||||
];
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
@@ -46,28 +73,25 @@ const snapshotRows = computed(() => {
|
||||
} catch {
|
||||
return [{ k: "摘要", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
||||
if (!obj || typeof obj !== "object") return [];
|
||||
const sf = obj.summaryFields;
|
||||
if (Array.isArray(sf) && sf.length) {
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
k: String(x.label),
|
||||
v: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务表单数据(§3.4):formDataJson 逐键值只读渲染;数组值按文件名/项连接;解析失败降级一行原文 */
|
||||
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: formValueText(v) }));
|
||||
}
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
return SUMMARY_FALLBACK_LABELS
|
||||
.filter(([key]) => obj[key] !== null && obj[key] !== undefined && obj[key] !== "")
|
||||
.map(([key, label]) => ({ k: label, v: formValueText(obj[key]) }));
|
||||
});
|
||||
|
||||
/** 值 → 展示文本(业务信息回退行共用):数组按文件名/项连接,对象降级 JSON */
|
||||
function formValueText(v: any): string {
|
||||
if (v === null || v === undefined) return "-";
|
||||
if (Array.isArray(v)) {
|
||||
@@ -90,9 +114,58 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 业务详情绝对地址(后端按 bizUrl + applyAppCode 查应用注册表 pc_url 拼出)。
|
||||
* bizUrl 本身是裸相对路径,流内直开时没有宿主页面可借基址,直接 window.open 会落到本应用 origin 上。 */
|
||||
const bizUrlAbs = computed(() => detail.value?.bizUrlAbs || "");
|
||||
/** 入口可见性:bare 由父页面按自己路由接管(原样传裸路径),独立打开必须拿到绝对地址 */
|
||||
const canOpenBiz = computed(() => !!detail.value?.bizUrl && (isBare.value || !!bizUrlAbs.value));
|
||||
|
||||
function openBizUrl() {
|
||||
if (detail.value?.bizUrl) {
|
||||
window.open(detail.value.bizUrl, "_blank");
|
||||
if (isBare.value) {
|
||||
postToParent({ type: "open-biz", bizId: detail.value?.bizId ?? null, bizUrl: detail.value?.bizUrl || "" });
|
||||
return;
|
||||
}
|
||||
if (!bizUrlAbs.value) {
|
||||
Message.warning("业务详情地址暂不可用,请在业务系统内查看");
|
||||
return;
|
||||
}
|
||||
window.open(bizUrlAbs.value, "_blank");
|
||||
}
|
||||
|
||||
/** 流程图仍要能看,但 bare 下不能用 router.push——/diagram 不是 /task/ 前缀,会在 iframe 里长出菜单壳 */
|
||||
function openDiagram() {
|
||||
const target = {
|
||||
path: "/diagram",
|
||||
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
|
||||
};
|
||||
if (isBare.value) {
|
||||
// 新窗口整页打开:地址必须只剩一个 hash(把 resolve 出的 "#/diagram?…" 直接当 path
|
||||
// 会在当前地址后面长出第二个 hash,页面原地不动且不报错——线上 P1-3 即此)
|
||||
window.open(toAppHashUrl(router.resolve(target).href), "_blank");
|
||||
return;
|
||||
}
|
||||
router.push(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理成功后的收尾:bare 回 postMessage(handled) + 结果条 + 重新拉详情(本任务转为不可办理态,
|
||||
* 避免"再点一次"),非 bare 保持原样跳工作台。
|
||||
*/
|
||||
async function afterHandled(action: FlowEmbedAction, tip: string) {
|
||||
myTaskId.value = "";
|
||||
doneTip.value = tip;
|
||||
try {
|
||||
if (!isBare.value) {
|
||||
await router.push("/workbench");
|
||||
return;
|
||||
}
|
||||
postToParent({ type: "handled", taskId: taskId.value, action });
|
||||
doneTip.value = tip;
|
||||
opinion.value = "";
|
||||
chosenEdge.value = "";
|
||||
await load();
|
||||
} catch {
|
||||
failTip.value = flowReloadFailTip("办理");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +175,10 @@ async function submit(approved: boolean) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
const label = approved ? "同意" : "拒绝";
|
||||
// F05:本页有行内失败提示,抑制全局 toast,同一失败只出现一条
|
||||
const silent = { silentBizToast: true };
|
||||
try {
|
||||
if (approved) {
|
||||
// 多出口节点必须带所选出口
|
||||
@@ -111,29 +188,56 @@ async function submit(approved: boolean) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
||||
Message.success("已同意");
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName, silent).send();
|
||||
} else {
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||
Message.success("已拒绝");
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim(), silent).send();
|
||||
}
|
||||
router.push("/workbench");
|
||||
} catch (e: any) {
|
||||
// F04:服务端明确拒绝才说"被拒绝";超时/断网/丢响应只说"结果待确认",
|
||||
// 不能断言未生效——请求可能已提交,前端无从推导回滚
|
||||
failTip.value = flowSubmitFailTip(label, e);
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
// 办理已成功:后续收尾(通知父页/重新拉详情)失败不能改口说"没办成"
|
||||
try {
|
||||
Message.success(approved ? "已同意" : "已拒绝");
|
||||
await afterHandled(approved ? "approve" : "reject",
|
||||
approved ? "已同意,本任务办理完成" : "已拒绝,本任务办理完成");
|
||||
} catch (e: any) {
|
||||
failTip.value = flowReloadFailTip(label);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二次确认:独立打开用 Modal.confirm(原生体验);bare 态一律行内确认。
|
||||
* iframe 内弹 Modal 会在父页面弹窗之上再叠一层遮罩——父页遮罩盖不到 iframe,视觉上是"弹窗套弹窗",
|
||||
* 且弹出位置只在本页顶部(长表单下离按钮很远)。行内确认条没有这个问题。
|
||||
*/
|
||||
const confirming = ref(false);
|
||||
|
||||
function confirmApprove() {
|
||||
if (outlets.value.length > 1 && !chosenEdge.value) {
|
||||
Message.warning("该节点有多个办理去向,请先选择");
|
||||
return;
|
||||
}
|
||||
if (isBare.value) {
|
||||
confirming.value = true;
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: "同意该任务?",
|
||||
onOk: () => submit(true),
|
||||
});
|
||||
}
|
||||
|
||||
function doApprove() {
|
||||
confirming.value = false;
|
||||
submit(true);
|
||||
}
|
||||
|
||||
// ===== 退回 =====
|
||||
function openReturn() {
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
@@ -143,16 +247,42 @@ function openReturn() {
|
||||
|
||||
function doReturn() {
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
|
||||
targets[targets.length - 1] || null;
|
||||
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined)
|
||||
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined,
|
||||
opinion.value.trim() || undefined, { silentBizToast: true })
|
||||
.send()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
Message.success("已退回");
|
||||
showReturn.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("return", "已退回,目标节点重新生成待办");
|
||||
})
|
||||
.catch((e: any) => { failTip.value = flowSubmitFailTip("退回", e); })
|
||||
.finally(() => { submitting.value = false; });
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 退回发起人修改(与上面的"退回到上游审批节点"是两件事,弹窗文案分开):
|
||||
* 服务端置办件为 50 退回待修改、失效全部在途代次;发起人重提后同实例同轮次从第一级重审。
|
||||
* 只有服务端返回成功才更新本页状态。
|
||||
*/
|
||||
function doReturnToSubmitter() {
|
||||
if (!opinion.value.trim()) {
|
||||
Message.warning("退回发起人修改必须填写退回原因");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
flowApi.returnToSubmitter(myTaskId.value, opinion.value.trim(), { silentBizToast: true })
|
||||
.send()
|
||||
.then(async () => {
|
||||
Message.success("已退回发起人修改");
|
||||
showReturn.value = false;
|
||||
await afterHandled("return", "已退回发起人修改,等待发起人重新提交");
|
||||
})
|
||||
.catch((e: any) => { failTip.value = flowSubmitFailTip("退回发起人", e); })
|
||||
.finally(() => { submitting.value = false; });
|
||||
}
|
||||
|
||||
@@ -164,22 +294,64 @@ async function doTransfer() {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
try {
|
||||
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
|
||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined, { silentBizToast: true })
|
||||
.send();
|
||||
Message.success("已移交");
|
||||
showTransfer.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("transfer", "已移交,本任务由接手人办理");
|
||||
} catch (e: any) {
|
||||
failTip.value = flowSubmitFailTip("移交", e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// ===== 去壳态高度上报(父页面 iframe 自适应,避免固定高度里内滚两层) =====
|
||||
const pageEl = ref<HTMLElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastReportedHeight = 0;
|
||||
|
||||
/** 内容变化(详情加载完、弹窗开合)都会触发;8px 死区防止亚像素抖动刷屏 */
|
||||
function reportHeight() {
|
||||
const el = pageEl.value;
|
||||
if (!el) return;
|
||||
const height = el.scrollHeight;
|
||||
if (!height || Math.abs(height - lastReportedHeight) < 8) return;
|
||||
lastReportedHeight = height;
|
||||
postToParent({ type: "height", height });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => detail.value,
|
||||
async () => {
|
||||
if (!isBare.value || !detail.value) return;
|
||||
await nextTick();
|
||||
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => reportHeight());
|
||||
}
|
||||
if (resizeObserver && pageEl.value) resizeObserver.observe(pageEl.value);
|
||||
reportHeight();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
window.addEventListener("resize", reportHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", reportHeight);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-if="detail">
|
||||
<div ref="pageEl" class="page" :class="{ bare: isBare }" v-if="detail">
|
||||
<Alert v-if="doneTip" type="success" show-icon class="done-tip">{{ doneTip }}</Alert>
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
||||
@@ -189,31 +361,24 @@ onMounted(load);
|
||||
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 业务摘要区块(M1 办理页核心;富文本正文留在业务方,经 bizUrl 查看) -->
|
||||
<!-- 业务信息区块(M1 办理页核心):只渲染子应用给的 summaryFields 中文标签行;
|
||||
富文本正文留在业务方,经 bizUrl 查看 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>业务摘要</template>
|
||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供摘要)</div>
|
||||
<template #title>业务信息</template>
|
||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供业务信息)</div>
|
||||
<div v-else 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>
|
||||
<Button v-if="detail.bizUrl" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
|
||||
<Button v-if="canOpenBiz" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
|
||||
查看业务详情
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 业务表单数据(§3.4 只读渲染,非表单引擎;无 formData 时整块隐藏) -->
|
||||
<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>
|
||||
<!-- 业务表单数据不在此渲染(§3.4):formDataJson 是托管模式的条件路由入参(deptId/deptName 之类),
|
||||
对办理人是内部噪音;原始表单只在管理面办件详情(InstanceBody full)看 -->
|
||||
|
||||
<!-- 办理区 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
@@ -228,17 +393,59 @@ onMounted(load);
|
||||
</div>
|
||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
||||
placeholder="同意可选填意见;拒绝必须填写意见" />
|
||||
<Alert v-if="failTip" type="error" show-icon class="fail-tip">
|
||||
{{ failTip }}
|
||||
<div class="muted">请先刷新核对最新状态;如仍有疑问,把页面提示与操作时间反馈给流程管理员。</div>
|
||||
</Alert>
|
||||
<div class="actions" v-if="myTaskId">
|
||||
<template v-if="confirming">
|
||||
<span class="confirm-tip">同意该任务?</span>
|
||||
<Button type="primary" :loading="submitting" @click="doApprove">确定</Button>
|
||||
<Button @click="confirming = false">取消</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||
<Button :loading="submitting" @click="openReturn">退回</Button>
|
||||
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</div>
|
||||
|
||||
<!-- bare 态退回/移交:行内面板(iframe 里再弹 Modal 会与父页面弹窗叠成三层) -->
|
||||
<div v-if="isBare && showReturn" class="inline-panel">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">退回至:</span>
|
||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||
placeholder="缺省=最近一个已办节点">
|
||||
<Option value="">(最近一个已办节点)</Option>
|
||||
<Option v-for="t in detail.returnTargets || []" :key="t.nodeId" :value="t.nodeId"
|
||||
:label="t.nodeName || t.nodeId" />
|
||||
</Select>
|
||||
<Button size="small" type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
|
||||
<Button size="small" :loading="submitting" @click="doReturnToSubmitter">退回发起人修改</Button>
|
||||
<Button size="small" @click="showReturn = false">取消</Button>
|
||||
</div>
|
||||
<div class="muted modal-tip">
|
||||
退回节点:目标节点重新生成待办,并行分支内节点不可作为退回目标。<br>
|
||||
退回发起人修改:办件转入「退回待修改」,发起人修改后重新提交仍属同一办件、同一申报轮次,
|
||||
历史审批记录保留,并从第一级重新审批。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isBare && showTransfer" class="inline-panel">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">移交对象工号:</span>
|
||||
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
||||
<Button size="small" type="primary" :loading="submitting" @click="doTransfer">确认移交</Button>
|
||||
<Button size="small" @click="showTransfer = false">取消</Button>
|
||||
</div>
|
||||
<div class="muted modal-tip">移交后由对方接手本任务办理;意见可在下方填写交接说明。</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 流程轨迹 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<!-- 流程轨迹(独立打开):中心侧唯一上下文,常显 -->
|
||||
<Card v-if="!isBare" class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||
@@ -247,12 +454,30 @@ onMounted(load);
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<Button size="small" ghost type="primary"
|
||||
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
|
||||
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗 -->
|
||||
<Modal v-model="showReturn" title="退回" :width="480">
|
||||
<!-- 流程轨迹(bare 态):父页面已有"审批进度"卡片,同一份 trace 不再整块铺开——
|
||||
默认收起,需要核对明细(路由判断/抄送/退回留痕)时再展开 -->
|
||||
<Card v-else class="block" :bordered="false" dis-hover>
|
||||
<details class="trace-fold">
|
||||
<summary>
|
||||
<span class="trace-fold-title">流程轨迹({{ (detail.trace || []).length }} 条)</span>
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button"
|
||||
@click.stop @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</summary>
|
||||
<div class="trace-fold-b">
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗(独立打开;bare 态走行内面板,见办理区) -->
|
||||
<Modal v-if="!isBare" v-model="showReturn" title="退回" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">退回至:</span>
|
||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||
@@ -262,15 +487,20 @@ onMounted(load);
|
||||
:label="t.nodeName || t.nodeId" />
|
||||
</Select>
|
||||
</div>
|
||||
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</div>
|
||||
<div class="muted modal-tip">
|
||||
退回节点:目标节点重新生成待办,并行分支内节点不可作为退回目标。<br>
|
||||
退回发起人修改:办件转入「退回待修改」,发起人重新提交后仍属同一办件、同一申报轮次,
|
||||
历史审批记录保留,并从第一级重新审批(须填写退回原因)。
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="showReturn = false">取消</Button>
|
||||
<Button :loading="submitting" @click="doReturnToSubmitter">退回发起人修改</Button>
|
||||
<Button type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 移交弹窗 -->
|
||||
<Modal v-model="showTransfer" title="移交" :width="480">
|
||||
<!-- 移交弹窗(独立打开;bare 态走行内面板) -->
|
||||
<Modal v-if="!isBare" v-model="showTransfer" title="移交" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">移交对象工号:</span>
|
||||
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
||||
@@ -287,6 +517,11 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
/* 去壳态:iframe 里父页面已给了标题与外边距,这里只留最小呼吸(也稳住高度上报的抖动) */
|
||||
.page.bare { padding: 8px 10px; }
|
||||
.done-tip { margin-bottom: 10px; }
|
||||
.fail-tip { margin: 10px 0 0; }
|
||||
.fail-tip .muted { margin-top: 4px; font-size: 12px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
@@ -294,7 +529,15 @@ onMounted(load);
|
||||
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
|
||||
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
|
||||
.biz-link { margin-top: 8px; }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
|
||||
.confirm-tip { color: #515a6e; font-size: 13px; }
|
||||
/* bare 态行内动作面板:与按钮行同宽,浅底描边,不遮挡父页面 */
|
||||
.inline-panel {
|
||||
margin-top: 10px; padding: 10px 12px;
|
||||
border: 1px solid #e8eaec; border-radius: 6px; background: #fafbfc;
|
||||
}
|
||||
.inline-panel .modal-row { margin-bottom: 4px; }
|
||||
.inline-panel .modal-row .ivu-btn { margin-left: 8px; }
|
||||
.muted { color: #999; }
|
||||
.outlet { margin-bottom: 8px; display: flex; align-items: center; }
|
||||
.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; }
|
||||
@@ -305,4 +548,18 @@ onMounted(load);
|
||||
.opinion { color: #666; }
|
||||
.time { color: #aaa; font-size: 12px; }
|
||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||||
/* bare 态轨迹折叠:summary 自绘(去默认三角),展开后内容与独立页一致 */
|
||||
.trace-fold > summary {
|
||||
cursor: pointer; list-style: none; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.trace-fold > summary::-webkit-details-marker { display: none; }
|
||||
.trace-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; font-size: 12px; transition: .2s; }
|
||||
.trace-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.trace-fold-title { position: relative; padding-left: 9px; }
|
||||
.trace-fold-title::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
|
||||
}
|
||||
.trace-fold-b { padding-top: 12px; }
|
||||
</style>
|
||||
|
||||
+197
-46
@@ -1,29 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)。
|
||||
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页状态各自独立
|
||||
import { ref } from "vue";
|
||||
// 工作台:我的待办 / 我的已办 / 我提交的 / 抄送我的(概览 §9.4 主路径查询,走 assignee 索引)。
|
||||
// 四页签均有「按应用筛选」(办件定义归属 flw_def.app_key)。
|
||||
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页与筛选状态各自独立
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import { flowSubmitFailTip } from "@/utils/flowError";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
type TabKey = "todo" | "done" | "cc" | "my";
|
||||
|
||||
const router = useRouter();
|
||||
const tab = ref<"todo" | "done" | "cc">("todo");
|
||||
const tab = ref<TabKey>("todo");
|
||||
const drawerVisible = ref(false);
|
||||
const drawerInstanceId = ref<string | number>("");
|
||||
const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const ccTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
// tabs 页:表格高度在常规骨架上再扣 Tab 条
|
||||
const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
|
||||
const myTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const myParams = ref<{ status?: number }>({ status: undefined });
|
||||
// 应用筛选(2026-10-12):后端按办件定义归属 flw_def.app_key 过滤;四页签筛选状态各自独立
|
||||
const apps = ref<{ appKey: string; appName: string }[]>([]);
|
||||
const filters = reactive<Record<TabKey, string>>({ todo: "", done: "", cc: "", my: "" });
|
||||
const tableRefs: Record<TabKey, typeof todoTableRef> = {
|
||||
todo: todoTableRef, done: doneTableRef, cc: ccTableRef, my: myTableRef,
|
||||
};
|
||||
|
||||
const todoColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 160 },
|
||||
{ title: "当前节点", key: "nodeName", width: 160 },
|
||||
{ title: "提交人", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || p.row.assigneeName || "-") },
|
||||
{ title: "到达时间", width: 170, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "办理", "icon-edit", () => goHandle(p.row)) },
|
||||
{ title: "提交人", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || "-") },
|
||||
{ title: "到达时间", width: 190, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
|
||||
actionColumn([{ key: "handle", label: "办理", icon: "icon-edit", on: (r: any) => goHandle(r) }], { mode: "text", width: 100, fixed: "right" }),
|
||||
];
|
||||
|
||||
const doneColumns = [
|
||||
@@ -35,18 +49,18 @@ const doneColumns = [
|
||||
title: "节点", minWidth: 150, render: (h: any, p: any) => {
|
||||
const kids: any[] = [h("span", p.row.nodeName || p.row.nodeId || "-")];
|
||||
if (p.row.doneCount > 1) {
|
||||
kids.push(h("Tag", { color: "default", size: "small", style: "margin-left:6px" },
|
||||
kids.push(h(Tag, { color: "default", size: "small", style: "margin-left:6px" },
|
||||
() => `共办理 ${p.row.doneCount} 次`));
|
||||
}
|
||||
return h("div", kids);
|
||||
},
|
||||
},
|
||||
{ title: "结果", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: TASK_STATUS_COLOR[p.row.status] || "default" }, () => TASK_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: "意见", key: "opinion", minWidth: 120 },
|
||||
{ title: "办理时间", width: 170, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) },
|
||||
{ title: "结果", width: 90, align: "center", render: (h: any, p: any) => h(Tag, { color: TASK_STATUS_COLOR[p.row.status] || "default" }, () => TASK_STATUS[p.row.status] || p.row.status) },
|
||||
// 意见可能很长(退回理由/审批批语),单行截断 + hover 全文
|
||||
{ title: "意见", key: "opinion", minWidth: 120, ellipsis: true, tooltip: true },
|
||||
{ title: "办理时间", width: 190, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) },
|
||||
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus) },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
|
||||
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) }], { mode: "text", width: 100, fixed: "right" }),
|
||||
];
|
||||
|
||||
// 抄送行是 NOTIFY 知会(非任务):无 taskId/status,直接跳办件详情
|
||||
@@ -54,53 +68,190 @@ const ccColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "抄送节点", key: "nodeName", width: 160 },
|
||||
{ title: "抄送时间", width: 170, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) },
|
||||
{ title: "抄送时间", width: 190, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) },
|
||||
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus || "-") },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
|
||||
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) }], { mode: "text", width: 100, fixed: "right" }),
|
||||
];
|
||||
|
||||
// 我提交的(提交人视角):2026-10-12 由独立页 /my 并入本页签,口径不变
|
||||
const myColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "业务类型", key: "bizType", width: 110 },
|
||||
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
|
||||
h(Tag, { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: "当前节点", key: "currentNodes", width: 150 },
|
||||
{ title: "提交时间", width: 190, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||
{ title: "办结时间", width: 190, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") },
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) },
|
||||
{
|
||||
key: "resubmit", label: "重新提交", icon: "icon-fasong", on: (r: any) => onResubmit(r),
|
||||
disabledWhen: (r: any) => r.status !== 50,
|
||||
},
|
||||
{ key: "withdraw", label: "撤回", icon: "icon-yaohao", on: (r: any) => onWithdraw(r), disabledWhen: (r: any) => r.status !== 10 && r.status !== 50, danger: true },
|
||||
{ key: "urge", label: "催办", icon: "icon-fasong", on: (r: any) => onUrge(r), disabledWhen: (r: any) => r.status !== 10 },
|
||||
],
|
||||
{ mode: "text", width: 230, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize });
|
||||
const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize });
|
||||
const ccApi = (p: { pageIndex: number; pageSize: number }) => flowApi.ccMy({ page: p.pageIndex, size: p.pageSize });
|
||||
const todoApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.todo({ appKey: filters.todo || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
const doneApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.done({ appKey: filters.done || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
const ccApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.ccMy({ appKey: filters.cc || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
const myApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.my({ status: myParams.value.status || undefined, appKey: filters.my || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
|
||||
function onMySearch() {
|
||||
myTableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/** 应用下拉数据源:应用设置白名单(flw_app enabled=1),失败降级为空下拉 */
|
||||
async function loadApps() {
|
||||
try {
|
||||
const rows: any[] = (await flowApi.appList().send()) || [];
|
||||
apps.value = rows.filter((r) => r.enabled === 1)
|
||||
.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
|
||||
} catch {
|
||||
apps.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 筛选变更:回到第一页重查;未加载过的页签标记为已加载,避免切页签时重复请求 */
|
||||
function onFilterChange(name: TabKey) {
|
||||
loaded[name] = true;
|
||||
tableRefs[name].value?.refreshData();
|
||||
}
|
||||
|
||||
async function onWithdraw(row: any) {
|
||||
await MyConfirm({
|
||||
title: "撤回",
|
||||
msg: `确定撤回办件「${row.bizTitle}」吗?`,
|
||||
info: "撤回后流程终止,不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await flowApi.withdraw(row.instanceId, { silentBizToast: true }).send();
|
||||
Message.success("已撤回");
|
||||
myTableRef.value?.reloadData(OperatorEnum.edit);
|
||||
} catch (e: any) {
|
||||
Message.error(flowSubmitFailTip("撤回", e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D14 发起人重提:只对「退回待修改」(status=50)可用。
|
||||
* 文案把话说清——同一办件、同一申报轮次,重新提交后从第一级重新审批,历史记录保留;
|
||||
* 业务表单内容请在来源应用里改,FL 不代替业务系统编辑数据。
|
||||
*/
|
||||
async function onResubmit(row: any) {
|
||||
await MyConfirm({
|
||||
title: "重新提交",
|
||||
msg: `确定重新提交办件「${row.bizTitle}」吗?`,
|
||||
info: "仍是同一办件、同一申报轮次,历史审批记录保留,并从第一级审批重新开始。"
|
||||
+ "需要修改的业务内容请先在来源应用内改好再提交。",
|
||||
async onOk() {
|
||||
try {
|
||||
await flowApi.resubmit(row.instanceId, { silentBizToast: true }).send();
|
||||
Message.success("已重新提交,流程从第一级重新审批");
|
||||
myTableRef.value?.reloadData(OperatorEnum.edit);
|
||||
} catch (e: any) {
|
||||
Message.error(flowSubmitFailTip("重新提交", e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onUrge(row: any) {
|
||||
try {
|
||||
await flowApi.urge(row.instanceId).send();
|
||||
Message.success("已催办");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "催办失败");
|
||||
}
|
||||
}
|
||||
|
||||
function goHandle(row: any) {
|
||||
router.push(`/task/${row.taskId}?pid=100`);
|
||||
}
|
||||
|
||||
// 两个内容区 v-show 共存,已办/抄送首次切换才拉取(保持原惰性加载行为)
|
||||
const doneLoaded = ref(false);
|
||||
const ccLoaded = ref(false);
|
||||
function switchTab(name: string) {
|
||||
tab.value = name as any;
|
||||
if (name === "done" && !doneLoaded.value) {
|
||||
doneLoaded.value = true;
|
||||
doneTableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
if (name === "cc" && !ccLoaded.value) {
|
||||
ccLoaded.value = true;
|
||||
ccTableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
function openDetail(instanceId: string | number) {
|
||||
drawerInstanceId.value = instanceId;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
// 各内容区 v-show 共存,非首个页签首次切换才拉取(保持原惰性加载行为)
|
||||
const loaded = reactive<Record<TabKey, boolean>>({ todo: true, done: false, cc: false, my: false });
|
||||
function switchTab(name: string) {
|
||||
tab.value = name as TabKey;
|
||||
if (loaded[name as TabKey]) return;
|
||||
loaded[name as TabKey] = true;
|
||||
tableRefs[name as TabKey].value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
|
||||
onMounted(loadApps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs type="card" :value="tab" @on-click="switchTab">
|
||||
<div class="tabs-wrapper yh-fill">
|
||||
<div class="main-container yh-tabs-card">
|
||||
<Tabs class="tabs-panel" :value="tab" @on-click="switchTab">
|
||||
<TabPane label="我的待办" name="todo" />
|
||||
<TabPane label="我的已办" name="done" />
|
||||
<TabPane label="我提交的" name="my" />
|
||||
<TabPane label="抄送我的" name="cc" />
|
||||
</Tabs>
|
||||
|
||||
<div class="main-container" v-show="tab === 'todo'">
|
||||
<TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" :tableHeight="tableHeight" />
|
||||
<div class="tab-body" v-show="tab === 'todo'">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="filters.todo" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
|
||||
@on-change="onFilterChange('todo')">
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="main-container" v-show="tab === 'done'">
|
||||
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
||||
<TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" />
|
||||
</div>
|
||||
<div class="main-container" v-show="tab === 'cc'">
|
||||
<TablePageAlovaNew ref="ccTableRef" :dataApi="ccApi" :columns="ccColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
||||
<div class="tab-body" v-show="tab === 'done'">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="filters.done" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
|
||||
@on-change="onFilterChange('done')">
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :autoLoad="false" />
|
||||
</div>
|
||||
<div class="tab-body" v-show="tab === 'my'">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="myParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onMySearch">
|
||||
<Option :value="10" label="审批中" />
|
||||
<Option :value="20" label="已完成" />
|
||||
<Option :value="30" label="已拒绝" />
|
||||
<Option :value="40" label="已撤回" />
|
||||
<Option :value="50" label="退回待修改" />
|
||||
</Select>
|
||||
<Select v-model="filters.my" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
|
||||
@on-change="onFilterChange('my')">
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<TablePageAlovaNew ref="myTableRef" :dataApi="myApi" :columns="myColumns" :autoLoad="false" />
|
||||
</div>
|
||||
<div class="tab-body" v-show="tab === 'cc'">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="filters.cc" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
|
||||
@on-change="onFilterChange('cc')">
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<TablePageAlovaNew ref="ccTableRef" :dataApi="ccApi" :columns="ccColumns" :autoLoad="false" />
|
||||
</div>
|
||||
</div>
|
||||
<InstanceDrawer v-model="drawerVisible" :instance-id="drawerInstanceId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
<script setup lang="ts">
|
||||
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单 + 学校池同步 + 开关);
|
||||
// tab2 科室管理(flw_dept 组+成员模型,取代旧「办理人映射」页——deptKey=本地自增 id,
|
||||
// 切源后旧流程定义引用的 UserBase 组 id 需重建科室后重新配置)。
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message, Tag, Button } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const tab = ref("apps");
|
||||
const router = useRouter();
|
||||
|
||||
// ==================== tab1 应用管理 ====================
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
const searchKey = ref("");
|
||||
const regStats = ref<Record<string, number>>({});
|
||||
const appRegionRef = ref<HTMLElement | null>(null);
|
||||
const appH = useElementHeight(appRegionRef);
|
||||
|
||||
// 搜索过滤(应用名/appKey 子串,纯前端)
|
||||
const filteredRows = () => {
|
||||
const k = searchKey.value.trim().toLowerCase();
|
||||
if (!k) return rows.value;
|
||||
return rows.value.filter((r: any) =>
|
||||
String(r.appName || "").toLowerCase().includes(k) || String(r.appKey || "").includes(k));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "应用", key: "appName", minWidth: 200 },
|
||||
{ title: "appKey", key: "appKey", width: 120, align: "center" },
|
||||
{
|
||||
title: "回调注册数",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", String(regStats.value[String(p.row.appKey)] ?? 0)),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 90,
|
||||
align: "center",
|
||||
// 停用态走 iView 默认 Tag(不指定 color),避免自定义灰底与默认样式不一致
|
||||
render: (h: any, p: any) =>
|
||||
p.row.enabled === 1 ? h(Tag, { color: "green" }, () => "已开启") : h(Tag, () => "已停用"),
|
||||
},
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onToggle(r, false), disabledWhen: (r: any) => r.enabled !== 1, danger: true },
|
||||
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onToggle(r, true), disabledWhen: (r: any) => r.enabled === 1 },
|
||||
],
|
||||
{ mode: "text", width: 120 },
|
||||
),
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = ((await flowApi.appList().send()) as any[]) || [];
|
||||
try {
|
||||
regStats.value = ((await flowApi.appStats().send()) as any) || {};
|
||||
} catch (e) {
|
||||
regStats.value = {};
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(row: any, enabled: boolean) {
|
||||
const doToggle = async () => {
|
||||
row._switching = true;
|
||||
try {
|
||||
await flowApi.appEnable(row.id, enabled).send();
|
||||
row.enabled = enabled ? 1 : 0;
|
||||
Message.success(enabled ? `已开启「${row.appName}」` : `已停用「${row.appName}」`);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "操作失败");
|
||||
load();
|
||||
} finally {
|
||||
row._switching = false;
|
||||
}
|
||||
};
|
||||
if (enabled) {
|
||||
doToggle();
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "停用应用",
|
||||
msg: `确定停用「${row.appName}(${row.appKey})」吗?`,
|
||||
info: "停用后新流程定义不能再归属到该应用(编辑器归属下拉与 defSave 校验拦截);存量定义不受影响",
|
||||
async onOk() {
|
||||
await doToggle();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== tab2 科室管理 ====================
|
||||
const deptLoading = ref(false);
|
||||
const depts = ref<any[]>([]);
|
||||
const deptRegionRef = ref<HTMLElement | null>(null);
|
||||
const deptH = useElementHeight(deptRegionRef);
|
||||
const deptColumns = [
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
{ title: "科室名", key: "deptName", minWidth: 160 },
|
||||
{ title: "deptKey", key: "deptKey", width: 90, align: "center" },
|
||||
{
|
||||
title: "启用成员",
|
||||
key: "count",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => h("span", String(p.row.count)),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Tag, { color: p.row.status === 0 ? "green" : "gray" },
|
||||
() => (p.row.status === 0 ? "启用" : "停用")),
|
||||
},
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "members", label: "成员", icon: "icon-yonghu", on: (r: any) => onMembers(r) },
|
||||
{ key: "rename", label: "改名", icon: "icon-edit", on: (r: any) => onRename(r) },
|
||||
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onDeptStatus(r, false), disabledWhen: (r: any) => r.status !== 0, danger: true },
|
||||
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onDeptStatus(r, true), disabledWhen: (r: any) => r.status === 0 },
|
||||
{ key: "del", label: "删除", icon: "icon-shanchu", on: (r: any) => onDelete(r), danger: true },
|
||||
],
|
||||
{ mode: "text", width: 180 },
|
||||
),
|
||||
];
|
||||
|
||||
async function loadDepts() {
|
||||
deptLoading.value = true;
|
||||
try {
|
||||
depts.value = ((await flowApi.deptList().send()) as any[]) || [];
|
||||
} finally {
|
||||
deptLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 新建科室 ----
|
||||
const createVisible = ref(false);
|
||||
const createSaving = ref(false);
|
||||
const createName = ref("");
|
||||
|
||||
function onCreate() {
|
||||
createName.value = "";
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
if (!createName.value.trim()) {
|
||||
Message.warning("科室名称不能为空");
|
||||
return;
|
||||
}
|
||||
createSaving.value = true;
|
||||
try {
|
||||
await flowApi.deptCreate(createName.value.trim()).send();
|
||||
Message.success("已创建");
|
||||
createVisible.value = false;
|
||||
loadDepts();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "创建失败");
|
||||
} finally {
|
||||
createSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 改名 ----
|
||||
const renameVisible = ref(false);
|
||||
const renameSaving = ref(false);
|
||||
const renameRow = ref<any>(null);
|
||||
const renameName = ref("");
|
||||
|
||||
function onRename(row: any) {
|
||||
renameRow.value = row;
|
||||
renameName.value = row.deptName;
|
||||
renameVisible.value = true;
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!renameName.value.trim()) {
|
||||
Message.warning("科室名称不能为空");
|
||||
return;
|
||||
}
|
||||
renameSaving.value = true;
|
||||
try {
|
||||
await flowApi.deptRename(renameRow.value.deptKey, renameName.value.trim()).send();
|
||||
Message.success("已改名");
|
||||
renameVisible.value = false;
|
||||
loadDepts();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "改名失败");
|
||||
} finally {
|
||||
renameSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 停启用 / 删除 ----
|
||||
function onDeptStatus(row: any, enabled: boolean) {
|
||||
MyConfirm({
|
||||
title: enabled ? "启用科室" : "停用科室",
|
||||
msg: `确定${enabled ? "启用" : "停用"}「${row.deptName}」吗?`,
|
||||
info: enabled
|
||||
? "引用它的流程节点恢复解析到科室成员"
|
||||
: "停用后引用它的流程定义不能发布;在途办件推进到该节点会报无可用办理人",
|
||||
async onOk() {
|
||||
await flowApi.deptStatus(row.deptKey, enabled).send();
|
||||
Message.success("操作成功");
|
||||
loadDepts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete(row: any) {
|
||||
MyConfirm({
|
||||
title: "删除科室",
|
||||
msg: `确定删除「${row.deptName}」及其全部成员吗?`,
|
||||
info: "删除后引用该 deptKey 的流程定义节点将解析失败(发布校验拦截/办理报错),请先确认无流程引用",
|
||||
async onOk() {
|
||||
await flowApi.deptDelete(row.deptKey).send();
|
||||
Message.success("已删除");
|
||||
loadDepts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 成员维护 ----
|
||||
const memberVisible = ref(false);
|
||||
const memberRow = ref<any>(null);
|
||||
const members = ref<any[]>([]);
|
||||
const memberLoading = ref(false);
|
||||
|
||||
function onMembers(row: any) {
|
||||
memberRow.value = row;
|
||||
memberVisible.value = true;
|
||||
loadMembers(row);
|
||||
}
|
||||
|
||||
async function loadMembers(row: any) {
|
||||
memberLoading.value = true;
|
||||
try {
|
||||
members.value = ((await flowApi.deptMembers(row.deptKey).send()) as any[]) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "成员加载失败");
|
||||
members.value = [];
|
||||
} finally {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加成员:规范选人组件(多选),提交即批量加入当前科室
|
||||
const addMemberCheckRef = ref();
|
||||
const memberSources = [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
|
||||
},
|
||||
];
|
||||
|
||||
function onAddMemberShow() {
|
||||
addMemberCheckRef.value?.show();
|
||||
}
|
||||
|
||||
async function onMemberAddSubmit(users: any[]) {
|
||||
if (!users || !users.length) {
|
||||
Message.warning("请先选择用户");
|
||||
return;
|
||||
}
|
||||
const ids = users
|
||||
.map((u: any) => Number(typeof u === "object" && u != null ? u.id : u))
|
||||
.filter((n: number) => Number.isFinite(n) && n > 0);
|
||||
if (!ids.length) {
|
||||
Message.warning("请先选择用户");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const added: any = await flowApi.deptMemberAdd(memberRow.value.deptKey, ids).send();
|
||||
Message.success(added > 0 ? `已添加 ${added} 人` : "所选成员均已存在");
|
||||
loadMembers(memberRow.value);
|
||||
loadDepts();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "添加失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onMemberRemove(m: any) {
|
||||
MyConfirm({
|
||||
title: "移除成员",
|
||||
msg: `确定把「${m.userName || "#" + m.userId}」移出「${memberRow.value.deptName}」吗?`,
|
||||
info: "移除后引用该科室的流程节点解析时不再包含此人;在途待办不受影响",
|
||||
async onOk() {
|
||||
await flowApi.deptMemberRemove(m.id).send();
|
||||
loadMembers(memberRow.value);
|
||||
loadDepts();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 一键拉取教师组(初始化语义) ----
|
||||
const groupVisible = ref(false);
|
||||
const groupLoading = ref(false);
|
||||
const groupImporting = ref(false);
|
||||
const groupRows = ref<any[]>([]);
|
||||
const groupColumns = [
|
||||
{ title: "科室名称", key: "deptName", width: 160 },
|
||||
{ title: "成员数", width: 90, align: "center", render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) },
|
||||
{ title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") },
|
||||
];
|
||||
|
||||
async function onGroupPreview() {
|
||||
groupVisible.value = true;
|
||||
groupLoading.value = true;
|
||||
try {
|
||||
groupRows.value = (await flowApi.deptGroupPreview().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "教师组拉取失败(UserBase 不可达?)");
|
||||
groupVisible.value = false;
|
||||
} finally {
|
||||
groupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onGroupImport() {
|
||||
groupImporting.value = true;
|
||||
try {
|
||||
const r: any = await flowApi.deptImportGroups().send();
|
||||
Message.success(`导入完成:新建 ${r?.created ?? 0} / 刷新 ${r?.refreshed ?? 0} 个科室,新增成员 ${r?.users ?? 0} 人`);
|
||||
groupVisible.value = false;
|
||||
loadDepts();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "导入失败");
|
||||
} finally {
|
||||
groupImporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadDepts();
|
||||
});
|
||||
|
||||
// ==================== tab3 对接健康度(2026-10-15 议题二 P2) ====================
|
||||
// 每应用一行六项检查 + 综合评分;数据全部来自已有接口(/callback/health 聚合),零新增表。
|
||||
// 应用无注册行 = 未接入(由接入向导负责),不参与体检。
|
||||
const healthLoading = ref(false);
|
||||
const healthRows = ref<any[]>([]);
|
||||
|
||||
const healthColumns = [
|
||||
{
|
||||
title: "应用",
|
||||
key: "appName",
|
||||
minWidth: 180,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "hc-app" }, [
|
||||
h("span", { class: "hc-app-n" }, p.row.appName),
|
||||
h("span", { class: "hc-app-k" }, p.row.appKey),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "评分",
|
||||
width: 96,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("span", { class: ["hc-score", "is-" + p.row.level] }, String(p.row.score)),
|
||||
},
|
||||
{
|
||||
title: "结论",
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) => {
|
||||
const map: Record<string, [string, string]> = {
|
||||
ok: ["正常", "green"],
|
||||
warn: ["关注", "orange"],
|
||||
bad: ["异常", "red"],
|
||||
};
|
||||
const [txt, color] = map[p.row.level] || ["未知", "default"];
|
||||
return h(Tag, { color }, () => txt);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "检查项",
|
||||
minWidth: 460,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "hc-checks" }, (p.row.checks || []).map((c: any) =>
|
||||
h("span", {
|
||||
class: ["hc-chip", "is-" + c.level],
|
||||
attrs: { title: (c.advice ? c.advice + "\n" : "") + "实测:" + (c.value || "-") },
|
||||
}, [
|
||||
h("i", { class: ["hc-dot", "is-" + c.level] }),
|
||||
h("span", { class: "hc-chip-n" }, c.name),
|
||||
c.value ? h("em", { class: "hc-chip-v" }, c.value) : null,
|
||||
]))),
|
||||
},
|
||||
{
|
||||
title: "结论说明",
|
||||
key: "summary",
|
||||
minWidth: 220,
|
||||
render: (h: any, p: any) => h("span", { class: "hc-sum" }, p.row.summary || "-"),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h(Button, {
|
||||
type: "text",
|
||||
size: "small",
|
||||
onClick: () => onViewLogs(p.row),
|
||||
}, () => "查推送日志"),
|
||||
},
|
||||
];
|
||||
|
||||
function onViewLogs(row: any) {
|
||||
router.push({ path: "/admin/callbacks", query: { appKey: row.appKey } });
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
healthLoading.value = true;
|
||||
try {
|
||||
healthRows.value = ((await (flowApi as any).callbackHealth().send()) as any[]) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "健康度加载失败");
|
||||
healthRows.value = [];
|
||||
} finally {
|
||||
healthLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 首次切到「对接健康度」时按需加载(避免进页面就多打一次聚合查询)
|
||||
let healthLoaded = false;
|
||||
watch(tab, (t) => {
|
||||
if (t === "health" && !healthLoaded) {
|
||||
healthLoaded = true;
|
||||
loadHealth();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container yh-tabs-card">
|
||||
<Tabs class="tabs-panel" v-model="tab">
|
||||
<TabPane label="应用管理" name="apps"></TabPane>
|
||||
<TabPane label="科室管理(旧机制)" name="depts"></TabPane>
|
||||
<TabPane label="对接健康度" name="health"></TabPane>
|
||||
</Tabs>
|
||||
|
||||
<!-- 应用管理 -->
|
||||
<div class="tab-body" v-show="tab === 'apps'">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="load">刷新(同步学校池)</Button>
|
||||
<Input class="fl" v-model="searchKey" placeholder="搜索应用名 / appKey" clearable
|
||||
style="width: 220px; margin-left: 10px" suffix="ios-search" />
|
||||
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
|
||||
应用清单来自本校已开通应用(UserBase 学校组开通,含学校别名);开启后可用于流程归属与筛选。
|
||||
新开通/改名在 UserBase 管理端操作后回本页刷新即可
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-region" ref="appRegionRef">
|
||||
<Table :columns="columns" :data="filteredRows()" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="appH || undefined" no-data-text="本校暂无已开通应用(请先在用户中心开通)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 科室管理:迁移完成前暂留,仅供历史 DEPT_KEY 定义继续解析与清理,不是新配置的权威来源 -->
|
||||
<div class="tab-body" v-show="tab === 'depts'">
|
||||
<Alert type="warning" show-icon class="mb-12">
|
||||
本页是<b>旧机制</b>:FL 本地科室表只继续解析<b>已发布定义里遗留的「按科室」节点</b>。
|
||||
新建/编辑流程的部门主体一律走 UserBase 部门目录(流程编辑器「部门」选项),
|
||||
在这里新建科室<b>不会</b>让新流程可选,也不会改变部门主体的候选人。
|
||||
</Alert>
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onCreate">新建科室</Button>
|
||||
<Button class="fl" size="large" style="margin-left: 8px" @click="onGroupPreview">一键拉取教师组</Button>
|
||||
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
|
||||
仅供遗留「按科室」节点解析;重复拉取=刷新组名与成员集(不重复建组)
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-region" ref="deptRegionRef">
|
||||
<Table :columns="deptColumns" :data="depts" :loading="deptLoading" class="link-table table-bottom-box"
|
||||
:height="deptH || undefined" no-data-text="暂无科室(可一键拉取教师组或手工新建)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对接健康度(2026-10-15 P2):每应用一行六项检查,问题应用置顶 -->
|
||||
<div class="tab-body" v-show="tab === 'health'">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="loadHealth">重新体检</Button>
|
||||
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
|
||||
一屏定位「为什么收不到回调」:登记地址形态、base_path、推送成功率、挂起/死信、注册启用态。
|
||||
数据来自回调注册与推送日志,无注册行的应用视为「未接入」,请走接入向导
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-region">
|
||||
<Table :columns="healthColumns" :data="healthRows" :loading="healthLoading"
|
||||
class="link-table table-bottom-box" no-data-text="暂无可体检应用(尚无任何回调注册,请先走接入向导)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-model="createVisible" title="新建科室" :mask-closable="false">
|
||||
<Input v-model="createName" placeholder="科室名称,如 人事处" :maxlength="30" />
|
||||
<template #footer>
|
||||
<Button @click="createVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="createSaving" @click="doCreate">创建</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="renameVisible" title="科室改名" :mask-closable="false">
|
||||
<Input v-model="renameName" placeholder="科室名称" :maxlength="30" />
|
||||
<template #footer>
|
||||
<Button @click="renameVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="renameSaving" @click="doRename">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="memberVisible" :title="`成员维护 - ${memberRow?.deptName || ''}`" :mask-closable="false" width="640">
|
||||
<div class="fix" style="margin-bottom: 10px">
|
||||
<Button class="fl" type="primary" icon="md-add" @click="onAddMemberShow">添加成员</Button>
|
||||
</div>
|
||||
<Table :columns="[
|
||||
{ title: '用户ID', key: 'userId', width: 100 },
|
||||
{ title: '姓名', key: 'userName' },
|
||||
actionColumn([{ key: 'remove', label: '移除', icon: 'icon-shanchu', on: (r: any) => onMemberRemove(r), danger: true }], { mode: 'text', width: 90 }),
|
||||
]" :data="members" :loading="memberLoading" size="small" max-height="360" no-data-text="暂无成员" />
|
||||
<template #footer>
|
||||
<Button @click="memberVisible = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="groupVisible" title="一键拉取教师组(预览)" :mask-closable="false" width="720">
|
||||
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 8px">
|
||||
以下为本校教师组容器下的科室及组内成员;确认后导入。已拉取过的科室(同名 UserBase 组)将刷新组名与成员集,不会重复建组
|
||||
</p>
|
||||
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" size="small" max-height="380"
|
||||
no-data-text="未发现可拉取的教师组科室" />
|
||||
<template #footer>
|
||||
<Button @click="groupVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">导入</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 添加成员选人弹窗(多选,与 Notice 设置中心同款组件) -->
|
||||
<UserCheckModal ref="addMemberCheckRef" :sources="memberSources" @on-submit="onMemberAddSubmit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 对接健康度面板:一屏一行,检查项紧凑成 chip */
|
||||
.hc-app { display: flex; flex-direction: column; line-height: 1.35; }
|
||||
.hc-app-n { color: #17233d; font-weight: 500; }
|
||||
.hc-app-k { color: #98a1b3; font-size: 12px; }
|
||||
|
||||
.hc-score { display: inline-block; min-width: 34px; padding: 1px 6px; border-radius: 4px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.hc-score.is-ok { background: #e8f7ee; color: #19a35a; }
|
||||
.hc-score.is-warn { background: #fff5e6; color: #d98106; }
|
||||
.hc-score.is-bad { background: #fdecec; color: #e03e3e; }
|
||||
|
||||
.hc-checks { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0; }
|
||||
.hc-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 1px 7px; border-radius: 3px; font-size: 12px; line-height: 18px; background: #f4f6f9; color: #515a6e; }
|
||||
.hc-chip.is-warn { background: #fff5e6; color: #b26a00; }
|
||||
.hc-chip.is-bad { background: #fdecec; color: #c0392b; }
|
||||
.hc-dot { width: 6px; height: 6px; border-radius: 50%; background: #19a35a; flex: none; }
|
||||
.hc-dot.is-warn { background: #f0a020; }
|
||||
.hc-dot.is-bad { background: #e03e3e; }
|
||||
.hc-chip-n { flex: none; }
|
||||
.hc-chip-v { font-style: normal; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.85; }
|
||||
|
||||
.hc-sum { color: #515a6e; font-size: 12px; }
|
||||
</style>
|
||||
@@ -2,37 +2,53 @@
|
||||
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
|
||||
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const LOG_STATUS: Record<number, string> = { 0: "待推/重试中", 1: "成功", 2: "死信" };
|
||||
const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled", "flow.completed", "flow.rejected", "flow.withdrawn"];
|
||||
|
||||
const tab = ref("regs");
|
||||
const loading = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
// tabs 页高度:在常规骨架上再扣 Tab 条;日志页含搜索区+分页,挂起页含提示条
|
||||
const regsTableHeight = ref(Utils.useTableHeight({ isTabs: true }));
|
||||
const logTableHeight = ref(Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
|
||||
const hungTableHeight = ref(Utils.useTableHeight({ isTabs: true, otherH: 46 }));
|
||||
// 无分页 tab 的表格高度:实测各自 .table-region(flex:1),替代 useTableHeight 常量减法
|
||||
const regsRegionRef = ref<HTMLElement | null>(null);
|
||||
const hungRegionRef = ref<HTMLElement | null>(null);
|
||||
const regsH = useElementHeight(regsRegionRef);
|
||||
const hungH = useElementHeight(hungRegionRef);
|
||||
|
||||
// ---- 注册列表 ----
|
||||
const regs = ref<any[]>([]);
|
||||
// appKey 筛选(2026-10-15):健康度面板「查推送日志」跳转带入 query.appKey 预填
|
||||
const regFilter = ref("");
|
||||
const filteredRegs = () => {
|
||||
const k = regFilter.value.trim().toLowerCase();
|
||||
if (!k) return regs.value;
|
||||
return regs.value.filter((r: any) => String(r.appKey || "").toLowerCase().includes(k));
|
||||
};
|
||||
const regColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "应用 appKey", key: "appKey", width: 110 },
|
||||
{ title: "流程 GUID", key: "flowGuid", width: 210 },
|
||||
{ title: "回调地址", key: "callbackUrl", minWidth: 240 },
|
||||
{ title: "订阅事件", key: "eventTypes", width: 200, render: (h: any, p: any) => h("span", p.row.eventTypes || "全部") },
|
||||
{ title: "状态", width: 80, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
|
||||
{ title: "操作", width: 140, align: "center", fixed: "right", render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, p.row.status === 0 ? "停用" : "启用", p.row.status === 0 ? "icon-stop" : "icon-shiyong", () => onRegStatus(p.row, p.row.status === 0)),
|
||||
Utils.operatingHandle(h, "重置secret", "icon-shezhi", () => onResetSecret(p.row)),
|
||||
] },
|
||||
{ title: "状态", width: 80, align: "center", render: (h: any, p: any) => h(Tag, { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "probe", label: "探测", icon: "icon-fasong", on: (r: any) => onProbe(r) },
|
||||
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onRegStatus(r, true), disabledWhen: (r: any) => r.status !== 0, danger: true },
|
||||
{ key: "enable", label: "启用", icon: "icon-shiyong", on: (r: any) => onRegStatus(r, false), disabledWhen: (r: any) => r.status === 0 },
|
||||
{ key: "resetSecret", label: "重置secret", icon: "icon-shezhi", on: (r: any) => onResetSecret(r) },
|
||||
],
|
||||
{ mode: "text", width: 240, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
// ---- 推送日志 ----
|
||||
@@ -46,13 +62,32 @@ const logColumns = [
|
||||
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
|
||||
{ title: "状态", width: 110, align: "center", render: (h: any, p: any) => {
|
||||
const c = p.row.status === 1 ? "green" : p.row.status === 2 ? "red" : "orange";
|
||||
return h("Tag", { color: c }, () => LOG_STATUS[p.row.status]);
|
||||
return h(Tag, { color: c }, () => LOG_STATUS[p.row.status]);
|
||||
} },
|
||||
{ title: "重试", key: "retryCount", width: 60, align: "center" },
|
||||
{ title: "末次结果", key: "lastError", minWidth: 200, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
|
||||
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
|
||||
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openLogDetail(r) }], { mode: "text", width: 100, fixed: "right" }),
|
||||
];
|
||||
|
||||
// ---- 推送日志详情抽屉(2026-10-11:完整请求体/响应摘要/重试链可视) ----
|
||||
const logDetail = ref(false);
|
||||
const logDetailRow = ref<any>(null);
|
||||
|
||||
function openLogDetail(row: any) {
|
||||
logDetailRow.value = row;
|
||||
logDetail.value = true;
|
||||
}
|
||||
|
||||
function prettyJson(raw: any): string {
|
||||
if (raw === null || raw === undefined || raw === "") return "-";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch (e) {
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 挂起列表(持续重试中:待推且重试达阈值;人工判定无意义 → 标记死信止血) ----
|
||||
const hungs = ref<any[]>([]);
|
||||
const hungLoading = ref(false);
|
||||
@@ -61,11 +96,10 @@ const hungColumns = [
|
||||
{ title: "事件", key: "event", width: 150 },
|
||||
{ title: "办件", key: "instanceId", width: 80 },
|
||||
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
|
||||
{ title: "重试", key: "retryCount", width: 70, align: "center", render: (h: any, p: any) => h("Tag", { color: "orange" }, () => `${p.row.retryCount} 次`) },
|
||||
{ title: "重试", key: "retryCount", width: 70, align: "center", render: (h: any, p: any) => h(Tag, { color: "orange" }, () => `${p.row.retryCount} 次`) },
|
||||
{ title: "末次结果", key: "lastError", minWidth: 220, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
|
||||
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
|
||||
{ title: "操作", width: 120, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "标记死信", "icon-ashbin", () => onMarkDead(p.row)) },
|
||||
actionColumn([{ key: "markDead", label: "标记死信", icon: "icon-ashbin", on: (r: any) => onMarkDead(r), danger: true }], { mode: "text", width: 120, fixed: "right" }),
|
||||
];
|
||||
|
||||
async function loadRegs() {
|
||||
@@ -128,6 +162,22 @@ function onResetSecret(row: any) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 回调探测(2026-10-11):签一条 flow.ping 走真实管道,结果弹窗展示并落推送日志 ----
|
||||
const probeResult = ref<any>(null);
|
||||
const probeShowing = ref(false);
|
||||
const probingId = ref<number | null>(null);
|
||||
|
||||
async function onProbe(row: any) {
|
||||
probingId.value = row.id;
|
||||
try {
|
||||
const d: any = await flowApi.callbackProbe(row.id).send();
|
||||
probeResult.value = { ...d, appKey: row.appKey, callbackUrl: row.callbackUrl };
|
||||
probeShowing.value = true;
|
||||
} finally {
|
||||
probingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onMarkDead(row: any) {
|
||||
MyConfirm({
|
||||
title: "标记死信",
|
||||
@@ -167,25 +217,43 @@ function switchTab(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRegs);
|
||||
onMounted(() => {
|
||||
// 从健康度面板跳转时带 appKey,预填注册列表筛选并切到「注册列表」tab
|
||||
const q = route.query.appKey;
|
||||
if (q) {
|
||||
regFilter.value = String(q);
|
||||
tab.value = "regs";
|
||||
}
|
||||
loadRegs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs type="card" :value="tab" @on-click="switchTab">
|
||||
<div class="tabs-wrapper yh-fill">
|
||||
<div class="main-container yh-tabs-card">
|
||||
<Tabs class="tabs-panel" :value="tab" @on-click="switchTab">
|
||||
<TabPane label="回调注册" name="regs" />
|
||||
<TabPane label="推送日志" name="logs" />
|
||||
<TabPane label="挂起中" name="hung" />
|
||||
</Tabs>
|
||||
|
||||
<!-- 回调注册:无分页 -->
|
||||
<div class="main-container" v-show="tab === 'regs'">
|
||||
<Table :columns="regColumns" :data="regs" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="regsTableHeight" no-data-text="暂无数据" />
|
||||
<div class="tab-body" v-show="tab === 'regs'">
|
||||
<div class="fix mb-12">
|
||||
<Input class="fl" v-model="regFilter" placeholder="按 appKey 筛选" clearable
|
||||
style="width: 220px" suffix="ios-search" />
|
||||
<span class="fl" style="color: #808695; font-size: 12px; line-height: 32px; margin-left: 10px">
|
||||
每行一个 (应用 × 流程) 登记;「探测」同步发一条 flow.ping 验证可达与验签
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-region" ref="regsRegionRef">
|
||||
<Table :columns="regColumns" :data="filteredRegs()" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="regsH || undefined" no-data-text="暂无数据" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推送日志:搜索区 + 分页,重推选中落分页栏左侧批量操作位 -->
|
||||
<div class="main-container" v-show="tab === 'logs'">
|
||||
<div class="tab-body" v-show="tab === 'logs'">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="logFilter.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onLogSearch">
|
||||
<Option :value="0" label="待推/重试中" />
|
||||
@@ -198,8 +266,7 @@ onMounted(loadRegs);
|
||||
<Input class="fr" v-model="logFilter.instanceId" placeholder="办件ID" search suffix="ios-search"
|
||||
style="width: 200px" @on-search="onLogSearch" @on-clear="onLogSearch" />
|
||||
</div>
|
||||
<TablePageAlovaNew ref="logTableRef" :dataApi="logApi" :columns="logColumns"
|
||||
:tableHeight="logTableHeight" :autoLoad="false">
|
||||
<TablePageAlovaNew ref="logTableRef" :dataApi="logApi" :columns="logColumns" :autoLoad="false">
|
||||
<template #default="{ checkList }">
|
||||
<Button size="small" type="primary" :disabled="!checkList.length" @click="onRepush(checkList)">重推选中</Button>
|
||||
</template>
|
||||
@@ -207,16 +274,59 @@ onMounted(loadRegs);
|
||||
</div>
|
||||
|
||||
<!-- 挂起中:无分页 -->
|
||||
<div class="main-container" v-show="tab === 'hung'">
|
||||
<div class="tab-body" v-show="tab === 'hung'">
|
||||
<div class="tip">
|
||||
持续重试中的事件(消费方疑似停机;消费方恢复后自动追平,无需干预)。仅当人工判定事件已无意义时才标记死信。
|
||||
</div>
|
||||
<div class="table-region" ref="hungRegionRef">
|
||||
<Table :columns="hungColumns" :data="hungs" :loading="hungLoading" class="link-table table-bottom-box"
|
||||
:height="hungTableHeight" no-data-text="暂无数据" />
|
||||
:height="hungH || undefined" no-data-text="暂无数据" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 回调探测结果 -->
|
||||
<Modal v-model="probeShowing" :title="`回调探测${probeResult?.success ? '成功' : '失败'}(${probeResult?.appKey})`"
|
||||
:width="640" :footer-hide="true">
|
||||
<div v-if="probeResult" class="probe-result">
|
||||
<Alert :type="probeResult.success ? 'success' : 'error'" show-icon>
|
||||
{{ probeResult.success ? "接收方可达且验签通过(HTTP 2xx)" : "探测失败:接收方不可达或验签被拒" }}
|
||||
</Alert>
|
||||
<div class="kv"><span class="k">回调地址</span><span class="v">{{ probeResult.callbackUrl }}</span></div>
|
||||
<div class="kv"><span class="k">HTTP 状态</span><span class="v">{{ probeResult.httpStatus ?? "-" }}</span></div>
|
||||
<div class="kv"><span class="k">耗时</span><span class="v">{{ probeResult.elapsedMs }} ms</span></div>
|
||||
<div class="kv" v-if="probeResult.error"><span class="k">错误</span><span class="v text-error">{{ probeResult.error }}</span></div>
|
||||
<div class="kv" v-if="probeResult.respSnippet">
|
||||
<span class="k">响应摘要</span>
|
||||
<pre class="v pre">{{ probeResult.respSnippet }}</pre>
|
||||
</div>
|
||||
<div class="tip" style="margin-top:10px">探测事件 flow.ping 已落「推送日志」(探测结果以接收方按规范记日志后 200 为准)</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 推送日志详情抽屉 -->
|
||||
<Drawer :title="`推送详情:${logDetailRow?.event || ''}`" :value="logDetail" :width="560" class="log-detail-drawer">
|
||||
<div v-if="logDetailRow" class="log-detail">
|
||||
<div class="kv"><span class="k">事件ID</span><span class="v">{{ logDetailRow.eventId }}</span></div>
|
||||
<div class="kv"><span class="k">办件 / biz</span><span class="v">{{ logDetailRow.instanceId }} / {{ logDetailRow.bizType }}/{{ logDetailRow.bizId }}</span></div>
|
||||
<div class="kv"><span class="k">标题</span><span class="v">{{ logDetailRow.bizTitle || "-" }}</span></div>
|
||||
<div class="kv"><span class="k">状态</span><span class="v">{{ LOG_STATUS[logDetailRow.status] }}</span></div>
|
||||
<div class="kv"><span class="k">重试次数</span><span class="v">{{ logDetailRow.retryCount }}</span></div>
|
||||
<div class="kv"><span class="k">末次 HTTP</span><span class="v">{{ logDetailRow.lastHttpStatus || "-" }}</span></div>
|
||||
<div class="kv"><span class="k">末次错误</span><span class="v">{{ logDetailRow.lastError || "-" }}</span></div>
|
||||
<div class="kv"><span class="k">推送时间</span><span class="v">{{ (logDetailRow.pushTime || "-").replace("T", " ") }}</span></div>
|
||||
<div class="kv"><span class="k">下次重推</span><span class="v">{{ (logDetailRow.nextRetryTime || "-").replace("T", " ") }}</span></div>
|
||||
<div class="kv"><span class="k">响应摘要</span><pre class="v pre">{{ logDetailRow.respBody || "-" }}</pre></div>
|
||||
<div class="kv"><span class="k">请求体(完整快照)</span><pre class="v pre">{{ prettyJson(logDetailRow.payloadJson) }}</pre></div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tip { margin-bottom: 10px; padding: 6px 10px; background: #f8f8f9; border-radius: 4px; color: #515a6e; font-size: 12px; }
|
||||
.probe-result .kv, .log-detail .kv { margin-bottom: 8px; font-size: 13px; }
|
||||
.probe-result .k, .log-detail .k { display: inline-block; width: 120px; color: #808695; vertical-align: top; }
|
||||
.probe-result .v, .log-detail .v { color: #515a6e; word-break: break-all; white-space: pre-wrap; }
|
||||
.pre { margin: 0; padding: 8px; background: #f8f8f9; border-radius: 4px; max-height: 320px; overflow: auto; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
+533
-45
@@ -2,78 +2,332 @@
|
||||
// 流程定义管理:列表 + 发布/启用/停用/升版本/版本历史/图示/源码。
|
||||
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程编辑器方案.md》);
|
||||
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Message, Table, Tag, Tooltip } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { lineActionErrorDetail, runLineAction } from "@/utils/lineOffline";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const router = useRouter();
|
||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
// 线级启停在途标记(按 flowGuid):确认后的请求期间禁用入口并展示「处理中」
|
||||
const lineBusyGuid = ref("");
|
||||
const versionVisible = ref(false);
|
||||
const versions = ref<any[]>([]);
|
||||
// 源码抽屉(列表行已带回 contentJson,无需再请求)
|
||||
const sourceVisible = ref(false);
|
||||
const sourceText = ref("");
|
||||
const sourceTitle = ref("");
|
||||
// 操作按钮区 + 表格,无搜索、无分页
|
||||
const tableHeight = ref(Utils.useTableHeight({ isOperat: true }));
|
||||
// 按应用筛选(flw_def.app_key,2026-10-10):下拉数据源来自 /def/appkeys
|
||||
const appKeys = ref<string[]>([]);
|
||||
// 操作按钮区 + 表格,无搜索、无分页:表格高度实测 .table-region(flex:1)
|
||||
const defsRegionRef = ref<HTMLElement | null>(null);
|
||||
const defsH = useElementHeight(defsRegionRef);
|
||||
// 按应用筛选(flw_def.app_key,2026-10-10):下拉数据源来自应用设置白名单(flw_app enabled=1)
|
||||
const apps = ref<{ appKey: string; appName: string }[]>([]);
|
||||
const appMap = ref<Record<string, string>>({});
|
||||
const filterApp = ref("");
|
||||
// 议题二②:列表视图(group=同 flowGuid 折叠一行,默认;flat=现状全版本平铺)——纯前端切换,defList 已全量返回
|
||||
const viewMode = ref<"group" | "flat">("group");
|
||||
// 分组展开态(key=flowGuid):Table 数据由 computed 派生,展开态必须存外面才不会丢
|
||||
const openMap = ref<Record<string, boolean>>({});
|
||||
// 议题二④:flowGuid → 在途办件数;拉取失败降级空 map(行内显示「—」,列表不崩)
|
||||
const inflightMap = ref<Record<string, number>>({});
|
||||
// E6:flowGuid → 生效授权应用数(使用许可摘要,与「应用」列的归属口径无关);缺键=0 是后端真实计数
|
||||
const grantMap = ref<Record<string, number>>({});
|
||||
const grantSummaryFailed = ref(false);
|
||||
// 摘要接口只在本页实例内提示一次:load() 会被筛选/各动作反复触发,不能每次都弹
|
||||
const grantSummaryWarned = ref(false);
|
||||
|
||||
/** 在途展示:数字=件数(0 也展示);undefined=接口无该线数据 → 「—」 */
|
||||
function inflightText(guid: string): string {
|
||||
const n = inflightMap.value[guid];
|
||||
return n == null ? "在途 —" : `在途 ${n} 件`;
|
||||
}
|
||||
|
||||
/** 创建时间格式化 YYYY-MM-DD HH:mm(后端 LocalDateTime 带 T,空值显示 -) */
|
||||
function fmtTime(raw: any): string {
|
||||
return raw ? String(raw).replace("T", " ").slice(0, 16) : "-";
|
||||
}
|
||||
|
||||
/** 应用列(未归属标记 2026-10-12 appKey 必填化):平铺/分组/展开行共用,口径不漂移 */
|
||||
const renderApp = (h: any, p: any) => {
|
||||
if (!p.row.appKey) {
|
||||
return h(Tag, { color: "warning" }, () => "未归属");
|
||||
}
|
||||
return h("span", appMap.value[p.row.appKey] || p.row.appKey);
|
||||
};
|
||||
|
||||
/** 状态列(草稿橙/已发布绿/停用灰):三态共用 */
|
||||
const renderStatus = (h: any, p: any) => {
|
||||
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
|
||||
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
|
||||
};
|
||||
|
||||
/** 流程线级停发状态(T03):与版本 status 正交,必须独立展示,不能混读成“发布/停用” */
|
||||
const renderLineState = (h: any, p: any) => {
|
||||
if (!p.row.flowGuid) {
|
||||
return h("span", { style: "color:#999" }, "—");
|
||||
}
|
||||
const offline = Number(p.row.lineOffline) === 1;
|
||||
const tag = h(Tooltip, {
|
||||
content: offline
|
||||
? "流程线已停发:新发起/选人预览均被拒绝,在途办件按原版本跑完;与版本发布状态相互独立"
|
||||
: "流程线正常:新发起可用;与版本发布状态相互独立",
|
||||
transfer: true,
|
||||
maxWidth: 360,
|
||||
}, () => h(Tag, { color: offline ? "red" : "default" }, () => (offline ? "已停发" : "正常")));
|
||||
return isLineBusy(p.row)
|
||||
? h("span", {}, [tag, h(Tag, { color: "blue", style: "margin-left:6px" }, () => "处理中…")])
|
||||
: tag;
|
||||
};
|
||||
|
||||
function isLineBusy(row: any): boolean {
|
||||
const guid = String(row?.flowGuid || "").trim();
|
||||
return !!guid && lineBusyGuid.value === guid;
|
||||
}
|
||||
|
||||
/** 生效/在途 徽标(2026-10-14 从「流程」列拆出独立成列,口径不变:计数仍按流程线) */
|
||||
const renderRunState = (h: any, p: any) => {
|
||||
const n = inflightMap.value[p.row.flowGuid];
|
||||
return h("span", {}, [
|
||||
h(Tag, { color: p.row.hasPublished ? "green" : "default" },
|
||||
() => (p.row.hasPublished ? `当前生效 v${p.row.version}` : "无生效版")),
|
||||
h(Tag, { color: n ? "blue" : "default", style: "margin-left:12px" }, () => inflightText(p.row.flowGuid)),
|
||||
]);
|
||||
};
|
||||
|
||||
/** 授权摘要列(E6):生效授权应用数。接口失败整列显示 -,不把"没读到"报成 0 个许可 */
|
||||
const renderGrantSummary = (h: any, p: any) => {
|
||||
if (!p.row.flowGuid) {
|
||||
return h("span", { style: "color:#999" }, "—");
|
||||
}
|
||||
if (grantSummaryFailed.value) {
|
||||
return h("span", { style: "color:#999" }, "-");
|
||||
}
|
||||
return h("span", String(Number(grantMap.value[p.row.flowGuid]) || 0));
|
||||
};
|
||||
|
||||
/** 固定宽文本列 + 省略号 + Tooltip:列上写了 render 时 iView 内置 column.tooltip 不生效(cell 只在
|
||||
* renderType==='normal' 分支渲染 tooltip),故手挂 Tooltip;ellipsis 仍走 column 配置。 */
|
||||
const ellipsisCell = (key: string, tag = "span") => (h: any, p: any) => h(Tooltip, {
|
||||
content: p.row[key] ?? "", transfer: true, maxWidth: 480,
|
||||
}, () => h(tag, p.row[key] ?? ""));
|
||||
|
||||
/** 版本级操作集(平铺态、分组展开行同一套;禁用规则按 status,勿与线级启停混用) */
|
||||
const versionActions = [
|
||||
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r), disabledWhen: (r: any) => r.status !== 0 },
|
||||
{ key: "publish", label: "发布", icon: "icon-fasong", on: (r: any) => onPublish(r), disabledWhen: (r: any) => r.status !== 0 },
|
||||
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
|
||||
// 停用:破坏性动作(已发布→停用),danger 沉底并进「更多」
|
||||
{ key: "offline", label: "停用", icon: "icon-stop", on: (r: any) => onOffline(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
|
||||
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
|
||||
// 草稿不走这里,草稿用「发布」)
|
||||
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status !== 2 },
|
||||
// 元数据原地编辑(2026-10-10 议题2):改名称/归属应用不升版本(含已发布/停用)
|
||||
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
|
||||
{ key: "diagram", label: "图示", icon: "icon-tupian", on: (r: any) => onDiagram(r) },
|
||||
{ key: "source", label: "源码", icon: "icon-liebiao", on: (r: any) => onSource(r) },
|
||||
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
|
||||
];
|
||||
|
||||
/** 流程线级操作(T03):按 flowGuid 批量停发/恢复,只在有流程线标识时出现 */
|
||||
const lineActions = [
|
||||
{ key: "lineOffline", label: "停发流程线", icon: "icon-stop", on: (r: any) => onLineOffline(r), when: (r: any) => !!r.flowGuid && Number(r.lineOffline) !== 1, disabledWhen: (r: any) => isLineBusy(r), danger: true },
|
||||
{ key: "lineEnable", label: "恢复流程线", icon: "icon-play", on: (r: any) => onLineEnable(r), when: (r: any) => !!r.flowGuid && Number(r.lineOffline) === 1, disabledWhen: (r: any) => isLineBusy(r) },
|
||||
];
|
||||
|
||||
/** 使用许可入口(E6):授权是流程线级、且与本页的归属/发布状态无关,故只跳转不就地改 */
|
||||
const grantAction = {
|
||||
key: "usageGrants", label: "使用许可", icon: "icon-shezhi", on: (r: any) => onViewGrants(r),
|
||||
when: (r: any) => !!r.flowGuid,
|
||||
};
|
||||
|
||||
/** 平铺态(每行=一个版本)同时提供线级入口;展开的版本子表仍只用版本级操作,避免误操作整线 */
|
||||
const flatActions = [...versionActions, ...lineActions, grantAction];
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "流程", key: "name", minWidth: 180 },
|
||||
{ title: "编码", key: "code", width: 160 },
|
||||
{ title: "应用", key: "appKey", width: 110, align: "center", render: (h: any, p: any) => h("span", p.row.appKey || "-") },
|
||||
{ title: "编码", key: "code", width: 200 },
|
||||
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
||||
{ title: "授权摘要", width: 90, align: "center", render: renderGrantSummary },
|
||||
{ title: "版本", key: "version", width: 70, align: "center" },
|
||||
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => {
|
||||
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
|
||||
return h("Tag", { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
|
||||
} },
|
||||
{ title: "GUID", key: "flowGuid", width: 200 },
|
||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", (p.row.publishTime || "").replace("T", " ") || "-") },
|
||||
{ title: "操作", width: 250, align: "center", fixed: "right", render: (h: any, p: any) => {
|
||||
const btns: any[] = [];
|
||||
if (p.row.status === 0) {
|
||||
btns.push(Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => onPublish(p.row)));
|
||||
} else if (p.row.status === 1) {
|
||||
btns.push(Utils.operatingHandle(h, "升版本", "icon-tianjia", () => onNewVersion(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "停用", "icon-stop", () => onOffline(p.row)));
|
||||
} else if (p.row.status === 2) {
|
||||
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
|
||||
// 草稿不走这里,草稿用「发布」)
|
||||
btns.push(Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)));
|
||||
}
|
||||
btns.push(Utils.operatingHandle(h, "图示", "icon-tupian", () => onDiagram(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "源码", "icon-liebiao", () => onSource(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "版本历史", "icon-shijian", () => onVersions(p.row)));
|
||||
return btns;
|
||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||
{ title: "GUID", key: "flowGuid", width: 320, render: (h: any, p: any) => {
|
||||
// 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁;列宽同分组态,保证 32 位 guid 单行全显
|
||||
const n = inflightMap.value[p.row.flowGuid];
|
||||
return h("span", {}, [
|
||||
p.row.flowGuid,
|
||||
h(Tag, { color: n ? "blue" : "default", style: "margin-left:6px" }, () => inflightText(p.row.flowGuid)),
|
||||
]);
|
||||
} },
|
||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
||||
actionColumn(flatActions, { mode: "text", width: 180, fixed: "right" }),
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || [];
|
||||
loadInflight();
|
||||
loadGrantSummary();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用清单(筛选下拉):flw_def 已标注 ∪ 已注册回调的应用,失败降级为空下拉 */
|
||||
/** 授权摘要(E6):与列表同批取,失败只置标记 + 提示一次,列内显示 -(不冒充 0) */
|
||||
async function loadGrantSummary() {
|
||||
try {
|
||||
grantMap.value = ((await flowApi.grantSummary().send()) as any) || {};
|
||||
grantSummaryFailed.value = false;
|
||||
} catch (e: any) {
|
||||
grantMap.value = {};
|
||||
grantSummaryFailed.value = true;
|
||||
if (!grantSummaryWarned.value) {
|
||||
grantSummaryWarned.value = true;
|
||||
Message.warning(`授权摘要加载失败,该列暂显示为 -:${e?.message || "接口无响应"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 在途统计(议题二④):列表到位后取 distinct flowGuids 批量查,失败静默降级(行内显示「—」) */
|
||||
async function loadInflight() {
|
||||
const guids = Array.from(new Set(rows.value.map((r: any) => r.flowGuid).filter(Boolean))).slice(0, 100);
|
||||
if (!guids.length) {
|
||||
inflightMap.value = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inflightMap.value = (await flowApi.defInflight(guids).send()) || {};
|
||||
} catch {
|
||||
inflightMap.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 分组行(议题二②):同 flowGuid 折叠一行,代表行=已发布中最高版;无已发布取最高版并标「无生效版」。
|
||||
* 空 flowGuid(历史脏数据)无流程线可折叠,各自单列为一行,避免被整体丢弃。 */
|
||||
const groups = computed(() => {
|
||||
const bucket: Record<string, any[]> = {};
|
||||
const order: string[] = [];
|
||||
const noGuid: any[] = [];
|
||||
rows.value.forEach((r: any) => {
|
||||
const g = r.flowGuid || "";
|
||||
if (!g) {
|
||||
noGuid.push(r);
|
||||
return;
|
||||
}
|
||||
if (!bucket[g]) {
|
||||
bucket[g] = [];
|
||||
order.push(g);
|
||||
}
|
||||
bucket[g].push(r);
|
||||
});
|
||||
const out = order.map((g) => {
|
||||
const vs = bucket[g].slice().sort((a: any, b: any) => b.version - a.version);
|
||||
const published = vs.filter((v: any) => v.status === 1);
|
||||
const cur = published[0] || vs[0];
|
||||
return {
|
||||
_key: g, flowGuid: g, id: cur.id, name: cur.name, code: cur.code, appKey: cur.appKey,
|
||||
version: cur.version, status: cur.status, publishTime: cur.publishTime,
|
||||
// 线状态全版本同值;任一行标记停发即按停发展示(防历史脏行让管理员误判可用)
|
||||
lineOffline: vs.some((v: any) => Number(v.lineOffline) === 1) ? 1 : 0,
|
||||
total: vs.length, hasPublished: published.length > 0,
|
||||
versions: vs, _expanded: !!openMap.value[g],
|
||||
};
|
||||
});
|
||||
// 空 flowGuid 兜底:openMap 用合成 key,避免与其他行共用 "" 键互相牵连
|
||||
noGuid.forEach((r: any) => {
|
||||
const k = `__noguid__${r.id}`;
|
||||
out.push({
|
||||
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
|
||||
version: r.version, status: r.status, publishTime: r.publishTime,
|
||||
lineOffline: 0,
|
||||
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作只用版本级 versionActions)。
|
||||
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
|
||||
const versionColumns = [
|
||||
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
|
||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||
{ title: "创建人", width: 110, render: (h: any, p: any) => h("span", p.row.createBy || "-") },
|
||||
// 创建时间「2026-10-13 23:44」实测行盒 2(折行)→ 取 160,与主表「发布时间」同宽(同文案已证单行)
|
||||
{ title: "创建时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.createTime)) },
|
||||
// 改动说明:固定窄列 + 省略号 + Tooltip(2026-10-14 用户要求压短,完整内容进 Tooltip)
|
||||
{ title: "改动说明", width: 260, ellipsis: true, render: (h: any, p: any) => (p.row.changeLog
|
||||
? ellipsisCell("changeLog")(h, p)
|
||||
: h("span", { style: "color:#999" }, "(未填写)")) },
|
||||
actionColumn(versionActions, { mode: "text", width: 210 }),
|
||||
];
|
||||
|
||||
/** 子表各列声明宽之和:作为内层 Table 的 width 传下去,避免 iView 把展开行剩余空间摊到各列
|
||||
* (否则「改动说明」会被撑回数百像素,等于没压短),同时让子表右端不再压到固定条带下方。 */
|
||||
const VERSIONS_TABLE_WIDTH = 70 + 90 + 110 + 160 + 260 + 210;
|
||||
|
||||
const groupColumns = [
|
||||
{ type: "expand", width: 40, render: (h: any, p: any) => h(Table, {
|
||||
columns: versionColumns, data: p.row.versions, size: "small", noDataText: "暂无数据",
|
||||
width: VERSIONS_TABLE_WIDTH,
|
||||
}) },
|
||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||
// 标题列固定宽 + 超长省略 + Tooltip;生效/在途 徽标已拆到独立列(2026-10-14)
|
||||
{ title: "流程", width: 260, ellipsis: true, render: ellipsisCell("name", "b") },
|
||||
{ title: "生效/在途", width: 240, align: "center", render: renderRunState },
|
||||
// 编码实测单行 155px + 内边距 24 → 160 会折行,故 190
|
||||
{ title: "编码", key: "code", width: 200 },
|
||||
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
||||
// 授权摘要(E6):本校该流程线上「生效」许可的应用数;与「应用」列的归属无关
|
||||
{ title: "授权摘要", width: 90, align: "center", render: renderGrantSummary },
|
||||
{ title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) },
|
||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||
// GUID 列宽按 32 位 uuid 实测单行 259px + 内边距 24 定尺寸(留余量,防不同字形更宽);不再截 8 位
|
||||
{ title: "GUID", width: 320, render: (h: any, p: any) => (p.row.flowGuid
|
||||
? h("span", { title: p.row.flowGuid }, p.row.flowGuid)
|
||||
: h("span", { style: "color:#999" }, "—")) },
|
||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
||||
actionColumn(
|
||||
[
|
||||
...lineActions,
|
||||
grantAction,
|
||||
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
|
||||
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
|
||||
],
|
||||
// maxInline 4:四动作全部直出,避免把既有「编辑信息」挤进「更多」削弱原入口
|
||||
{ mode: "text", width: 320, maxInline: 4, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
/** 分组态展开/收起:只由左侧箭头触发(Table 的 on-expand),行内任意位置点击不再切换 */
|
||||
function onGroupExpand(row?: any, status?: boolean) {
|
||||
if (row && row._key) {
|
||||
openMap.value[row._key] = !!status;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用清单(筛选下拉):应用设置白名单(flw_app enabled=1),失败降级为空下拉 */
|
||||
async function loadAppKeys() {
|
||||
try {
|
||||
appKeys.value = (await flowApi.defAppKeys().send()) || [];
|
||||
const rows: any[] = (await flowApi.appList().send()) || [];
|
||||
const enabled = rows.filter((r) => r.enabled === 1);
|
||||
apps.value = enabled.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
|
||||
const map: Record<string, string> = {};
|
||||
rows.forEach((r) => { if (r.appKey != null) map[String(r.appKey)] = r.appName || String(r.appKey); });
|
||||
appMap.value = map;
|
||||
} catch {
|
||||
appKeys.value = [];
|
||||
apps.value = [];
|
||||
appMap.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +358,13 @@ async function onPublish(row: any) {
|
||||
}
|
||||
|
||||
function onOffline(row: any) {
|
||||
// 议题二④:在途数 >0 才追加提示(0 或接口降级态沿用原文案)
|
||||
const n = inflightMap.value[row.flowGuid] || 0;
|
||||
MyConfirm({
|
||||
title: "停用",
|
||||
msg: `确定停用流程「${row.name}」吗?`,
|
||||
info: "停用后无法发起新办件,进行中的办件不受影响",
|
||||
info: "停用后无法发起新办件,进行中的办件不受影响"
|
||||
+ (n ? `;当前在途 ${n} 件仍跑本版直至完成` : ""),
|
||||
async onOk() {
|
||||
await flowApi.defOffline(row.id).send();
|
||||
Message.success("已停用");
|
||||
@@ -134,6 +391,103 @@ function onEnable(row: any) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 流程线级停发:与版本级「停用」正交,确认文案明确讲清影响面 */
|
||||
function onLineOffline(row: any) {
|
||||
if (!row.flowGuid) {
|
||||
Message.warning("该行缺少流程线标识,无法执行线级停发");
|
||||
return;
|
||||
}
|
||||
if (isLineBusy(row)) {
|
||||
Message.info("该流程线操作正在处理中,请稍候");
|
||||
return;
|
||||
}
|
||||
if (Number(row.lineOffline) === 1) {
|
||||
Message.info("该流程线已处于停发状态");
|
||||
return;
|
||||
}
|
||||
const n = inflightMap.value[row.flowGuid] || 0;
|
||||
MyConfirm({
|
||||
title: "停发流程线",
|
||||
msg: `确定停发流程线「${row.name}」吗?`,
|
||||
info: "停发后整条流程线不可发起新办件/选人预览,各版本发布状态不变,在途办件不受影响"
|
||||
+ (n ? `;当前在途 ${n} 件仍按原版本跑完` : ""),
|
||||
async onOk() {
|
||||
await applyLineAction(row, "offline");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 流程线级恢复:只恢复线可用性,不改任何版本状态或发布内容 */
|
||||
function onLineEnable(row: any) {
|
||||
if (!row.flowGuid) {
|
||||
Message.warning("该行缺少流程线标识,无法执行线级恢复");
|
||||
return;
|
||||
}
|
||||
if (isLineBusy(row)) {
|
||||
Message.info("该流程线操作正在处理中,请稍候");
|
||||
return;
|
||||
}
|
||||
if (Number(row.lineOffline) !== 1) {
|
||||
Message.info("该流程线当前未被停发");
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "恢复流程线",
|
||||
msg: `确定恢复流程线「${row.name}」吗?`,
|
||||
info: "仅恢复流程线可用性,不改动各版本状态或发布内容;恢复后新发起使用当前最高已发布版本",
|
||||
async onOk() {
|
||||
await applyLineAction(row, "enable");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 线级启停统一编排:状态守卫 → 调后端 → 按受影响行数反馈 → 刷新列表(失败不误报成功) */
|
||||
async function applyLineAction(row: any, action: "offline" | "enable") {
|
||||
const label = action === "offline" ? "停发" : "恢复";
|
||||
if (isLineBusy(row)) {
|
||||
Message.info("该流程线操作正在处理中,请稍候");
|
||||
return;
|
||||
}
|
||||
lineBusyGuid.value = String(row.flowGuid || "").trim();
|
||||
try {
|
||||
const out = await runLineAction({
|
||||
flowGuid: row.flowGuid,
|
||||
lineOffline: Number(row.lineOffline) || 0,
|
||||
action,
|
||||
request: async (guid) => Number(await (action === "offline"
|
||||
? flowApi.defOfflineLine(guid, { silentBizToast: true }).send()
|
||||
: flowApi.defEnableLine(guid, { silentBizToast: true }).send())) || 0,
|
||||
reload: () => load(),
|
||||
});
|
||||
if (out.kind === "skipped") {
|
||||
if (out.reason === "no-guid") {
|
||||
Message.warning(`该行缺少流程线标识,无法执行线级${label}`);
|
||||
} else if (out.reason === "already-offline") {
|
||||
Message.info("该流程线已处于停发状态");
|
||||
} else {
|
||||
Message.info("该流程线当前未被停发");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (out.kind === "empty") {
|
||||
Message.warning("未更新任何版本,流程线状态可能已变化,请刷新后确认");
|
||||
} else {
|
||||
Message.success(action === "offline"
|
||||
? `流程线已停发(${out.affected} 个版本同步标记,在途办件不受影响)`
|
||||
: `流程线已恢复(${out.affected} 个版本同步恢复,版本状态与发布内容未改动)`);
|
||||
}
|
||||
if (out.reloadFailed) {
|
||||
Message.warning("操作已提交,但列表刷新失败,请手动刷新后确认最新状态");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(`${label}流程线失败:${lineActionErrorDetail(e?.message || "")}`);
|
||||
} finally {
|
||||
if (lineBusyGuid.value === String(row.flowGuid || "").trim()) {
|
||||
lineBusyGuid.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 源码:列表行已带回 contentJson(defList 返回全量实体),直接开抽屉,无需再拉接口 */
|
||||
function onSource(row: any) {
|
||||
sourceText.value = prettyJson(row.contentJson);
|
||||
@@ -161,6 +515,64 @@ function onDiagram(row: any) {
|
||||
router.push({ path: "/diagram", query: { defId: row.id } });
|
||||
}
|
||||
|
||||
/** 使用许可(E6):跳独立授权页并预选该流程线;归属应用与授权许可是两套数据,不在本页就地改 */
|
||||
function onViewGrants(row: any) {
|
||||
if (!row.flowGuid) {
|
||||
Message.warning("该行缺少流程线标识,无法定位使用许可");
|
||||
return;
|
||||
}
|
||||
router.push({ path: "/admin/usage-grants", query: { flowGuid: String(row.flowGuid) } });
|
||||
}
|
||||
|
||||
// ---- 编辑信息(元数据原地编辑,2026-10-10 议题2:改名称/归属应用不升版本) ----
|
||||
const metaVisible = ref(false);
|
||||
const metaSaving = ref(false);
|
||||
const metaForm = ref<{ id: number; name: string; appKey: string; version: number; status: number }>({
|
||||
id: 0, name: "", appKey: "", version: 0, status: 0,
|
||||
});
|
||||
|
||||
function onEditMeta(row: any) {
|
||||
metaForm.value = { id: row.id, name: row.name || "", appKey: row.appKey || "", version: row.version, status: row.status };
|
||||
metaVisible.value = true;
|
||||
}
|
||||
|
||||
/** 当前 appKey 不在启用集合时兜底插入(保存会被后端拦,提示用户先开启或清空) */
|
||||
const metaAppOptions = computed(() => {
|
||||
const list = apps.value.map((a) => ({ value: a.appKey, label: a.appName }));
|
||||
const cur = metaForm.value.appKey;
|
||||
if (cur && !list.some((o) => o.value === cur)) {
|
||||
list.unshift({ value: cur, label: `${cur}(未开启,保存将被拦)` });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
async function onMetaSave() {
|
||||
if (!metaForm.value.name.trim()) {
|
||||
Message.warning("流程名称不能为空");
|
||||
return;
|
||||
}
|
||||
// 归属应用必填(2026-10-12 拍板):存量未归属行在此原地补填
|
||||
if (!metaForm.value.appKey) {
|
||||
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
|
||||
return;
|
||||
}
|
||||
metaSaving.value = true;
|
||||
try {
|
||||
await flowApi.defUpdateMeta({
|
||||
id: metaForm.value.id,
|
||||
name: metaForm.value.name.trim(),
|
||||
appKey: metaForm.value.appKey,
|
||||
}).send();
|
||||
Message.success("已更新(版本不变)");
|
||||
metaVisible.value = false;
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
metaSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadAppKeys();
|
||||
@@ -168,30 +580,106 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<!-- 操作按钮区:主操作靠左(bsch应用页规范 §1) -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onNew">新建流程</Button>
|
||||
<Select v-model="filterApp" clearable filterable placeholder="按应用筛选" class="fl"
|
||||
style="width: 200px; margin-left: 8px" @on-change="load">
|
||||
<Option v-for="k in appKeys" :key="k" :value="k">{{ k }}</Option>
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
<!-- 议题二②:分组(同 flowGuid 折叠一行,默认)/ 平铺(现状全版本行)纯前端切换 -->
|
||||
<RadioGroup v-model="viewMode" type="button" button-style="solid" class="fl" style="margin-left: 8px">
|
||||
<Radio label="group">仅最新版(分组)</Radio>
|
||||
<Radio label="flat">显示全部版本</Radio>
|
||||
</RadioGroup>
|
||||
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
|
||||
{{ viewMode === "group"
|
||||
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立"
|
||||
: "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }}
|
||||
</span>
|
||||
<!-- 三处入口分属三套数据(E6):不写清就会被读成"选了应用即自动获得发起权" -->
|
||||
<p class="grants-hint fl" style="width: 100%">
|
||||
「应用」列=流程定义归属(<code>flw_def.app_key</code>,创建方与审计元数据);
|
||||
「授权摘要」列=该流程线上生效的<b>使用许可</b>数(<code>flw_usage_grant</code>,独立授权记录);
|
||||
办件详情的来源应用=<b>实例</b>实际发起方。三者互不等价:归属某应用不等于该应用可发起,
|
||||
未授予许可也<b>不影响流程可见与编辑</b>,只影响能否发起。点行内「使用许可」到独立页授予/撤销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="tableHeight" no-data-text="暂无数据" />
|
||||
<div class="table-region" ref="defsRegionRef">
|
||||
<Table v-if="viewMode === 'group'" :columns="groupColumns" :data="groups" :loading="loading"
|
||||
class="link-table table-bottom-box" :height="defsH || undefined" no-data-text="暂无数据"
|
||||
@on-expand="onGroupExpand" />
|
||||
<Table v-else :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="defsH || undefined" no-data-text="暂无数据" />
|
||||
</div>
|
||||
|
||||
<Modal v-model="versionVisible" title="版本历史" width="640">
|
||||
<Modal v-model="versionVisible" title="版本历史" width="820">
|
||||
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 8px">
|
||||
审计信息:创建人 / 创建时间 / 改动说明为本次新增列(存量版本未填说明时显示「(未填写)」)
|
||||
</p>
|
||||
<Table :columns="[
|
||||
{ title: '版本', key: 'version', width: 80 },
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '状态', width: 90, render: (h, p) => h('Tag', () => DEF_STATUS[p.row.status]) },
|
||||
{ title: '发布时间', width: 160, render: (h, p) => (p.row.publishTime || '').replace('T', ' ') || '-' },
|
||||
{ title: '版本', key: 'version', width: 70, render: (h, p) => h('b', 'v' + p.row.version) },
|
||||
{ title: '名称', key: 'name', width: 150 },
|
||||
{ title: '状态', width: 80, render: (h, p) => h(Tag, () => DEF_STATUS[p.row.status]) },
|
||||
{ title: '发布时间', width: 132, render: (h, p) => (p.row.publishTime || '').replace('T', ' ').slice(0, 16) || '-' },
|
||||
{ title: '创建人', width: 90, render: (h, p) => p.row.createBy || '-' },
|
||||
{ title: '创建时间', width: 132, render: (h, p) => (p.row.createTime || '').replace('T', ' ').slice(0, 16) || '-' },
|
||||
{ title: '改动说明', minWidth: 230, render: (h, p) => p.row.changeLog
|
||||
? h('span', p.row.changeLog) : h('span', { style: 'color:#999' }, '(未填写)') },
|
||||
]" :data="versions" size="small" no-data-text="暂无数据" />
|
||||
</Modal>
|
||||
|
||||
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
|
||||
|
||||
<Modal v-model="metaVisible" title="编辑流程信息(不升版本)" :mask-closable="false" width="460">
|
||||
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 10px">
|
||||
只修改名称与归属应用,版本与流程内容保持不变(v{{ metaForm.version }},{{
|
||||
DEF_STATUS[metaForm.status] || metaForm.status }})。结构改动请走「{{
|
||||
metaForm.status === 0 ? "编辑" : "升版本" }}」进编辑器。
|
||||
</p>
|
||||
<Form :label-width="90">
|
||||
<FormItem label="流程名称">
|
||||
<Input v-model="metaForm.name" placeholder="流程名称" :maxlength="30" />
|
||||
</FormItem>
|
||||
<FormItem label="归属应用">
|
||||
<Select v-model="metaForm.appKey" filterable placeholder="归属应用(必选)">
|
||||
<Option v-for="o in metaAppOptions" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="metaVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="metaSaving" @click="onMetaSave">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 无分页脚:通道只对含 .page-panel 的卡片自动转纵向分栏,本页自声明保证 table-region 撑满 */
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 三概念口径说明(E6):紧贴筛选行,避免管理员把「应用」列读成授权 */
|
||||
.grants-hint {
|
||||
margin-top: 6px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.grants-hint b {
|
||||
color: #17233d;
|
||||
}
|
||||
.grants-hint code {
|
||||
background: #f0f2f5;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,55 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
// 办件记录(管理端):全校办件查询;CONDITION 判断失败挂起的办件在此"重跑路由"恢复
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
|
||||
const router = useRouter();
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
|
||||
const dataParams = ref<{ status?: number; keyword?: string }>({ status: undefined, keyword: "" });
|
||||
// status 下拉含伪选项 'hung'(路由挂起):表内是 string,出站转 hung=true(不再传 status)
|
||||
const dataParams = ref<{ status?: number | string; keyword?: string }>({ status: undefined, keyword: "" });
|
||||
const drawerVisible = ref(false);
|
||||
const drawerInstanceId = ref<string | number>("");
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) },
|
||||
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
|
||||
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: "状态", width: 120, align: "center", render: (h: any, p: any) => h("span", [
|
||||
h(Tag, { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status),
|
||||
// F11-4 路由挂起红标:接口回传 routeHung(最新 attempt 路由判断仍无出口)
|
||||
p.row.routeHung ? h(Tag, { color: "red" }, () => "路由挂起") : null,
|
||||
]) },
|
||||
{ title: "提交人", key: "submitterName", width: 100 },
|
||||
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||
{ title: "操作", width: 170, align: "center", fixed: "right", render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)),
|
||||
h("span", { style: "display:inline-block;width:10px" }),
|
||||
// 仅审批中的办件可能挂在条件分支节点(重跑入口对非挂起办件会得到业务错提示)
|
||||
p.row.status === 10
|
||||
? Utils.operatingHandle(h, "重跑路由", "icon-play", () => onReroute(p.row))
|
||||
: h("span"),
|
||||
] },
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.id) },
|
||||
// 仅审批中的办件可挂条件分支节点;其余行置灰保位(重跑入口对非挂起办件是业务错提示)
|
||||
{ key: "reroute", label: "重跑路由", icon: "icon-play", on: (r: any) => onReroute(r), disabledWhen: (r: any) => r.status !== 10 },
|
||||
],
|
||||
{ mode: "text", width: 170, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
/** 重跑路由(2026-09-24 §4.4):重新调判断服务,成功即按出口续走;仍失败回业务错(办件继续挂起) */
|
||||
function openDetail(instanceId: string | number) {
|
||||
drawerInstanceId.value = instanceId;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
/** 重跑路由(2026-09-24 §4.4 + F5-4 逃生门):确认框带「按当前已发布定义重跑」复选——
|
||||
* 僵尸办件(挂起时的表达式已修好并发布为新版)勾选后按新版续走,否则按落库快照重跑 */
|
||||
const rerouteVisible = ref(false);
|
||||
const rerouteUseLatest = ref(false);
|
||||
const rerouteRow = ref<any>(null);
|
||||
|
||||
function onReroute(row: any) {
|
||||
MyConfirm({
|
||||
title: "重跑路由",
|
||||
msg: `确定对办件「${row.bizTitle || row.id}」重跑条件路由判断吗?`,
|
||||
info: "仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)",
|
||||
async onOk() {
|
||||
await flowApi.instanceReroute(row.id).send();
|
||||
Message.success("路由已重跑,流程按新判断结果继续");
|
||||
rerouteRow.value = row;
|
||||
rerouteUseLatest.value = false;
|
||||
rerouteVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmReroute() {
|
||||
const row = rerouteRow.value;
|
||||
if (!row) return;
|
||||
await flowApi.instanceReroute(row.id, rerouteUseLatest.value).send();
|
||||
rerouteVisible.value = false;
|
||||
Message.success("路由已重跑,流程按判断结果继续");
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
||||
const params: any = { page: p.pageIndex, size: p.pageSize };
|
||||
if (dataParams.value.status) params.status = dataParams.value.status;
|
||||
if (dataParams.value.status === "hung") {
|
||||
// F11-4 路由挂起伪选项:只传 hung,不带 status(后端 hung 过滤已内含审批中)
|
||||
params.hung = true;
|
||||
} else if (dataParams.value.status) {
|
||||
params.status = dataParams.value.status;
|
||||
}
|
||||
if (dataParams.value.keyword) params.keyword = dataParams.value.keyword.trim();
|
||||
return flowApi.adminInstances(params);
|
||||
};
|
||||
@@ -60,7 +80,7 @@ function onSearch() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
|
||||
@@ -68,11 +88,19 @@ function onSearch() {
|
||||
<Option :value="20" label="已完成" />
|
||||
<Option :value="30" label="已拒绝" />
|
||||
<Option :value="40" label="已撤回" />
|
||||
<Option :value="50" label="退回待修改" />
|
||||
<Option value="hung" label="路由挂起" />
|
||||
</Select>
|
||||
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
|
||||
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||
</div>
|
||||
<InstanceDrawer v-model="drawerVisible" :instance-id="drawerInstanceId" />
|
||||
<Modal v-model="rerouteVisible" title="重跑路由" :mask-closable="false" @on-ok="confirmReroute">
|
||||
<p style="margin: 0 0 8px">确定对办件「{{ rerouteRow?.bizTitle || rerouteRow?.id }}」重跑条件路由判断吗?</p>
|
||||
<p style="margin: 0 0 12px; color: #808695">仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)</p>
|
||||
<Checkbox v-model="rerouteUseLatest">按当前已发布流程定义重跑(救僵尸办件)</Checkbox>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;办理人改为弹窗选人(参照 Notice 选人范式)
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import UserSelectInput from "@/components/UserSelectInput.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const loading = ref(false);
|
||||
const deptKeyFilter = ref("");
|
||||
const rows = ref<any[]>([]);
|
||||
// 操作按钮区 + 搜索区、无分页
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
|
||||
|
||||
// 一键拉取教师组(预览确认后批量导入)
|
||||
const groupVisible = ref(false);
|
||||
const groupLoading = ref(false);
|
||||
const groupImporting = ref(false);
|
||||
const groupRows = ref<any[]>([]);
|
||||
const groupColumns = [
|
||||
{ title: "科室编码", key: "deptKey", width: 100 },
|
||||
{ title: "科室名称", key: "deptName", width: 160 },
|
||||
{ title: "成员数", key: "memberCount", width: 90, align: "center", render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) },
|
||||
{ title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") },
|
||||
];
|
||||
|
||||
async function onGroupPreview() {
|
||||
groupVisible.value = true;
|
||||
groupLoading.value = true;
|
||||
try {
|
||||
groupRows.value = (await flowApi.mapGroupPreview().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "教师组拉取失败(UserBase 不可达?)");
|
||||
groupVisible.value = false;
|
||||
} finally {
|
||||
groupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onGroupImport() {
|
||||
groupImporting.value = true;
|
||||
try {
|
||||
const r: any = await flowApi.mapImportGroups().send();
|
||||
Message.success(`导入完成:${r?.depts ?? 0} 个科室 / 新增 ${r?.users ?? 0} 条映射 / 跳过已存在 ${r?.skippedExisting ?? 0} 条`);
|
||||
groupVisible.value = false;
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "导入失败");
|
||||
} finally {
|
||||
groupImporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "科室编码", key: "deptKey", width: 140 },
|
||||
{ title: "科室名称", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") },
|
||||
{ title: "办理人", key: "userName", width: 120 },
|
||||
{ title: "用户ID", key: "userId", width: 90 },
|
||||
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
p.row.status === 0
|
||||
? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
|
||||
: Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)) },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await flowApi.mapList(deptKeyFilter.value.trim() || undefined).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
load();
|
||||
}
|
||||
|
||||
// ---- 新增/更新映射弹窗(规范:必填红星标注,校验失败提示统一文案) ----
|
||||
const saveVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const formRef = ref<any>(null);
|
||||
const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" });
|
||||
const saveRules = {
|
||||
deptKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
|
||||
userId: [{ required: true, message: "请填写红色星号内容!", trigger: "change" }],
|
||||
};
|
||||
|
||||
function onAdd() {
|
||||
form.value = { deptKey: "", deptName: "", userId: null, userName: "" };
|
||||
saveVisible.value = true;
|
||||
}
|
||||
|
||||
function onUserPick(u: any) {
|
||||
// 选人组件回传 id+name 同帧快照(姓名不再自查)
|
||||
form.value.userName = u?.name || "";
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
formRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await flowApi.mapSave({
|
||||
deptKey: form.value.deptKey.trim(),
|
||||
deptName: form.value.deptName,
|
||||
userId: Number(form.value.userId),
|
||||
userName: form.value.userName,
|
||||
}).send();
|
||||
Message.success("已保存");
|
||||
saveVisible.value = false;
|
||||
load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onDisable(row: any) {
|
||||
MyConfirm({
|
||||
title: "停用",
|
||||
msg: `确定停用科室「${row.deptName || row.deptKey}」的办理人映射吗?`,
|
||||
info: "停用后该科室流程节点需重新配置办理人",
|
||||
async onOk() {
|
||||
await flowApi.mapDisable(row.id).send();
|
||||
Message.success("已停用");
|
||||
load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 启用(2026-09-18 补齐:原来停用后只能重新"新增映射"恢复) */
|
||||
function onEnable(row: any) {
|
||||
MyConfirm({
|
||||
title: "启用",
|
||||
msg: `确定启用科室「${row.deptName || row.deptKey}」的办理人「${row.userName || row.userId}」吗?`,
|
||||
info: "启用后该科室的流程节点会重新解析到这名办理人",
|
||||
async onOk() {
|
||||
await flowApi.mapEnable(row.id).send();
|
||||
Message.success("已启用");
|
||||
load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 操作按钮区:主操作靠左、其余靠右(bsch应用页规范 §1) -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onAdd">新增映射</Button>
|
||||
<Button class="fr" size="large" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区:关键字即输即搜,不设查询按钮 -->
|
||||
<div class="search-wrap fix">
|
||||
<Input class="fr" v-model="deptKeyFilter" placeholder="按科室编码筛选" search suffix="ios-search"
|
||||
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="tableHeight" no-data-text="暂无数据" />
|
||||
|
||||
<Modal v-model="saveVisible" title="新增/更新映射" :mask-closable="false">
|
||||
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="90">
|
||||
<FormItem label="科室编码" prop="deptKey">
|
||||
<Input v-model="form.deptKey" placeholder="流程 JSON 中 DEPT_KEY 引用的编码,如 ybb" />
|
||||
</FormItem>
|
||||
<FormItem label="科室名称">
|
||||
<Input v-model="form.deptName" placeholder="如 医保办(可空)" />
|
||||
</FormItem>
|
||||
<FormItem label="办理人" prop="userId">
|
||||
<UserSelectInput
|
||||
:model-value="form.userId"
|
||||
:display-name="form.userName"
|
||||
@update:model-value="form.userId = $event"
|
||||
@update:display-name="form.userName = $event"
|
||||
@on-change="onUserPick"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="saveVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="groupVisible" title="一键拉取教师组(UserBase 学校组树)" width="720" :mask-closable="false">
|
||||
<p style="margin-bottom: 8px; color: #808695">
|
||||
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
|
||||
</p>
|
||||
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" no-data-text="暂无数据" />
|
||||
<template #footer>
|
||||
<Button @click="groupVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">确认导入</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,741 @@
|
||||
<script setup lang="ts">
|
||||
// 接入向导(2026-10-15 议题二 P1):把子应用接入流程中心从「人背文档 + 逐字对齐字符串」变成
|
||||
// 「向导引导 + 机器生成正确配置 + 每步可自检」。五步:应用档案 → 工程接入 → 回调注册 → 流程编排 → 联调验收。
|
||||
// 数据源全部复用已有接口(appList / regList / callbackProbe / defList / defGet / callbackLogs / adminInstances),
|
||||
// 零新增表、零新增写接口。配置生成器对齐《子应用接入流程中心规范.md》§7.2/§7.5。
|
||||
// 方案文档:docs/流程中心接入向导与配置生成器-实施方案-20261015.md
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { Message, Tag, Button } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { extractExprVars, EXPR_BUILTIN_VARS } from "@/views/flow/admin/def-editor/model";
|
||||
|
||||
// ==================== 步骤骨架 ====================
|
||||
const STEPS = [
|
||||
{ title: "应用档案", hint: "我是谁:appKey / target / context-path / bizType" },
|
||||
{ title: "工程接入", hint: "生成正确形态的 yh.flow-callback 配置" },
|
||||
{ title: "回调注册", hint: "登记行存在且地址合规 + 探测" },
|
||||
{ title: "流程编排", hint: "流程定义 → 变量契约显性化" },
|
||||
{ title: "联调验收", hint: "六项自检 + 自检报告" },
|
||||
];
|
||||
const step = ref(0);
|
||||
|
||||
// ==================== STEP① 应用档案 ====================
|
||||
const apps = ref<any[]>([]);
|
||||
const appMap = ref<Record<string, string>>({});
|
||||
const s1 = ref({
|
||||
appKey: "",
|
||||
target: "",
|
||||
ctx: "",
|
||||
bizTypes: "",
|
||||
bindLevel: "app",
|
||||
});
|
||||
const BIND_LEVELS = [
|
||||
{ value: "app", label: "应用级", desc: "整个应用一种审批流(一校一行绑定表)" },
|
||||
{ value: "object", label: "对象级", desc: "每个业务对象各走各的流(对象行加 flow_guid 列)" },
|
||||
{ value: "form", label: "表单级", desc: "自定义表单统一审批路径,事项零配置继承" },
|
||||
];
|
||||
|
||||
// ==================== STEP② 工程接入(配置生成器) ====================
|
||||
const s2 = ref({
|
||||
// 端点形态:platform=平台验签式(式 A,推荐)|xflw=注册级 X-Flw 式(式 B)
|
||||
endpointMode: "platform" as "platform" | "xflw",
|
||||
// 接入场景:new=新接入|upgrade=存量升级(已接回调,升 starter 版本即可)
|
||||
scenario: "new" as "new" | "upgrade",
|
||||
configDone: false,
|
||||
});
|
||||
// 前置条件清单(规范 §7.1 + work-leave 四个静默断点);后两条高危,红底强调
|
||||
const PREREQS = [
|
||||
{ key: "openapi", label: "已配 yh-openapi-spring-boot-starter", danger: false, tip: "Registrar 自注册用" },
|
||||
{ key: "appauth", label: "平台验签式:已配 yh.app-auth.server.enforce-patterns: [/oapi/*]", danger: false, tip: "式 A 的验签过滤器" },
|
||||
{ key: "datasource", label: "应用有 JDBC DataSource", danger: false, tip: "幂等表/事件表落地" },
|
||||
{ key: "redis", label: "projman 已勾选 Redis 公共组件", danger: true, tip: "断点 3:缺 Redis 验签链无密钥,编译启动都不报错,直到验签 401" },
|
||||
{ key: "trust", label: "trust-registry: true 已配", danger: true, tip: "断点 4:缺失 = 验签不过,症状与「收不到回调」同构" },
|
||||
{ key: "endpoint", label: "接收端点已部署且可访问", danger: false, tip: "注册接口会探活,端点必须先上线" },
|
||||
];
|
||||
const prereqChecked = ref<Record<string, boolean>>({});
|
||||
const prereqAllDone = computed(() => PREREQS.every((p) => prereqChecked.value[p.key]));
|
||||
|
||||
/** 生成的 yh.flow-callback 配置块(技术核心,对齐规范 §7.2 推导式) */
|
||||
const generatedYaml = computed(() => {
|
||||
const ctx = s1.value.ctx.trim();
|
||||
const bizTypes = s1.value.bizTypes
|
||||
.split(",")
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
const isXflw = s2.value.endpointMode === "xflw";
|
||||
const L: string[] = ["yh:", " flow-callback:", " enabled: true"];
|
||||
L.push(ctx
|
||||
? ` base-path: ${ctx} # 接收方 servlet context-path;§4.2:填错/漏填 ⇒ 推送全 401 无限重试`
|
||||
: " # base-path: # 本地无 context-path 就不填(填了反而 401)");
|
||||
L.push(` biz-types: [${bizTypes.join(", ")}] # 本应用 bizType 白名单;空=不过滤全交 Handler`);
|
||||
L.push(` verify-flw-signature: ${isXflw}${isXflw ? " # 式 B:注册级 X-Flw 签名" : " # 式 A:平台应用签名(AppAuthFilter)"}`);
|
||||
if (isXflw) {
|
||||
L.push(" path: /callback/flow");
|
||||
L.push(" # ⚠ 端点第二段非 oapi → 网关 TargetRouter 裸 404,必须显式配 callback-url 直连:");
|
||||
L.push(" # callback-url: http://<host>:<port>/callback/flow");
|
||||
} else {
|
||||
L.push(" # path: /oapi/flow/callback # 平台验签式用 starter 默认值,不必写");
|
||||
}
|
||||
L.push(" store:");
|
||||
L.push(" auto-create-table: true # 首次使用自动 CREATE TABLE IF NOT EXISTS");
|
||||
return L.join("\n");
|
||||
});
|
||||
|
||||
/** 生成的依赖块(存量升级场景不生成,改提示升版本) */
|
||||
const generatedPom = computed(() => {
|
||||
if (s2.value.scenario === "upgrade") {
|
||||
return "<!-- 存量升级:无需新增依赖,升级 yh-flow-callback-spring-boot-starter 版本即可。\n 回调接收与条件路由判断由同一个 jar 提供。 -->";
|
||||
}
|
||||
const extra = s2.value.endpointMode === "platform"
|
||||
? "\n<!-- 平台验签式额外需要:应用已配 yh-app-auth-spring-boot-starter -->"
|
||||
: "";
|
||||
return "<dependency>\n <groupId>com.yunhai.platform</groupId>\n <artifactId>yh-flow-callback-spring-boot-starter</artifactId>\n</dependency>" + extra;
|
||||
});
|
||||
|
||||
/** 生成的 Handler 骨架(规范 §7.3) */
|
||||
const generatedHandler = computed(() =>
|
||||
"@Bean\npublic FlowCallbackHandler flowCallbackHandler(YourRepository repo) {\n return event -> {\n // 走到这里已验签、已过幂等/乱序门/bizType 过滤,且与业务推进同一事务\n switch (event.getEvent()) {\n case \"flow.submitted\": /* 在途 */ break;\n case \"flow.task.created\": /* 建待办 */ break;\n case \"flow.task.handled\": /* 记录办理 */ break;\n case \"flow.completed\": /* 终态·通过(终态不可逆!) */ break;\n case \"flow.rejected\": /* 终态·驳回 */ break;\n case \"flow.withdrawn\": /* 终态·撤回 */ break;\n default: /* 未知事件码 → 记日志后正常返回,200 吞掉(向前兼容) */ break;\n }\n };\n}");
|
||||
|
||||
// ==================== STEP③ 回调注册 ====================
|
||||
const regs = ref<any[]>([]);
|
||||
const regLoading = ref(false);
|
||||
/** 当前 appKey 的登记行(同一应用多个 flowGuid 各一行) */
|
||||
const appRegs = computed(() =>
|
||||
regs.value.filter((r: any) => String(r.appKey) === String(s1.value.appKey)));
|
||||
const regCheck = ref<{ level: "ok" | "warn" | "bad"; text: string; advice?: string }[]>([]);
|
||||
const regCheckedAt = ref("");
|
||||
const probeResult = ref<any>(null);
|
||||
const probing = ref(false);
|
||||
|
||||
async function loadRegs() {
|
||||
regLoading.value = true;
|
||||
try {
|
||||
regs.value = ((await flowApi.regList().send()) as any[]) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "登记列表加载失败");
|
||||
regs.value = [];
|
||||
} finally {
|
||||
regLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 登记行检查(调研 §3.3 痛点② 的直接兜底) */
|
||||
async function checkRegs() {
|
||||
if (!s1.value.appKey) {
|
||||
Message.warning("请先在第 ① 步选择应用");
|
||||
step.value = 0;
|
||||
return;
|
||||
}
|
||||
await loadRegs();
|
||||
const mine = appRegs.value;
|
||||
const out: { level: "ok" | "warn" | "bad"; text: string; advice?: string }[] = [];
|
||||
if (!mine.length) {
|
||||
out.push({ level: "bad", text: "该应用没有任何回调登记行", advice: "在管理端保存绑定时调 ensureRegistered(schId, flowGuid)(规范 §7.4);不要在发起时才注册" });
|
||||
} else {
|
||||
// 地址形态:私网/开发机地址必然拒连无限重试(断点 1)
|
||||
const PRIVATE = /:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+)/;
|
||||
const bad = mine.filter((r: any) => r.callbackUrl && PRIVATE.test(r.callbackUrl));
|
||||
if (bad.length) {
|
||||
out.push({ level: "bad", text: bad.length + " 条登记地址是私网/开发机地址(" + bad[0].callbackUrl + ")", advice: "推送必然拒连并无限重试:删掉本地镜像行 → 管理端重新保存绑定,走 starter 自注册" });
|
||||
}
|
||||
// basePath:有 ctx 却漏填 ⇒ 推送全 401(§4.2 高频事故)
|
||||
const emptyBase = mine.filter((r: any) => !r.basePath);
|
||||
if (emptyBase.length && s1.value.ctx.trim()) {
|
||||
out.push({ level: "bad", text: emptyBase.length + " 条登记未填 base_path,而你在第 ① 步声明了 context-path", advice: "漏填 base_path → 推送全 401 → 无限重试;症状表现为「收不到回调」" });
|
||||
} else if (emptyBase.length) {
|
||||
out.push({ level: "warn", text: emptyBase.length + " 条登记未填 base_path(你声明了无 context-path,需确认)", advice: "本地无 ctx 才留空;生产有 ctx 必须填" });
|
||||
}
|
||||
// 登记地址与第 ① 步声明的 target 一致性
|
||||
const target = s1.value.target.trim();
|
||||
if (target) {
|
||||
const mismatch = mine.filter((r: any) => r.callbackUrl && !String(r.callbackUrl).includes("/" + target));
|
||||
if (mismatch.length) {
|
||||
out.push({ level: "warn", text: mismatch.length + " 条登记地址不含声明的 target「" + target + "」", advice: "确认是旧登记残留,还是登记地址推导有误" });
|
||||
}
|
||||
}
|
||||
if (!out.length) {
|
||||
out.push({ level: "ok", text: mine.length + " 条登记全部合规(地址为网关形态、base_path 一致)" });
|
||||
}
|
||||
}
|
||||
regCheck.value = out;
|
||||
regCheckedAt.value = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
/** 探测:同步发一条 flow.ping 走真实管道(复用管理端 callbackProbe) */
|
||||
async function probeFirst() {
|
||||
const first = appRegs.value[0];
|
||||
if (!first) {
|
||||
Message.warning("该应用没有可探测的登记行");
|
||||
return;
|
||||
}
|
||||
probing.value = true;
|
||||
probeResult.value = null;
|
||||
try {
|
||||
probeResult.value = (await flowApi.callbackProbe(first.id).send()) || null;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "探测失败");
|
||||
} finally {
|
||||
probing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== STEP④ 流程编排 ====================
|
||||
const defs = ref<any[]>([]);
|
||||
const defLoading = ref(false);
|
||||
const selectedDefId = ref<number | null>(null);
|
||||
/** 选中流程的 CONDITION 节点清单 + 变量契约核对 */
|
||||
const condNodes = ref<{ name: string; judgeMode: string; routerKey: string; outlets: { name: string; expr: string; unknown: string[] }[] }[]>([]);
|
||||
const knownVars = ref<string[]>([]);
|
||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||
|
||||
async function loadDefs() {
|
||||
if (!s1.value.appKey) return;
|
||||
defLoading.value = true;
|
||||
try {
|
||||
defs.value = ((await flowApi.defList({ appKey: s1.value.appKey }).send()) as any[]) || [];
|
||||
if (!selectedDefId.value && defs.value.length) selectedDefId.value = defs.value[0].id;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "流程定义加载失败");
|
||||
defs.value = [];
|
||||
} finally {
|
||||
defLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 已知变量 = 近期 50 条办件 formData 顶层键(去重截 40)+ 内置 8 个;口径同编辑器与调试弹窗 */
|
||||
async function loadKnownVars() {
|
||||
const keys = new Set<string>();
|
||||
try {
|
||||
const rows: any[] = (await flowApi.adminInstances({ page: 1, size: 50 }).send())?.content || [];
|
||||
rows.forEach((r: any) => {
|
||||
try {
|
||||
const v = JSON.parse(r.formDataJson || "{}");
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) Object.keys(v).forEach((k: string) => keys.add(k));
|
||||
} catch { /* 脏 formData 跳过 */ }
|
||||
});
|
||||
} catch { /* 办件拉取失败:退化为仅内置,不阻塞 */ }
|
||||
knownVars.value = Array.from(new Set([...Array.from(keys).slice(0, 40), ...EXPR_BUILTIN_VARS]));
|
||||
}
|
||||
|
||||
const hasPublished = computed(() => defs.value.some((d: any) => d.status === 1));
|
||||
|
||||
/** 解析选中流程的 CONDITION 节点,把表达式变量与已知键核对(发布期 warn 校验的可视化复述) */
|
||||
async function inspectDef(id: number) {
|
||||
selectedDefId.value = id;
|
||||
condNodes.value = [];
|
||||
try {
|
||||
const d: any = await flowApi.defGet(id).send();
|
||||
const content = typeof d?.contentJson === "string" ? JSON.parse(d.contentJson || "{}") : (d?.contentJson || {});
|
||||
const nodes: any[] = content.nodes || [];
|
||||
const edges: any[] = content.edges || [];
|
||||
condNodes.value = nodes
|
||||
.filter((n: any) => n.type === "CONDITION")
|
||||
.map((n: any) => ({
|
||||
name: n.name || n.id,
|
||||
judgeMode: n.judgeMode || "SERVICE",
|
||||
routerKey: n.routerKey || "",
|
||||
outlets: edges
|
||||
.filter((e: any) => e.from === n.id)
|
||||
.map((e: any) => {
|
||||
const expr = e.condition || "";
|
||||
return { name: e.name || "(未命名出口)", expr, unknown: expr ? extractExprVars(expr).filter((v: string) => !knownVars.value.includes(v)) : [] };
|
||||
}),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "流程详情加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== STEP⑤ 联调验收 ====================
|
||||
const accept = ref<{ no: number; name: string; level: "ok" | "warn" | "bad" | "none"; detail: string; advice?: string }[]>([]);
|
||||
const acceptDone = ref(false);
|
||||
const acceptLoading = ref(false);
|
||||
|
||||
/** 六项自检:把「手册的诊断命令集」变成按钮 */
|
||||
async function runAcceptance() {
|
||||
acceptLoading.value = true;
|
||||
try {
|
||||
const R: typeof accept.value = [];
|
||||
const app = apps.value.find((a: any) => String(a.appKey) === String(s1.value.appKey));
|
||||
// 1 应用白名单启用(必须通过)
|
||||
R.push(app && app.enabled === 1
|
||||
? { no: 1, name: "应用白名单启用", level: "ok", detail: app.appName + "(" + app.appKey + ")已开通且启用" }
|
||||
: { no: 1, name: "应用白名单启用", level: "bad", detail: app ? "应用已停用" : "应用不在白名单", advice: "请先在用户中心开通/启用该应用" });
|
||||
// 2 前置条件全勾选(黄色可带病通过)
|
||||
const missed = PREREQS.filter((p) => !prereqChecked.value[p.key]);
|
||||
R.push(missed.length
|
||||
? { no: 2, name: "前置条件全勾选", level: "warn", detail: "尚有 " + missed.length + " 项未勾选:" + missed.map((m) => m.label).join(";"), advice: "回到第 ② 步补勾,优先 Redis 公共组件与 trust-registry" }
|
||||
: { no: 2, name: "前置条件全勾选", level: "ok", detail: PREREQS.length + " 项前置条件已确认" });
|
||||
// 3 登记行存在且地址合规(必须通过)
|
||||
const regBad = regCheck.value.filter((c) => c.level === "bad");
|
||||
const regWarn = regCheck.value.filter((c) => c.level === "warn");
|
||||
if (!regCheckedAt.value) {
|
||||
R.push({ no: 3, name: "登记行存在且地址合规", level: "warn", detail: "尚未在第 ③ 步执行「检查注册」", advice: "回第 ③ 步点「检查注册」" });
|
||||
} else if (regBad.length) {
|
||||
R.push({ no: 3, name: "登记行存在且地址合规", level: "bad", detail: regBad.map((c) => c.text).join(";"), advice: regBad[0].advice });
|
||||
} else {
|
||||
R.push({ no: 3, name: "登记行存在且地址合规", level: regWarn.length ? "warn" : "ok", detail: regWarn.length ? "地址合规,但有 " + regWarn.length + " 条告警:" + regWarn.map((c) => c.text).join(";") : appRegs.value.length + " 条登记全部合规", advice: regWarn[0]?.advice });
|
||||
}
|
||||
// 4 探测可达且验签通过(黄色可带病通过)
|
||||
if (!probeResult.value) {
|
||||
R.push({ no: 4, name: "探测可达且验签通过", level: "warn", detail: "尚未执行探测", advice: "回第 ③ 步点「发一条 flow.ping」" });
|
||||
} else if (probeResult.value.success) {
|
||||
R.push({ no: 4, name: "探测可达且验签通过", level: "ok", detail: "HTTP " + probeResult.value.httpStatus + " · 耗时 " + probeResult.value.elapsedMs + "ms" });
|
||||
} else {
|
||||
R.push({ no: 4, name: "探测可达且验签通过", level: "warn", detail: "探测失败:" + (probeResult.value.error || "HTTP " + (probeResult.value.httpStatus ?? "?")), advice: "401 → 查 base-path / trust-registry;拒连 → 查登记地址" });
|
||||
}
|
||||
// 5 有已发布流程(必须通过)
|
||||
R.push(hasPublished.value
|
||||
? { no: 5, name: "有已发布流程", level: "ok", detail: defs.value.filter((d: any) => d.status === 1).length + " 个已发布流程" }
|
||||
: { no: 5, name: "有已发布流程", level: "bad", detail: defs.value.length ? "仅有草稿/停用流程" : "该应用无任何流程定义", advice: "回第 ④ 步:发布流程后再回填绑定" });
|
||||
// 6 近 7 日推送有成功记录(无数据不扣分)
|
||||
try {
|
||||
const page: any = await flowApi.callbackLogs({ status: 1, size: 1 }).send();
|
||||
const total = page?.totalCount ?? page?.total ?? 0;
|
||||
R.push(total > 0
|
||||
? { no: 6, name: "近 7 日推送有成功记录", level: "ok", detail: "累计成功推送 " + total + " 条" }
|
||||
: { no: 6, name: "近 7 日推送有成功记录", level: "warn", detail: "暂无成功推送记录(可能还没发起过办件,可跳过)" });
|
||||
} catch {
|
||||
R.push({ no: 6, name: "近 7 日推送有成功记录", level: "warn", detail: "推送日志拉取失败,跳过该项" });
|
||||
}
|
||||
accept.value = R;
|
||||
acceptDone.value = true;
|
||||
} finally {
|
||||
acceptLoading.value = false;
|
||||
}
|
||||
}
|
||||
/** 一键生成接入自检报告(纯前端拼装,不落库;可直接复制进工单) */
|
||||
const reportVisible = ref(false);
|
||||
const reportText = computed(() => {
|
||||
const app = apps.value.find((a: any) => String(a.appKey) === String(s1.value.appKey));
|
||||
const L: string[] = [];
|
||||
L.push("# 子应用接入流程中心 · 自检报告");
|
||||
L.push("");
|
||||
L.push("- 生成时间:" + new Date().toLocaleString());
|
||||
L.push("- 应用:" + (app ? app.appName + "(" + app.appKey + ")" : s1.value.appKey || "未选择"));
|
||||
L.push("- 网关 target:" + (s1.value.target || "未填(缺省取 base-path)"));
|
||||
L.push("- context-path:" + (s1.value.ctx || "无"));
|
||||
L.push("- bizType:" + (s1.value.bizTypes || "未填"));
|
||||
L.push("- 绑定档位:" + (BIND_LEVELS.find((b) => b.value === s1.value.bindLevel)?.label || "-"));
|
||||
L.push("- 接入口径:" + (s2.value.endpointMode === "xflw" ? "注册级 X-Flw 式(式 B)" : "平台验签式(式 A)"));
|
||||
L.push("");
|
||||
L.push("## 六项自检结果");
|
||||
L.push("");
|
||||
if (!acceptDone.value) {
|
||||
L.push("_尚未执行自检,请先点「执行六项自检」。_");
|
||||
} else {
|
||||
const icon = { ok: "✅", warn: "⚠️", bad: "❌", none: "➖" };
|
||||
accept.value.forEach((r) => {
|
||||
L.push(icon[r.level] + " **" + r.no + ". " + r.name + "** —— " + r.detail);
|
||||
if (r.advice) L.push(" - 建议:" + r.advice);
|
||||
});
|
||||
}
|
||||
L.push("");
|
||||
L.push("## 生成的配置块");
|
||||
L.push("");
|
||||
L.push("```yaml");
|
||||
L.push(generatedYaml.value);
|
||||
L.push("```");
|
||||
L.push("");
|
||||
L.push("## 前置条件确认");
|
||||
L.push("");
|
||||
PREREQS.forEach((p) => L.push((prereqChecked.value[p.key] ? "- [x]" : "- [ ]") + " " + p.label + (p.danger ? "(高危断点)" : "")));
|
||||
L.push("");
|
||||
L.push("## 登记行");
|
||||
L.push("");
|
||||
if (!appRegs.value.length) L.push("_无登记行_");
|
||||
else {
|
||||
L.push("| 流程 GUID | 回调地址 | base_path | 状态 |");
|
||||
L.push("|---|---|---|---|");
|
||||
appRegs.value.forEach((r: any) => L.push("| " + (r.flowGuid || "-") + " | " + (r.callbackUrl || "-") + " | " + (r.basePath || "-") + " | " + (r.status === 0 ? "启用" : "停用") + " |"));
|
||||
}
|
||||
L.push("");
|
||||
L.push("## 条件路由变量契约");
|
||||
L.push("");
|
||||
if (!condNodes.value.length) L.push("_所选流程无 CONDITION 节点_");
|
||||
else {
|
||||
condNodes.value.forEach((n) => {
|
||||
L.push("### " + n.name + "(" + (n.judgeMode === "EXPRESSION" ? "表达式" : "外部判断服务") + (n.routerKey ? " · routerKey=" + n.routerKey : "") + ")");
|
||||
n.outlets.forEach((o) => {
|
||||
L.push("- 出口「" + o.name + "」:" + (o.expr || "(无表达式)"));
|
||||
if (o.unknown.length) L.push(" - ⚠️ 引用了未在已知变量中的:" + o.unknown.join("、") + "(疑似拼错,运行时会挂起)");
|
||||
});
|
||||
});
|
||||
}
|
||||
return L.join("\n");
|
||||
});
|
||||
|
||||
async function copyText(t: string, ok = "已复制") {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(t);
|
||||
Message.success(ok);
|
||||
return;
|
||||
}
|
||||
await Utils.copyToClipboard(t);
|
||||
Message.success(ok);
|
||||
} catch {
|
||||
Message.error("复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
try {
|
||||
apps.value = ((await flowApi.appList().send()) as any[]) || [];
|
||||
appMap.value = {};
|
||||
apps.value.forEach((a: any) => { if (a.appKey) appMap.value[a.appKey] = a.appName; });
|
||||
} catch { /* 应用列表拉取失败:第 ① 步会给红字提示 */ }
|
||||
await loadKnownVars();
|
||||
});
|
||||
|
||||
/** 进入某步时的按需装载 */
|
||||
async function goto(n: number) {
|
||||
step.value = n;
|
||||
if (n === 2) await loadRegs();
|
||||
if (n === 3) await loadDefs();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container ob-wrap">
|
||||
<!-- 顶栏 -->
|
||||
<div class="ob-head fix">
|
||||
<span class="fl ob-title">接入向导</span>
|
||||
<span class="fl ob-sub">把「子应用接入流程中心」从人背文档变成向导引导 + 机器生成正确配置</span>
|
||||
<Button class="fr" type="primary" size="large" @click="reportVisible = true">生成接入自检报告</Button>
|
||||
</div>
|
||||
|
||||
<!-- 步骤条(可任意跳转,不强制线性) -->
|
||||
<div class="ob-steps">
|
||||
<div v-for="(s, i) in STEPS" :key="i" class="ob-step" :class="{ 'is-cur': step === i, 'is-done': i < step }" @click="goto(i)">
|
||||
<span class="ob-num">{{ i + 1 }}</span>
|
||||
<span class="ob-st">
|
||||
<b>{{ s.title }}</b>
|
||||
<em>{{ s.hint }}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STEP① 应用档案 ============ -->
|
||||
<div v-show="step === 0" class="ob-body">
|
||||
<div class="ob-card">
|
||||
<h3>我是谁</h3>
|
||||
<p class="ob-desc">这四个值是后续所有生成器的输入;context-path 是 §4.2 高频事故点,请如实填写。</p>
|
||||
<div class="ob-form">
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">应用<i>*</i></label>
|
||||
<Select v-model="s1.appKey" style="width: 320px" placeholder="选择已开通的应用" @on-change="loadDefs">
|
||||
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey" :label="a.appName + '(' + a.appKey + ')'" />
|
||||
</Select>
|
||||
<span v-if="!apps.length" class="ob-err">应用清单拉取失败或尚未开通 —— 请先在用户中心开通该应用</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">网关 target</label>
|
||||
<Input v-model="s1.target" style="width: 320px" placeholder="如 notice;可留空,缺省取 base-path" />
|
||||
<span class="ob-tip">网关路由首段;契约上与 base-path 恒等,通常留空即可</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">context-path(base-path)</label>
|
||||
<Input v-model="s1.ctx" style="width: 320px" placeholder="如 /notice;本地无 ctx 留空" />
|
||||
<span class="ob-warn-txt">⚠ 填错/漏填 ⇒ 推送全 401 无限重试,症状表现为「收不到回调」</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">bizType</label>
|
||||
<Input v-model="s1.bizTypes" style="width: 320px" placeholder="如 notice;多个用逗号分隔" />
|
||||
<span class="ob-tip">发起时声明的业务关联对;回调按它路由到单据行</span>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">绑定档位</label>
|
||||
<RadioGroup v-model="s1.bindLevel" type="button">
|
||||
<Radio v-for="b in BIND_LEVELS" :key="b.value" :label="b.value">{{ b.label }}</Radio>
|
||||
</RadioGroup>
|
||||
<span class="ob-tip">{{ (BIND_LEVELS.find((b) => b.value === s1.bindLevel) || {}).desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STEP② 工程接入(配置生成器) ============ -->
|
||||
<div v-show="step === 1" class="ob-body">
|
||||
<div class="ob-card">
|
||||
<h3>端点形态与接入场景</h3>
|
||||
<div class="ob-form">
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">端点形态<i>*</i></label>
|
||||
<RadioGroup v-model="s2.endpointMode">
|
||||
<Radio label="platform">平台验签式(式 A,推荐)</Radio>
|
||||
<Radio label="xflw">注册级 X-Flw 式(式 B)</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div class="ob-row">
|
||||
<label class="ob-lab">接入场景<i>*</i></label>
|
||||
<RadioGroup v-model="s2.scenario">
|
||||
<Radio label="new">新接入</Radio>
|
||||
<Radio label="upgrade">存量升级(已接回调)</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-card">
|
||||
<h3>A · Maven 依赖</h3>
|
||||
<pre class="ob-code" :class="{ 'is-warn': s2.scenario === 'upgrade' }">{{ generatedPom }}</pre>
|
||||
<Button size="small" class="ob-copy" @click="copyText(generatedPom, '依赖块已复制')">复制</Button>
|
||||
</div>
|
||||
|
||||
<div class="ob-card">
|
||||
<h3>B · yh.flow-callback 配置块(自动生成)</h3>
|
||||
<p class="ob-desc">按上方选择 + 第 ① 步输入实时生成,形态对齐规范 §7.2 推导式。</p>
|
||||
<pre class="ob-code">{{ generatedYaml }}</pre>
|
||||
<Button size="small" class="ob-copy" @click="copyText(generatedYaml, '配置块已复制')">复制</Button>
|
||||
<div v-if="s2.endpointMode === 'xflw'" class="ob-alert">
|
||||
⚠ X-Flw 式端点第二段非 <code>oapi</code>,网关 TargetRouter 裸 404,<b>必须显式配 callback-url 直连</b>(规范 §7.5)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-card">
|
||||
<h3>C · FlowCallbackHandler 骨架</h3>
|
||||
<pre class="ob-code">{{ generatedHandler }}</pre>
|
||||
<Button size="small" class="ob-copy" @click="copyText(generatedHandler, 'Handler 骨架已复制')">复制</Button>
|
||||
</div>
|
||||
|
||||
<div class="ob-card">
|
||||
<h3>D · 前置条件清单</h3>
|
||||
<p class="ob-desc">把散落文档里的前置条件变成可勾选待办;红底两项是 work-leave 复盘出的高危静默断点。</p>
|
||||
<div class="ob-prereqs">
|
||||
<label v-for="p in PREREQS" :key="p.key" class="ob-prereq" :class="{ 'is-danger': p.danger }">
|
||||
<Checkbox v-model="prereqChecked[p.key]" />
|
||||
<span class="ob-prereq-t">{{ p.label }}</span>
|
||||
<span class="ob-prereq-tip">{{ p.tip }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ob-row" style="margin-top: 14px">
|
||||
<Checkbox v-model="s2.configDone" />
|
||||
<span style="margin-left: 8px">我已完成上述配置部署</span>
|
||||
<span class="ob-tip" style="margin-left: 12px">计入第 ⑤ 步判分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ob-card">
|
||||
<h3>E · 避坑速查(规范 §10)</h3>
|
||||
<Collapse class="ob-pit">
|
||||
<Panel name="1">
|
||||
接入前必读的 10 条坑
|
||||
<template #content>
|
||||
<ol class="ob-pit-list">
|
||||
<li><b>basePath 401</b>:填错/漏填 ⇒ 推送必 401 无限重试。</li>
|
||||
<li><b>重复注册轮换 secret</b>:本地镜像行有效就别再 reg。</li>
|
||||
<li><b>注册探活</b>:接收端点必须先上线再注册。</li>
|
||||
<li><b>4xx 也无限重试</b>:流程中心只认 2xx,脏消息必须 200 吞掉。</li>
|
||||
<li><b>验签用原始字节</b>:先反序列化再序列化会验签失败。</li>
|
||||
<li><b>事件行与业务同事务</b>:拆开会导致重推被幂等吞掉丢单。</li>
|
||||
<li><b>乱序是常态</b>:靠 instanceVersion 门 + 终态不可逆守卫。</li>
|
||||
<li><b>bizId 是字符串</b>:无论数值与否都用字符串处理。</li>
|
||||
<li><b>地址推导与漂移</b>:callback-url 可省略;reconcile-on-register 默认关。</li>
|
||||
<li><b>待办在流程中心</b>:子应用不维护待办表,用 progress/instance 接口拉。</li>
|
||||
</ol>
|
||||
</template>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STEP③ 回调注册 ============ -->
|
||||
<div v-show="step === 2" class="ob-body">
|
||||
<div class="ob-card">
|
||||
<div class="fix">
|
||||
<h3 class="fl">登记行({{ appRegs.length }} 条)</h3>
|
||||
<span class="fr">
|
||||
<Button size="small" :loading="regLoading" @click="loadRegs">刷新</Button>
|
||||
<Button size="small" type="primary" style="margin-left: 8px" @click="checkRegs">检查注册</Button>
|
||||
<Button size="small" style="margin-left: 8px" :loading="probing" @click="probeFirst">发一条 flow.ping</Button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="ob-desc">
|
||||
登记由子应用在「管理端保存绑定」时调 <code>ensureRegistered</code> 完成,向导只检查不代注册。
|
||||
<span v-if="regCheckedAt">上次检查:{{ regCheckedAt }}</span>
|
||||
</p>
|
||||
<Table :columns="[
|
||||
{ title: '流程 GUID', key: 'flowGuid', minWidth: 200 },
|
||||
{ title: '回调地址', key: 'callbackUrl', minWidth: 240 },
|
||||
{ title: 'base_path', key: 'basePath', width: 110, render: (h: any, p: any) => h('span', p.row.basePath || '(空)') },
|
||||
{ title: '状态', width: 80, align: 'center', render: (h: any, p: any) => h(Tag, { color: p.row.status === 0 ? 'green' : 'gray' }, () => p.row.status === 0 ? '启用' : '停用') },
|
||||
]" :data="appRegs" :loading="regLoading" size="small" max-height="260"
|
||||
no-data-text="该应用暂无登记行(未接入:回第②步配置后,在管理端保存绑定触发注册)" />
|
||||
<div v-if="regCheck.length" class="ob-results">
|
||||
<div v-for="(c, i) in regCheck" :key="i" class="ob-result" :class="'is-' + c.level">
|
||||
<b>{{ c.level === 'ok' ? '✓ 通过' : c.level === 'warn' ? '⚠ 告警' : '✗ 失败' }}</b>
|
||||
<span>{{ c.text }}</span>
|
||||
<em v-if="c.advice">建议:{{ c.advice }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="probeResult" class="ob-result" :class="probeResult.success ? 'is-ok' : 'is-warn'">
|
||||
<b>{{ probeResult.success ? '✓ 探测成功' : '⚠ 探测失败' }}</b>
|
||||
<span>HTTP {{ probeResult.httpStatus ?? '?' }} · 耗时 {{ probeResult.elapsedMs ?? '?' }} ms<span v-if="probeResult.respSnippet"> · 响应片段:{{ probeResult.respSnippet }}</span><span v-if="probeResult.error"> · {{ probeResult.error }}</span></span>
|
||||
<em v-if="!probeResult.success">401 → 查 base-path 与 trust-registry;拒连/未知主机 → 查登记地址是否网关形态</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STEP④ 流程编排 ============ -->
|
||||
<div v-show="step === 3" class="ob-body">
|
||||
<div class="ob-card">
|
||||
<h3>流程定义</h3>
|
||||
<p class="ob-desc">至少需要一个「已发布」流程;发起时用它的 flowGuid。</p>
|
||||
<Table :columns="[
|
||||
{ title: '流程', key: 'name', minWidth: 180 },
|
||||
{ title: 'flowGuid', key: 'flowGuid', minWidth: 200 },
|
||||
{ title: '状态', width: 90, align: 'center', render: (h: any, p: any) => h(Tag, { color: p.row.status === 1 ? 'green' : p.row.status === 0 ? 'orange' : 'gray' }, () => DEF_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: '操作', width: 90, align: 'center', render: (h: any, p: any) => h(Button, { type: 'text', size: 'small', onClick: () => inspectDef(p.row.id) }, () => '查看条件') },
|
||||
]" :data="defs" :loading="defLoading" size="small" max-height="220"
|
||||
no-data-text="该应用无流程定义(先回第①步确认应用,或去流程定义页新建)" />
|
||||
</div>
|
||||
|
||||
<div class="ob-card" v-if="condNodes.length">
|
||||
<h3>条件路由变量契约</h3>
|
||||
<p class="ob-desc">
|
||||
EXPRESSION 模式的判断依据就是子应用 apply 时 <code>formData</code> 的顶层键。这里把表达式引用的变量与
|
||||
已知键(近 50 条办件 formData 聚合 + 内置 8 个)核对 —— 引用了从未出现的键,运行时会<b>挂起</b>。
|
||||
</p>
|
||||
<div v-for="(n, i) in condNodes" :key="i" class="ob-cond">
|
||||
<div class="ob-cond-h">
|
||||
<b>{{ n.name }}</b>
|
||||
<Tag :color="n.judgeMode === 'EXPRESSION' ? 'blue' : 'purple'" style="margin-left: 8px">
|
||||
{{ n.judgeMode === 'EXPRESSION' ? '表达式' : '外部判断服务' }}
|
||||
</Tag>
|
||||
<span v-if="n.routerKey" class="ob-cond-k">routerKey = {{ n.routerKey }}</span>
|
||||
</div>
|
||||
<div v-for="(o, j) in n.outlets" :key="j" class="ob-cond-o">
|
||||
<span class="ob-cond-on">出口「{{ o.name }}」</span>
|
||||
<code class="ob-cond-e">{{ o.expr || '(无表达式)' }}</code>
|
||||
<span v-if="o.unknown.length" class="ob-cond-u">⚠ 引用了未在已知变量中的:<b>{{ o.unknown.join('、') }}</b> —— 疑似拼错,运行时会挂起</span>
|
||||
<span v-else-if="o.expr" class="ob-cond-ok">✓ 变量均在已知集合内</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ob-card" v-else-if="selectedDefId">
|
||||
<p class="ob-desc">所选流程没有 CONDITION 节点(无需变量契约核对)。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ STEP⑤ 联调验收 ============ -->
|
||||
<div v-show="step === 4" class="ob-body">
|
||||
<div class="ob-card">
|
||||
<div class="fix">
|
||||
<h3 class="fl">六项自检</h3>
|
||||
<Button class="fr" size="small" type="primary" :loading="acceptLoading" @click="runAcceptance">执行六项自检</Button>
|
||||
</div>
|
||||
<p class="ob-desc">把「手册的诊断命令集」变成按钮。1/3/5 必须通过;2/4 黄色可带病通过;6 无数据不扣分。</p>
|
||||
<div v-if="!acceptDone" class="ob-empty">尚未执行自检</div>
|
||||
<div v-else class="ob-results">
|
||||
<div v-for="r in accept" :key="r.no" class="ob-result" :class="'is-' + r.level">
|
||||
<b>{{ r.no }}. {{ r.name }} —— {{ r.level === 'ok' ? '✓ 通过' : r.level === 'warn' ? '⚠ 关注' : '✗ 失败' }}</b>
|
||||
<span>{{ r.detail }}</span>
|
||||
<em v-if="r.advice">建议:{{ r.advice }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作条 -->
|
||||
<div class="ob-foot">
|
||||
<Button size="large" :disabled="step === 0" @click="step > 0 && step--">上一步</Button>
|
||||
<span class="ob-foot-r">
|
||||
<span v-if="step === 1 && !prereqAllDone" class="ob-foot-hint">尚有前置条件未勾选,仍可继续</span>
|
||||
<span v-if="step === 3 && !hasPublished" class="ob-foot-hint">尚无已发布流程,联调验收第 5 项会失败</span>
|
||||
<Button size="large" v-if="step < 4" type="primary" @click="goto(step + 1)">下一步</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 接入自检报告 -->
|
||||
<Drawer v-model="reportVisible" title="接入自检报告" :width="720" :mask-closable="false">
|
||||
<p class="ob-desc" style="margin-bottom: 10px">纯前端拼装、不落库。可直接复制进工单或群。</p>
|
||||
<pre class="ob-code ob-report">{{ reportText }}</pre>
|
||||
<template #footer>
|
||||
<Button @click="reportVisible = false">关闭</Button>
|
||||
<Button type="primary" @click="copyText(reportText.value, '报告已复制')">复制报告</Button>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ob-wrap { display: flex; flex-direction: column; padding: 16px 20px; overflow: auto; }
|
||||
.ob-head { margin-bottom: 4px; }
|
||||
.ob-title { font-size: 18px; font-weight: 600; color: #17233d; }
|
||||
.ob-sub { margin-left: 14px; color: #808695; font-size: 12px; line-height: 34px; }
|
||||
|
||||
/* 步骤条 */
|
||||
.ob-steps { display: flex; gap: 8px; margin: 14px 0 18px; }
|
||||
.ob-step { flex: 1; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #e5e6eb; border-radius: 4px; cursor: pointer; background: #fff; transition: all .15s; }
|
||||
.ob-step:hover { border-color: #2d8cf0; }
|
||||
.ob-step.is-cur { border-color: #2d8cf0; background: #f0f7ff; }
|
||||
.ob-step.is-done { opacity: .75; }
|
||||
.ob-num { flex: none; width: 24px; height: 24px; border-radius: 50%; background: #e5e6eb; color: #515a6e; text-align: center; line-height: 24px; font-weight: 600; font-size: 13px; }
|
||||
.ob-step.is-cur .ob-num { background: #2d8cf0; color: #fff; }
|
||||
.ob-step.is-done .ob-num { background: #19a35a; color: #fff; }
|
||||
.ob-st { display: flex; flex-direction: column; line-height: 1.35; min-width: 0; }
|
||||
.ob-st b { font-size: 13px; color: #17233d; }
|
||||
.ob-st em { font-style: normal; font-size: 11px; color: #98a1b3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.ob-body { overflow: auto; }
|
||||
.ob-card { background: #fff; border: 1px solid #e5e6eb; border-radius: 4px; padding: 16px 18px; margin-bottom: 14px; }
|
||||
.ob-card h3 { font-size: 14px; color: #17233d; margin-bottom: 4px; }
|
||||
.ob-desc { color: #808695; font-size: 12px; line-height: 1.7; margin-bottom: 12px; }
|
||||
.ob-desc code { background: #f4f6f9; padding: 1px 4px; border-radius: 3px; font-family: Consolas, Menlo, monospace; }
|
||||
|
||||
.ob-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.ob-row { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
|
||||
.ob-lab { flex: none; width: 168px; text-align: right; line-height: 32px; color: #515a6e; font-size: 13px; }
|
||||
.ob-lab i { color: #e03e3e; font-style: normal; margin-left: 2px; }
|
||||
.ob-tip { line-height: 32px; color: #98a1b3; font-size: 12px; }
|
||||
.ob-warn-txt { line-height: 32px; color: #d98106; font-size: 12px; }
|
||||
.ob-err { line-height: 32px; color: #e03e3e; font-size: 12px; }
|
||||
|
||||
.ob-code { position: relative; background: #f7f8fa; border: 1px solid #e5e6eb; border-radius: 4px; padding: 12px 14px; font-family: Consolas, Menlo, monospace; font-size: 12px; line-height: 1.6; color: #314659; white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||||
.ob-code.is-warn { background: #fff8e6; border-color: #f0d9a0; }
|
||||
.ob-copy { margin-top: 8px; }
|
||||
.ob-report { max-height: 60vh; overflow: auto; }
|
||||
.ob-alert { margin-top: 10px; background: #fff5e6; border: 1px solid #f0d9a0; border-radius: 4px; padding: 8px 12px; color: #b26a00; font-size: 12px; line-height: 1.6; }
|
||||
.ob-alert code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; }
|
||||
|
||||
.ob-prereqs { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ob-prereq { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border: 1px solid #e5e6eb; border-radius: 4px; }
|
||||
.ob-prereq.is-danger { border-color: #f5c2c2; background: #fff5f5; }
|
||||
.ob-prereq-t { font-size: 13px; color: #314659; line-height: 20px; }
|
||||
.ob-prereq-tip { font-size: 11px; color: #98a1b3; line-height: 20px; margin-left: auto; text-align: right; max-width: 46%; }
|
||||
.ob-pit-list { padding-left: 20px; color: #515a6e; font-size: 12px; line-height: 1.9; }
|
||||
.ob-pit-list b { color: #314659; }
|
||||
|
||||
/* 检查结果 */
|
||||
.ob-results { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.ob-result { border-radius: 4px; padding: 10px 12px; font-size: 12px; line-height: 1.7; border: 1px solid #e5e6eb; background: #f7f8fa; }
|
||||
.ob-result b { display: block; margin-bottom: 2px; }
|
||||
.ob-result em { display: block; font-style: normal; color: #808695; margin-top: 2px; }
|
||||
.ob-result.is-ok { background: #eefaf2; border-color: #b7e4c7; }
|
||||
.ob-result.is-ok b { color: #19a35a; }
|
||||
.ob-result.is-warn { background: #fff8e6; border-color: #f0d9a0; }
|
||||
.ob-result.is-warn b { color: #d98106; }
|
||||
.ob-result.is-bad { background: #fdecec; border-color: #f5c2c2; }
|
||||
.ob-result.is-bad b { color: #e03e3e; }
|
||||
.ob-empty { color: #98a1b3; font-size: 12px; text-align: center; padding: 30px 0; }
|
||||
|
||||
/* 条件契约 */
|
||||
.ob-cond { border: 1px solid #e5e6eb; border-radius: 4px; padding: 12px 14px; margin-bottom: 10px; }
|
||||
.ob-cond-h { display: flex; align-items: center; margin-bottom: 8px; }
|
||||
.ob-cond-h b { font-size: 13px; color: #17233d; }
|
||||
.ob-cond-k { margin-left: auto; color: #98a1b3; font-size: 11px; font-family: Consolas, Menlo, monospace; }
|
||||
.ob-cond-o { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 4px 0; font-size: 12px; }
|
||||
.ob-cond-on { color: #515a6e; flex: none; }
|
||||
.ob-cond-e { background: #f4f6f9; padding: 2px 6px; border-radius: 3px; font-family: Consolas, Menlo, monospace; color: #314659; }
|
||||
.ob-cond-u { color: #d98106; }
|
||||
.ob-cond-ok { color: #19a35a; }
|
||||
|
||||
.ob-foot { display: flex; align-items: center; justify-content: space-between; padding: 12px 0 4px; border-top: 1px solid #e5e6eb; margin-top: auto; }
|
||||
.ob-foot-r { display: flex; align-items: center; gap: 12px; }
|
||||
.ob-foot-hint { color: #d98106; font-size: 12px; }
|
||||
</style>
|
||||
@@ -3,20 +3,30 @@
|
||||
// CONDITION 节点的 routerKey 必须命中启用中的注册(发布校验口径);basePath 是对端 context-path,
|
||||
// 漏填必 401(规范 §4.2 同款坑)——生产 notice 为 /notice,本地为空。
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
|
||||
// 无搜索分页脚:表格高度实测 .table-region(flex:1)
|
||||
const routerRegionRef = ref<HTMLElement | null>(null);
|
||||
const routersH = useElementHeight(routerRegionRef);
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "注册键", key: "routerKey", width: 220 },
|
||||
{ title: "名称", key: "name", width: 160 },
|
||||
{ title: "服务目录键", key: "serviceKey", width: 110 },
|
||||
{
|
||||
// 2026-10-12 SERVICE 收口:reg_app_key 非空=yh-flow-callback starter 自注册行(归属应用)
|
||||
title: "来源",
|
||||
width: 160,
|
||||
render: (h: any, p: any) => h("span", p.row.regAppKey ? `自注册 · ${p.row.regAppKey}` : "手工"),
|
||||
},
|
||||
{ title: "判断端点", key: "path", minWidth: 300 },
|
||||
{
|
||||
title: "basePath",
|
||||
@@ -28,21 +38,16 @@ const columns = [
|
||||
width: 90,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
h("Tag", { color: p.row.status === 1 ? "green" : "gray" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||
h(Tag, { color: p.row.status === 1 ? "green" : "gray" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 150,
|
||||
align: "center",
|
||||
fixed: "right",
|
||||
render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)),
|
||||
h("span", { style: "display:inline-block;width:10px" }),
|
||||
p.row.status === 1
|
||||
? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
|
||||
: Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)),
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r) },
|
||||
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onDisable(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
|
||||
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status === 1 },
|
||||
],
|
||||
},
|
||||
{ mode: "text", width: 150, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
async function load() {
|
||||
@@ -131,14 +136,22 @@ function onEnable(row: any) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 表达式调试(2026-10-10 四议题;2026-10-12 议题三改为三模式共享弹窗 ExprEvalModal)----
|
||||
const exprVisible = ref(false);
|
||||
|
||||
function onEval() {
|
||||
exprVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper">
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onAdd">新增注册</Button>
|
||||
<Button class="fl" size="large" style="margin-left: 8px" @click="onEval">表达式调试</Button>
|
||||
</div>
|
||||
|
||||
<div class="search-wrap fix">
|
||||
@@ -148,8 +161,10 @@ onMounted(load);
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-region" ref="routerRegionRef">
|
||||
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="tableHeight" no-data-text="暂无数据" />
|
||||
:height="routersH || undefined" no-data-text="暂无数据" />
|
||||
</div>
|
||||
|
||||
<Modal v-model="saveVisible" title="新增/编辑判断服务" :mask-closable="false">
|
||||
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="100">
|
||||
@@ -177,6 +192,16 @@ onMounted(load);
|
||||
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<ExprEvalModal v-model="exprVisible" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 无分页脚:通道只对含 .page-panel 的卡片自动转纵向分栏,本页自声明保证 table-region 撑满 */
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup lang="ts">
|
||||
// 应用使用许可管理页(E6,2026-10-28):flw_usage_grant 的独立授权面。
|
||||
// 后端授权闸门(fail-closed)已生效,此前无前端入口,管理员无法授予/撤销 —— 本页补上入口,接口全部复用既有 /admin/usage-grant/*。
|
||||
// 数据源恒为 grantSchoolList()(校域由管理员会话派生,**没有也不该有 schId 入参**);筛选只做客户端二次过滤,
|
||||
// 因为取数本身已是服务端校域范围,不再假装"按流程线服务端分页"。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Message, Tag, Button } from "view-ui-plus";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
import { flowFailTip, flowSubmitFailTip } from "@/utils/flowError";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
/** 清单加载失败的原因(非空即"没读到",不得渲染成空表冒充成功) */
|
||||
const loadError = ref("");
|
||||
|
||||
// 流程线下拉/名称回显(defList 全量版本 → 按 flowGuid 去重,取最高版本的名称)
|
||||
const flows = ref<{ flowGuid: string; name: string; code: string }[]>([]);
|
||||
const flowNameMap = ref<Record<string, string>>({});
|
||||
const flowsError = ref("");
|
||||
|
||||
// 授予弹窗的应用下拉:本校已开启应用白名单(flw_app enabled=1);
|
||||
// 拉取失败时保持下拉禁用(后端会把 appKey 解析成 UserBase 稳定主体并校验校内开通,手填必然被拒)
|
||||
const apps = ref<{ appKey: string; appName: string }[]>([]);
|
||||
const appsError = ref("");
|
||||
|
||||
const filterFlow = ref("");
|
||||
const filterApp = ref("");
|
||||
/** "" = 全部;"1" = 生效;"0" = 已撤销 */
|
||||
const filterStatus = ref("");
|
||||
|
||||
const regionRef = ref<HTMLElement | null>(null);
|
||||
const tableH = useElementHeight(regionRef);
|
||||
|
||||
/** 更新时间格式化 YYYY-MM-DD HH:mm(后端 LocalDateTime 带 T,空值显示 -) */
|
||||
function fmtTime(raw: any): string {
|
||||
return raw ? String(raw).replace("T", " ").slice(0, 16) : "-";
|
||||
}
|
||||
|
||||
/** 流程线名称:定义清单没加载成功时不猜名字,直接说明口径缺失 */
|
||||
function flowNameOf(guid: string): string {
|
||||
const n = flowNameMap.value[String(guid || "")];
|
||||
if (n) return n;
|
||||
return flowsError.value ? "名称未加载(流程清单获取失败)" : "(流程定义不存在或已改名)";
|
||||
}
|
||||
|
||||
/** 应用展示名:快照优先(grant 当时写入的 appNameSnapshot),缺省回退 appKeySnapshot */
|
||||
function appLabelOf(row: any): string {
|
||||
return row.appNameSnapshot || row.appKeySnapshot || "-";
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
return rows.value.filter((r: any) => {
|
||||
if (filterFlow.value && String(r.flowGuid || "") !== filterFlow.value) return false;
|
||||
if (filterApp.value && String(r.appKeySnapshot || "") !== filterApp.value) return false;
|
||||
if (filterStatus.value !== "" && Number(r.enabled) !== Number(filterStatus.value)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/** 应用筛选项:按已取回清单里的 appKeySnapshot 去重(含已停用/已撤销的历史快照,不只本校当前白名单) */
|
||||
const appFilterOptions = computed(() => {
|
||||
const seen = new Map<string, string>();
|
||||
rows.value.forEach((r: any) => {
|
||||
const k = String(r.appKeySnapshot || "");
|
||||
if (k && !seen.has(k)) seen.set(k, r.appNameSnapshot || k);
|
||||
});
|
||||
return Array.from(seen.entries()).map(([appKey, appName]) => ({ appKey, appName }));
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{
|
||||
title: "流程线",
|
||||
minWidth: 260,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "ug-cell2" }, [
|
||||
h("span", { class: "ug-cell-n" }, flowNameOf(p.row.flowGuid)),
|
||||
h("span", { class: "ug-cell-k" }, p.row.flowGuid || "—"),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "应用",
|
||||
minWidth: 200,
|
||||
render: (h: any, p: any) =>
|
||||
h("div", { class: "ug-cell2" }, [
|
||||
h("span", { class: "ug-cell-n" }, appLabelOf(p.row)),
|
||||
h("span", { class: "ug-cell-k" }, p.row.appKeySnapshot || "-"),
|
||||
]),
|
||||
},
|
||||
// 稳定主体(UserBase appId):撤销按它下刀,appKey 换凭据不影响已授予的许可
|
||||
{ title: "稳定主体", width: 110, align: "center", render: (h: any, p: any) => h("span", p.row.appId == null ? "-" : String(p.row.appId)) },
|
||||
{
|
||||
title: "状态",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (h: any, p: any) =>
|
||||
Number(p.row.enabled) === 1 ? h(Tag, { color: "green" }, () => "生效") : h(Tag, () => "已撤销"),
|
||||
},
|
||||
{
|
||||
title: "备注",
|
||||
minWidth: 180,
|
||||
render: (h: any, p: any) => (p.row.remark
|
||||
? h("span", String(p.row.remark))
|
||||
: h("span", { style: "color:#999" }, "(未填写)")),
|
||||
},
|
||||
{ title: "更新时间", width: 150, render: (h: any, p: any) => h("span", fmtTime(p.row.updateTime || p.row.createTime)) },
|
||||
actionColumn(
|
||||
[
|
||||
{ key: "revoke", label: "撤销", icon: "icon-stop", on: (r: any) => onRevoke(r), when: (r: any) => Number(r.enabled) === 1, danger: true },
|
||||
// 已撤销行保留在清单里(enabled=0 不删行),给一条原地重申的通路,免得管理员去猜 appKey
|
||||
{ key: "regrant", label: "重新授予", icon: "icon-play", on: (r: any) => onRegrant(r), when: (r: any) => Number(r.enabled) !== 1 },
|
||||
],
|
||||
{ mode: "text", width: 130, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
rows.value = ((await flowApi.grantSchoolList().send()) as any[]) || [];
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
loadError.value = flowFailTip("使用许可清单加载", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 流程线清单(下拉 + 名称回显):失败置 flowsError,下拉留空并说明原因 */
|
||||
async function loadFlows() {
|
||||
flowsError.value = "";
|
||||
try {
|
||||
const list: any[] = (await flowApi.defList().send()) || [];
|
||||
const best: Record<string, any> = {};
|
||||
list.forEach((r: any) => {
|
||||
const g = String(r.flowGuid || "");
|
||||
if (!g) return;
|
||||
if (!best[g] || Number(r.version) > Number(best[g].version)) best[g] = r;
|
||||
});
|
||||
flows.value = Object.keys(best)
|
||||
.map((g) => ({ flowGuid: g, name: best[g].name || g, code: best[g].code || "" }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
const map: Record<string, string> = {};
|
||||
flows.value.forEach((f) => { map[f.flowGuid] = f.name; });
|
||||
flowNameMap.value = map;
|
||||
} catch (e: any) {
|
||||
flows.value = [];
|
||||
flowNameMap.value = {};
|
||||
flowsError.value = flowFailTip("流程清单加载", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用白名单(授予下拉数据源):只取 enabled=1,与后端「校内已开通」口径对齐 */
|
||||
async function loadApps() {
|
||||
appsError.value = "";
|
||||
try {
|
||||
const list: any[] = (await flowApi.appList().send()) || [];
|
||||
apps.value = list
|
||||
.filter((r: any) => r.enabled === 1)
|
||||
.map((r: any) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
|
||||
} catch (e: any) {
|
||||
apps.value = [];
|
||||
appsError.value = flowFailTip("本校应用清单加载", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 授予 ====================
|
||||
const grantVisible = ref(false);
|
||||
const grantSaving = ref(false);
|
||||
const grantError = ref("");
|
||||
const grantForm = ref<{ flowGuid: string; appKey: string; remark: string }>({ flowGuid: "", appKey: "", remark: "" });
|
||||
|
||||
function onGrant() {
|
||||
grantError.value = "";
|
||||
grantForm.value = { flowGuid: filterFlow.value || "", appKey: "", remark: "" };
|
||||
grantVisible.value = true;
|
||||
}
|
||||
|
||||
/** 重申:把已撤销行的 appKey 直接填进同一个表单,管理员确认后走同一条 grant 接口 */
|
||||
function onRegrant(row: any) {
|
||||
grantError.value = "";
|
||||
grantForm.value = { flowGuid: String(row.flowGuid || ""), appKey: String(row.appKeySnapshot || ""), remark: row.remark || "" };
|
||||
grantVisible.value = true;
|
||||
}
|
||||
|
||||
/** 当前 appKey 不在启用集合时兜底展示(重申历史快照可能已停用,仍要看得懂选的是什么,保存由后端拦) */
|
||||
const grantAppOptions = computed(() => {
|
||||
const list = apps.value.map((a) => ({ value: a.appKey, label: a.appName }));
|
||||
const cur = grantForm.value.appKey;
|
||||
if (cur && !list.some((o) => o.value === cur)) {
|
||||
list.unshift({ value: cur, label: `${cur}(当前未开启,保存可能被拒)` });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
async function doGrant() {
|
||||
if (!grantForm.value.flowGuid) {
|
||||
grantError.value = "请选择流程线";
|
||||
return;
|
||||
}
|
||||
if (!grantForm.value.appKey) {
|
||||
grantError.value = "请选择要授予许可的应用";
|
||||
return;
|
||||
}
|
||||
grantSaving.value = true;
|
||||
grantError.value = "";
|
||||
const body = {
|
||||
flowGuid: grantForm.value.flowGuid,
|
||||
appKey: grantForm.value.appKey,
|
||||
remark: grantForm.value.remark.trim() || undefined,
|
||||
};
|
||||
try {
|
||||
// 本页有行内失败面,抑制全局 toast,同一失败只出现一条
|
||||
await flowApi.grantSet(body, { silentBizToast: true }).send();
|
||||
grantVisible.value = false;
|
||||
// 成功后一律回读后端,不本地拼行(appId/快照名由服务端解析,前端无从推导)
|
||||
await load();
|
||||
if (!loadError.value) {
|
||||
Message.success("已授予使用许可,清单已按后端结果刷新");
|
||||
} else {
|
||||
Message.warning("授予已提交成功,但清单刷新失败,请重试查看最新状态");
|
||||
}
|
||||
} catch (e: any) {
|
||||
grantError.value = flowSubmitFailTip("授予", e);
|
||||
} finally {
|
||||
grantSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 撤销 ====================
|
||||
function onRevoke(row: any) {
|
||||
if (row.appId == null) {
|
||||
Message.warning("该记录缺少稳定主体 appId,无法撤销,请在后端核对数据来源");
|
||||
return;
|
||||
}
|
||||
MyConfirm({
|
||||
title: "撤销使用许可",
|
||||
msg: `确定撤销「${appLabelOf(row)}」对流程「${flowNameOf(row.flowGuid)}」的使用许可吗?`,
|
||||
// 影响面按闸门语义写死:只拦新发起,不回溯在途与已受理请求
|
||||
info: "撤销只阻止该应用的新发起;在途办件与已受理的请求回执不受影响,历史记录保留(该行以「已撤销」状态继续在清单中可审计)",
|
||||
async onOk() {
|
||||
try {
|
||||
await flowApi.grantRevoke({ flowGuid: String(row.flowGuid), appId: Number(row.appId) }, { silentBizToast: true }).send();
|
||||
await load();
|
||||
if (!loadError.value) {
|
||||
Message.success("已撤销使用许可,清单已按后端结果刷新");
|
||||
} else {
|
||||
Message.warning("撤销已提交成功,但清单刷新失败,请重试查看最新状态");
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(flowSubmitFailTip("撤销", e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 流程定义页「使用许可」跳转带 flowGuid,预选到该流程线
|
||||
const q = route.query.flowGuid;
|
||||
if (q) filterFlow.value = String(q);
|
||||
load();
|
||||
loadFlows();
|
||||
loadApps();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrapper yh-fill">
|
||||
<div class="main-container">
|
||||
<!-- 三个概念必须同屏讲清:归属≠许可,许可≠可见性 -->
|
||||
<div class="ug-explain mb-12">
|
||||
<div class="ug-explain-t">「谁建的流程」「谁能发起」「这单是谁发的」是三件事,本页只管第二件</div>
|
||||
<div class="ug-concepts">
|
||||
<div class="ug-concept">
|
||||
<b>① 流程定义归属</b>
|
||||
<span>记在 <code>flw_def.app_key</code>,只表示这条流程线由哪个应用创建/归属,是审计与筛选用的元数据,<b>不产生任何发起权限</b>。</span>
|
||||
</div>
|
||||
<div class="ug-concept">
|
||||
<b>② 应用使用许可</b>
|
||||
<span>即本页管理的 <code>flw_usage_grant</code> 独立授权记录:授予谁,谁才能发起这条流程线的办件。与归属相互独立——可以给非归属应用授予,也可以撤销归属应用自己的许可。</span>
|
||||
</div>
|
||||
<div class="ug-concept">
|
||||
<b>③ 实例来源应用</b>
|
||||
<span>办件发起时实际调用方解析出的稳定主体,随办件留档只用于追溯,不反过来参与授权判定。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ug-notes">
|
||||
<div>· 授予许可要求该应用<b>已在该校开通</b>:后端会把 appKey 解析成 UserBase 稳定主体并校验校内开通,未开通或 UserBase 不可达会被直接拒绝。</div>
|
||||
<div>· 新建流程<b>不因未授权而不可见</b>——授权只影响「能否发起」,流程定义本身仍然可见、可编辑、可发布。</div>
|
||||
<div>· 撤销许可只阻止新发起,在途办件与已受理的请求回执不受影响;授权行不物理删除,撤销后以「已撤销」状态保留可审计。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onGrant">授予使用许可</Button>
|
||||
<Select v-model="filterFlow" clearable filterable placeholder="按流程线筛选" class="fl"
|
||||
style="width: 240px; margin-left: 8px">
|
||||
<Option v-for="f in flows" :key="f.flowGuid" :value="f.flowGuid">{{ f.name }}</Option>
|
||||
</Select>
|
||||
<Select v-model="filterApp" clearable filterable placeholder="按应用筛选" class="fl"
|
||||
style="width: 200px; margin-left: 8px">
|
||||
<Option v-for="a in appFilterOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
<Select v-model="filterStatus" clearable placeholder="授权状态" class="fl" style="width: 140px; margin-left: 8px">
|
||||
<Option value="1">生效</Option>
|
||||
<Option value="0">已撤销</Option>
|
||||
</Select>
|
||||
<Button class="fl" size="large" style="margin-left: 8px" :loading="loading" @click="load()">刷新</Button>
|
||||
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
|
||||
清单为本校全部授权行(校域由管理员会话派生);筛选只在已取回的数据上做二次过滤
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 失败必须可见:加载失败/流程清单失败/应用清单失败各自一条,不靠空表暗示"没有数据" -->
|
||||
<Alert v-if="loadError" type="error" show-icon class="mb-12">
|
||||
{{ loadError }}
|
||||
<Button size="small" type="error" style="margin-left: 10px" @click="load()">重试</Button>
|
||||
</Alert>
|
||||
<Alert v-if="flowsError" type="warning" show-icon class="mb-12">
|
||||
{{ flowsError }}:流程线名称与「流程线」下拉暂无候选(授权行本身仍来自后端清单,不受影响)
|
||||
<Button size="small" type="warning" style="margin-left: 10px" @click="loadFlows()">重试流程清单</Button>
|
||||
</Alert>
|
||||
<Alert v-if="appsError" type="warning" show-icon class="mb-12">
|
||||
{{ appsError }}:授予弹窗的应用下拉保持禁用(后端按 appKey 解析稳定主体,手填必被拒),
|
||||
<Button size="small" type="warning" style="margin-left: 10px" @click="loadApps()">重试应用清单</Button>
|
||||
</Alert>
|
||||
|
||||
<div class="table-region" ref="regionRef">
|
||||
<Table :columns="columns" :data="filteredRows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="tableH || undefined"
|
||||
:no-data-text="loadError ? '授权清单未加载成功(见上方错误提示),不代表本校没有许可' : '暂无使用许可记录(尚未授予任何应用)'" />
|
||||
</div>
|
||||
|
||||
<Modal v-model="grantVisible" title="授予应用使用许可" :mask-closable="false" width="560">
|
||||
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 10px">
|
||||
许可的对象是稳定应用主体:后端按 appKey 解析 UserBase 主体并校验该校开通,前端不传学校、也不接受手填 appKey。
|
||||
</p>
|
||||
<Alert v-if="grantError" type="error" show-icon class="mb-12">{{ grantError }}</Alert>
|
||||
<Alert v-if="flowsError" type="warning" show-icon class="mb-12">
|
||||
{{ flowsError }}:流程线下拉暂无候选,可关闭后重试流程清单加载
|
||||
</Alert>
|
||||
<Alert v-if="appsError" type="warning" show-icon class="mb-12">
|
||||
{{ appsError }}:应用下拉已禁用,请先重试应用清单再授予
|
||||
</Alert>
|
||||
<Form :label-width="90">
|
||||
<FormItem label="流程线">
|
||||
<Select v-model="grantForm.flowGuid" filterable placeholder="选择流程线" :disabled="!!flowsError">
|
||||
<Option v-for="f in flows" :key="f.flowGuid" :value="f.flowGuid">
|
||||
{{ f.name }}({{ f.code }})
|
||||
</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="应用">
|
||||
<Select v-model="grantForm.appKey" filterable placeholder="选择本校已开启的应用" :disabled="!!appsError">
|
||||
<Option v-for="o in grantAppOptions" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="备注">
|
||||
<Input v-model="grantForm.remark" type="textarea" :rows="2" :maxlength="200"
|
||||
placeholder="授权缘由(选填,随许可行落库供后续审计)" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="grantVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="grantSaving" :disabled="!!appsError || !!flowsError" @click="doGrant">授予</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 概念说明区:三概念并列 + 三条边界,管理员不必跳文档就能判断"该点哪个按钮" */
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ug-explain {
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #e4e9f2;
|
||||
border-radius: 4px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.ug-explain-t { color: #17233d; font-weight: 600; font-size: 13px; margin-bottom: 8px; }
|
||||
.ug-concepts { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.ug-concept {
|
||||
flex: 1 1 260px;
|
||||
min-width: 260px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8eaef;
|
||||
border-radius: 3px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #515a6e;
|
||||
}
|
||||
.ug-concept > b { color: #17233d; display: block; margin-bottom: 2px; }
|
||||
.ug-concept code { background: #f0f2f5; padding: 0 4px; border-radius: 2px; font-size: 11px; }
|
||||
/* 概念正文里的强调只变色不变块,否则 <b> 会把行劈断 */
|
||||
.ug-concept span b { color: #c0392b; }
|
||||
|
||||
.ug-notes { margin-top: 8px; }
|
||||
.ug-notes div { font-size: 12px; line-height: 1.8; color: #515a6e; }
|
||||
.ug-notes b { color: #17233d; }
|
||||
|
||||
.ug-cell2 { display: flex; flex-direction: column; line-height: 1.35; }
|
||||
.ug-cell-n { color: #17233d; font-weight: 500; }
|
||||
.ug-cell-k { color: #98a1b3; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 审批节点「预览候选人」(FL上线独立验收报告 P2-1:/assign-preview 页存在但编辑器内无入口)。
|
||||
*
|
||||
* 口径:
|
||||
* - 只读。解析当前抽屉里的**草稿配置**,不写回节点、不改流程定义、不建实例。
|
||||
* - 数据全部来自既有接口(科室成员 / UserBase 用户检索 / 发起预览),前端不重实现人员解析:
|
||||
* DEPT_KEY 读的是后端 AssigneeResolver 同一张 flw_dept_member,USERS 读的是 UserBase byId。
|
||||
* - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。
|
||||
*/
|
||||
import { describeFlowError } from "@/utils/flowError";
|
||||
import type { DeptRef } from "./model";
|
||||
|
||||
export type PreviewAssigneeType = "DEPT" | "DEPT_KEY" | "USERS";
|
||||
|
||||
export interface PreviewAssignPolicy {
|
||||
mode?: string;
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/** 抽屉里的当前(可能未保存)节点配置 */
|
||||
export interface PreviewNodeConfig {
|
||||
uid: string;
|
||||
name: string;
|
||||
type: PreviewAssigneeType;
|
||||
deptKey: string;
|
||||
/** UserBase 部门引用(type=DEPT 时生效;SYSTEM/CUSTOM 消歧位必带) */
|
||||
deptRefs?: DeptRef[];
|
||||
userIds: number[];
|
||||
countersign: "ALL" | "ANY";
|
||||
assignPolicy: PreviewAssignPolicy | null | undefined;
|
||||
}
|
||||
|
||||
export interface PreviewDept {
|
||||
deptKey: string;
|
||||
deptName: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CandidateRow {
|
||||
userId: number;
|
||||
userName: string;
|
||||
/** 该候选人不计入/存在风险的说明;空=正常 */
|
||||
warn?: string;
|
||||
}
|
||||
|
||||
/** 已发布版本的发起预览对账结果 */
|
||||
export interface PublishedView {
|
||||
state: "ok" | "missing" | "unavailable";
|
||||
defVersion?: number;
|
||||
rows: CandidateRow[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NodePreviewResult {
|
||||
/** 是否解析出了可用候选人(false 时一定有 blocked 或 published.unavailable 解释) */
|
||||
ok: boolean;
|
||||
headline: string;
|
||||
rows: CandidateRow[];
|
||||
blocked?: string;
|
||||
published?: PublishedView;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PreviewApi {
|
||||
deptMembers: (deptId: string) => Promise<any[]>;
|
||||
/** UserBase 新部门目录成员(scope=select 严档,与运行时审批人解析同口径) */
|
||||
deptDirectoryMembers?: (type: string, departmentId: number, scope?: string) => Promise<any[]>;
|
||||
lookupUser: (userId: number) => Promise<any>;
|
||||
applyPreview: (flowGuid: string) => Promise<any>;
|
||||
}
|
||||
|
||||
/** flw_dept_member.status / flw_dept.status:0=启用(与后端 resolveEnabled 同口径) */
|
||||
const DEPT_MEMBER_ENABLED = 0;
|
||||
|
||||
function countersignText(c: "ALL" | "ANY"): string {
|
||||
return c === "ALL" ? "会签(全部同意才通过)" : "或签(任一人同意即通过)";
|
||||
}
|
||||
|
||||
function assignPolicyText(p: PreviewAssignPolicy | null | undefined): string {
|
||||
if (!p) return "";
|
||||
const mode = p.mode === "SINGLE" ? "单选 1 人" : `多选 ${p.min ?? 1}~${p.max ?? "不限"} 人`;
|
||||
return `;发起时指派:${mode}${p.required ? " · 必选" : ""}`;
|
||||
}
|
||||
|
||||
/** UserBase BoaUser.status:1=启用(后端 D11 终审按此拒绝禁用/未知状态账号) */
|
||||
function userRow(u: any, id: number): CandidateRow {
|
||||
if (!u) return { userId: id, userName: `#${id}`, warn: "UserBase 查不到该用户,正式发起时会被拒绝" };
|
||||
const status = typeof u.status === "number" ? u.status : null;
|
||||
return {
|
||||
userId: id,
|
||||
userName: u.name || `#${id}`,
|
||||
warn: status === 1 ? undefined : `账号状态${status == null ? "未知" : status}(非启用),正式办理时会被服务端拒绝`,
|
||||
};
|
||||
}
|
||||
|
||||
async function deptRows(deptKey: string, api: PreviewApi): Promise<CandidateRow[]> {
|
||||
const members = (await api.deptMembers(deptKey)) || [];
|
||||
return members.map((m: any) => ({
|
||||
userId: Number(m.userId),
|
||||
userName: m.userName || `#${m.userId}`,
|
||||
warn: Number(m.status) === DEPT_MEMBER_ENABLED ? undefined : "该成员已停用,不计入候选",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* UserBase 部门主体预览(C4):数据来源、主体类型、实际候选、会签/或签规则与不可预览原因都要说清。
|
||||
* 一个用户可属多个被选部门 → 按用户去重;目录不可用照实报错,不把"查不到"当"没人"。
|
||||
*/
|
||||
async function resolveDepartmentPreview(
|
||||
cfg: PreviewNodeConfig,
|
||||
api: PreviewApi,
|
||||
published: PublishedView,
|
||||
notes: string[],
|
||||
): Promise<NodePreviewResult> {
|
||||
const headline = "按部门(UserBase 目录)解析";
|
||||
const refs = cfg.deptRefs || [];
|
||||
if (!refs.length) {
|
||||
return { ok: false, headline, rows: [], blocked: "尚未选择部门,请先在「部门」里选择至少一个部门。", published, notes };
|
||||
}
|
||||
if (!api.deptDirectoryMembers) {
|
||||
return {
|
||||
ok: false,
|
||||
headline,
|
||||
rows: [],
|
||||
blocked: "部门目录成员通道未接通(deptDirectoryMembers 缺失),不能拿本地科室冒充结果。",
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
const rows: CandidateRow[] = [];
|
||||
const seen = new Set<number>();
|
||||
const failed: string[] = [];
|
||||
for (const ref of refs) {
|
||||
const key = `${ref.departmentType}:${ref.departmentId}`;
|
||||
let members: any[];
|
||||
try {
|
||||
members = (await api.deptDirectoryMembers(ref.departmentType, ref.departmentId, "select")) || [];
|
||||
} catch (e: any) {
|
||||
failed.push(`${key}:${describeFlowError(e)}`);
|
||||
continue;
|
||||
}
|
||||
for (const m of members) {
|
||||
const id = Number(m.id ?? m.userId);
|
||||
if (!id || seen.has(id)) continue; // 多部门归属同一人:只列一次
|
||||
seen.add(id);
|
||||
rows.push({
|
||||
userId: id,
|
||||
userName: m.name || m.userName || `#${id}`,
|
||||
warn: Number(m.status) === 1
|
||||
? undefined
|
||||
: `账号状态${m.status == null ? "未知" : m.status}(非启用),正式办理时会被服务端拒绝`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (failed.length) {
|
||||
return {
|
||||
ok: false,
|
||||
headline,
|
||||
rows,
|
||||
blocked: `部门目录解析失败(不是"没有候选人"):${failed.join(";")}`,
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
const usable = rows.filter((r) => !r.warn);
|
||||
notes.unshift("候选数据来源=UserBase 部门目录 /oapi/departments/{type}/{id}/members?scope=select。");
|
||||
return {
|
||||
ok: usable.length > 0,
|
||||
headline: `${headline} · ${refs.map((r) => `${r.departmentType}:${r.departmentId}`).join("、")} · ${rows.length} 人(${usable.length} 人启用)`,
|
||||
rows,
|
||||
blocked: usable.length ? undefined : "所选部门下没有启用账号,发布会被服务端资格终审拦截。",
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/** 已发布版本对账:只有本线已保存(有 flowGuid)时才有可比对象 */
|
||||
export async function loadPublishedView(
|
||||
cfg: PreviewNodeConfig,
|
||||
flowGuid: string,
|
||||
api: PreviewApi,
|
||||
): Promise<PublishedView> {
|
||||
if (!flowGuid) {
|
||||
return { state: "unavailable", rows: [], text: "本流程线尚未保存,没有可比对的已发布版本(预览按当前草稿配置解析)。" };
|
||||
}
|
||||
let res: any;
|
||||
try {
|
||||
res = await api.applyPreview(flowGuid);
|
||||
} catch (e: any) {
|
||||
return { state: "unavailable", rows: [], text: `已发布版本预览不可用:${describeFlowError(e)}` };
|
||||
}
|
||||
const version = Number(res?.defVersion) || undefined;
|
||||
const hit = (res?.nodes || []).find((n: any) => String(n.nodeId) === String(cfg.uid));
|
||||
if (!hit) {
|
||||
return {
|
||||
state: "missing",
|
||||
defVersion: version,
|
||||
rows: [],
|
||||
text: `已发布版本 v${version ?? "?"} 的发起预览里没有本节点:要么它未开启发起时指派,要么是尚未发布的草稿节点。`,
|
||||
};
|
||||
}
|
||||
const rows: CandidateRow[] = (hit.candidates || []).map((c: any) => ({
|
||||
userId: Number(c.userId),
|
||||
userName: c.userName || `#${c.userId}`,
|
||||
}));
|
||||
return {
|
||||
state: "ok",
|
||||
defVersion: version,
|
||||
rows,
|
||||
text: `已发布版本 v${version ?? "?"} 解析出的可指派候选人(发起人实际可选范围以此为准,本节点${rows.length ? "" : "当前无候选人"})。`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前节点配置的候选人。
|
||||
* depts 传编辑器的科室上下文(含启用人数),用于把「科室不存在/0 人」讲清楚而不靠猜。
|
||||
*/
|
||||
export async function resolveNodePreview(
|
||||
cfg: PreviewNodeConfig,
|
||||
depts: PreviewDept[],
|
||||
api: PreviewApi,
|
||||
flowGuid = "",
|
||||
): Promise<NodePreviewResult> {
|
||||
const notes = [
|
||||
`多人审批方式:${countersignText(cfg.countersign)}${assignPolicyText(cfg.assignPolicy)}`,
|
||||
"预览只读:不写回节点、不改动流程定义;最终办理资格由 FL 服务端在正式发起时复核。",
|
||||
];
|
||||
const published = await loadPublishedView(cfg, flowGuid, api);
|
||||
|
||||
if (cfg.type === "DEPT") {
|
||||
return resolveDepartmentPreview(cfg, api, published, notes);
|
||||
}
|
||||
|
||||
if (cfg.type === "DEPT_KEY") {
|
||||
if (!cfg.deptKey) {
|
||||
return { ok: false, headline: "按科室映射解析", rows: [], blocked: "尚未选择科室映射,请先在「科室映射」里选一个科室。", published, notes };
|
||||
}
|
||||
const dept = depts.find((d) => d.deptKey === cfg.deptKey);
|
||||
if (!dept) {
|
||||
return {
|
||||
ok: false,
|
||||
headline: "按科室映射解析",
|
||||
rows: [],
|
||||
blocked: `科室映射「${cfg.deptKey}」不在本校科室列表中(可能已删除),发布会被拦截。`,
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
let rows: CandidateRow[];
|
||||
try {
|
||||
rows = await deptRows(cfg.deptKey, api);
|
||||
} catch (e: any) {
|
||||
return {
|
||||
ok: false,
|
||||
headline: `科室「${dept.deptName || dept.deptKey}」`,
|
||||
rows: [],
|
||||
blocked: `读取科室成员失败:${describeFlowError(e)}`,
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
const enabled = rows.filter((r) => !r.warn);
|
||||
if (!enabled.length) {
|
||||
return {
|
||||
ok: false,
|
||||
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})`,
|
||||
rows,
|
||||
blocked: `科室「${dept.deptName || dept.deptKey}」当前 0 名启用办理人,发布会被拦截;请到「办理人映射」补人。`,
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
if (rows.length !== enabled.length) {
|
||||
notes.unshift(`共 ${rows.length} 名成员,其中 ${enabled.length} 名启用(停用成员已在下表标注)。`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})· ${enabled.length} 名启用办理人`,
|
||||
rows,
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
if (!cfg.userIds.length) {
|
||||
return { ok: false, headline: "按指定用户解析", rows: [], blocked: "尚未选择用户,请先点「选择用户」添加审批人。", published, notes };
|
||||
}
|
||||
const rows: CandidateRow[] = [];
|
||||
for (const id of cfg.userIds) {
|
||||
try {
|
||||
rows.push(userRow(await api.lookupUser(Number(id)), Number(id)));
|
||||
} catch (e: any) {
|
||||
rows.push({ userId: Number(id), userName: `#${id}`, warn: `用户检索失败:${describeFlowError(e)}` });
|
||||
}
|
||||
}
|
||||
const usable = rows.filter((r) => !r.warn);
|
||||
return {
|
||||
ok: usable.length > 0,
|
||||
headline: `指定用户 · ${rows.length} 人(${usable.length} 人可检索到且状态启用)`,
|
||||
rows,
|
||||
blocked: usable.length ? undefined : "指定的用户全部查不到或状态非启用,发布会被拦截。",
|
||||
published,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,22 @@
|
||||
// 审批节点配置抽屉(方案 §3.4):打开时把节点值拷进本地表单,确定时写回——
|
||||
// 分段控件切换只改本地状态,不会因重渲染丢失未保存输入(原型阶段踩过该坑)。
|
||||
// 指定用户改为树形选人弹窗(参照 Notice 自定义组选人),id+name 同帧拿到,不再手输 ID。
|
||||
// 审批主体口径(C1/C3,2026-10-29):新配置只有「部门(UserBase 权威目录)」与「指定人员」两条路;
|
||||
// 本地科室(DEPT_KEY,flw_dept 旧表)只作只读兼容——仅当它正是节点当前值时才出现在分段控件里,
|
||||
// 切走即消失、不可再选,且不做任何静默回落(历史定义仍能打开、原样保存)。
|
||||
// 展示规则(C3)与主体分开存:「节点显示方式」独立成块,displayMode 未显式配置时不写字段,
|
||||
// 保证历史定义保存后 JSON 字节级不变。
|
||||
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 AssigneePreviewModal from "./AssigneePreviewModal.vue";
|
||||
import type { PreviewNodeConfig } from "../assignPreview";
|
||||
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
||||
import type { DeptRef, DisplayMode } from "../model";
|
||||
|
||||
/** 审批主体三态:DEPT=UserBase 部门(新)|USERS=指定人员|DEPT_KEY=本地科室(只读兼容) */
|
||||
type SubjectType = "DEPT" | "DEPT_KEY" | "USERS";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -23,12 +34,21 @@ const ctx = inject(EDITOR_CTX)!;
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
type: "DEPT_KEY" as "DEPT_KEY" | "USERS",
|
||||
type: "DEPT" as SubjectType,
|
||||
deptKey: "",
|
||||
deptRefs: [] as DeptRef[],
|
||||
userIds: [] as number[],
|
||||
countersign: "ANY" as "ALL" | "ANY",
|
||||
deadlineHours: null as number | null,
|
||||
multiOutlet: false,
|
||||
assignEnabled: false,
|
||||
assignMode: "MULTI" as "SINGLE" | "MULTI",
|
||||
assignMin: 1,
|
||||
assignMax: null as number | null,
|
||||
assignRequired: false,
|
||||
/** 空串=节点未显式配置展示方式(缺省按人员),保存时不写字段 */
|
||||
displayMode: "" as "" | DisplayMode,
|
||||
displayLabel: "",
|
||||
});
|
||||
|
||||
const userCheckModal = ref<any>();
|
||||
@@ -39,16 +59,85 @@ const userModalSources = [
|
||||
},
|
||||
];
|
||||
|
||||
// ==================== 部门主体(UserBase 目录)状态 ====================
|
||||
interface UbDept {
|
||||
departmentType: "SYSTEM" | "CUSTOM";
|
||||
departmentId: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
selectable: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
/** 引用回显:只有 deptResolve 能给出已停用/已软删部门的名称,故选中与载入都归一到这张表 */
|
||||
interface UbInfo {
|
||||
categoryId?: number | null;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
warn: string;
|
||||
}
|
||||
|
||||
const ubGroups = ref<any[]>([]);
|
||||
const ubGroupKey = ref("");
|
||||
const ubOptions = ref<UbDept[]>([]);
|
||||
/** 逐级下钻路径(末项即当前层;id=null 为组根) */
|
||||
const ubNav = ref<{ id: number | null; name: string }[]>([]);
|
||||
const ubKeyword = ref("");
|
||||
const ubAddChildren = ref(false);
|
||||
const ubLoading = ref(false);
|
||||
const ubErr = ref("");
|
||||
const ubInfo = reactive<Record<string, UbInfo>>({});
|
||||
/** 并发去重:载入节点与切主体可能同时触发,早退会让自动选组落空,故复用同一 in-flight 请求。
|
||||
* 必须与上面的状态同批声明——syncFromNode 的 immediate watch 会同步走到 loadUbGroups,声明靠后会 TDZ */
|
||||
let ubGroupsPromise: Promise<void> | null = null;
|
||||
|
||||
/** 展示名默认填入的判据:管理员改过一次就再也不覆盖 */
|
||||
const labelTouched = ref(false);
|
||||
let autoLabel = "";
|
||||
|
||||
function ubKeyOf(type: string, id: number | string): string {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
function syncFromNode(): void {
|
||||
ubGroupKey.value = "";
|
||||
const n = props.node;
|
||||
if (!n) return;
|
||||
form.name = n.name;
|
||||
form.type = n.assignee?.type === "USERS" ? "USERS" : "DEPT_KEY";
|
||||
form.deptKey = n.assignee?.type === "DEPT_KEY" ? n.assignee.deptKey : "";
|
||||
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
|
||||
const a = n.assignee;
|
||||
// 旧机制的空占位(createApproval 造的新节点 {DEPT_KEY,""})不算"选了本地科室",
|
||||
// 直接落到新口径,避免新建节点一打开就顶着个只读旧选项
|
||||
form.type = a && !(a.type === "DEPT_KEY" && !a.deptKey.trim()) ? a.type : "DEPT";
|
||||
form.deptKey = a?.type === "DEPT_KEY" ? a.deptKey : "";
|
||||
// 不带 name:名称只从目录解析回显,避免"存了个当时的名字"被误当关联依据
|
||||
form.deptRefs =
|
||||
a?.type === "DEPT"
|
||||
? (a.deptRefs || []).map((r) => ({
|
||||
departmentType: r.departmentType,
|
||||
departmentId: Number(r.departmentId),
|
||||
}))
|
||||
: [];
|
||||
form.userIds = a?.type === "USERS" ? [...a.userIds] : [];
|
||||
form.countersign = n.countersign;
|
||||
form.deadlineHours = n.deadlineHours;
|
||||
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
|
||||
form.assignEnabled = !!n.assignPolicy;
|
||||
form.assignMode = n.assignPolicy?.mode === "SINGLE" ? "SINGLE" : "MULTI";
|
||||
form.assignMin = n.assignPolicy?.min ?? 1;
|
||||
form.assignMax = n.assignPolicy?.max ?? null;
|
||||
form.assignRequired = !!n.assignPolicy?.required;
|
||||
form.displayMode = n.displayMode === "DEPT" || n.displayMode === "USER" ? n.displayMode : "";
|
||||
form.displayLabel = n.displayLabel || "";
|
||||
autoLabel = form.displayLabel;
|
||||
// 已有展示名视为管理员(或历史配置)定过的值,不被部门名默认覆盖
|
||||
labelTouched.value = !!n.displayLabel;
|
||||
if (form.type === "DEPT") {
|
||||
void ensureUbContext();
|
||||
void resolveUbRefs();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -59,8 +148,8 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/** 科室下拉项:mapList 聚合 + 当前值(可能已不在映射表中)兜底,避免选项丢失 */
|
||||
const deptOptions = computed(() => {
|
||||
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
|
||||
const legacyDeptOptions = computed(() => {
|
||||
const list = ctx.depts.map((d) => ({
|
||||
value: d.deptKey,
|
||||
label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 名办理人`,
|
||||
@@ -72,7 +161,7 @@ const deptOptions = computed(() => {
|
||||
});
|
||||
|
||||
/** 当前科室的映射状态提示(与 validate.ts 同口径:仅提示,不阻断存草稿) */
|
||||
const deptHint = computed(() => {
|
||||
const legacyDeptHint = computed(() => {
|
||||
if (form.type !== "DEPT_KEY" || !form.deptKey) return "";
|
||||
const d = ctx.depts.find((x) => x.deptKey === form.deptKey);
|
||||
if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`;
|
||||
@@ -80,6 +169,288 @@ const deptHint = computed(() => {
|
||||
return "";
|
||||
});
|
||||
|
||||
// ==================== 部门目录取数(真实接口,失败如实上抛到界面) ====================
|
||||
const ubGroupItems = computed(() =>
|
||||
ubGroups.value.map((g: any) => {
|
||||
const type: "SYSTEM" | "CUSTOM" = g.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM";
|
||||
const categoryId = g.categoryId == null ? null : Number(g.categoryId);
|
||||
const name = g.name || (type === "SYSTEM" ? "系统组织" : `自定义分类#${categoryId ?? "?"}`);
|
||||
return {
|
||||
key: ubKeyOf(type, categoryId ?? ""),
|
||||
type,
|
||||
categoryId,
|
||||
name,
|
||||
label: `${name}(${type === "SYSTEM" ? "系统组织" : "自定义部门"}·${Number(g.departmentCount) || 0} 个部门)`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function ubGroupByKey(key: string) {
|
||||
return ubGroupItems.value.find((g) => g.key === key) || null;
|
||||
}
|
||||
|
||||
function loadUbGroups(): Promise<void> {
|
||||
if (ubGroups.value.length) return Promise.resolve();
|
||||
if (!ubGroupsPromise) {
|
||||
ubGroupsPromise = (async () => {
|
||||
ubLoading.value = true;
|
||||
ubErr.value = "";
|
||||
try {
|
||||
// 显式泛型的 get<T[]> 在 alova 类型里会把 send() 收成 {},先经 any 落一次再入列表
|
||||
const raw: any = await flowApi.deptGroups().send();
|
||||
ubGroups.value = raw || [];
|
||||
} catch (e: any) {
|
||||
ubErr.value = `部门组加载失败:${e?.message || e}`;
|
||||
} finally {
|
||||
ubLoading.value = false;
|
||||
}
|
||||
})().finally(() => {
|
||||
ubGroupsPromise = null;
|
||||
});
|
||||
}
|
||||
return ubGroupsPromise;
|
||||
}
|
||||
|
||||
async function loadUbOptions(): Promise<void> {
|
||||
const g = ubGroupByKey(ubGroupKey.value);
|
||||
ubOptions.value = [];
|
||||
if (!g) return;
|
||||
ubNav.value = [{ id: null, name: g.name }];
|
||||
ubLoading.value = true;
|
||||
ubErr.value = "";
|
||||
try {
|
||||
// 系统组织是层级树,一次取全子树后在前端按 parentId 重建;自定义分类是平表,不必展开子树
|
||||
const raw: any = await flowApi
|
||||
.deptOptions({
|
||||
type: g.type,
|
||||
categoryId: g.categoryId ?? undefined,
|
||||
withChildren: g.type === "SYSTEM" ? true : undefined,
|
||||
})
|
||||
.send();
|
||||
const rows: any[] = raw || [];
|
||||
ubOptions.value = rows.map((r: any): UbDept => ({
|
||||
departmentType: r.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM",
|
||||
departmentId: Number(r.departmentId),
|
||||
name: r.name || `#${r.departmentId}`,
|
||||
parentId: r.parentId == null ? null : Number(r.parentId),
|
||||
categoryId: r.categoryId == null ? null : Number(r.categoryId),
|
||||
categoryName: r.categoryName || "",
|
||||
enabled: r.enabled !== false,
|
||||
deleted: r.deleted === true,
|
||||
selectable: r.selectable !== false,
|
||||
memberCount: Number(r.memberCount) || 0,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
ubErr.value = `部门列表加载失败:${e?.message || e}`;
|
||||
} finally {
|
||||
ubLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 已存引用逐条解析名称(含停用/软删):这是唯一能让"看不见部门"变成"看得见并警告"的通道 */
|
||||
async function resolveUbRefs(): Promise<void> {
|
||||
for (const r of form.deptRefs) {
|
||||
const key = ubKeyOf(r.departmentType, r.departmentId);
|
||||
if (ubInfo[key]) continue;
|
||||
let row: any;
|
||||
try {
|
||||
const raw: any = await flowApi.deptResolve(r.departmentType, r.departmentId).send();
|
||||
const rows: any[] = raw || [];
|
||||
row = rows.find((x: any) => Number(x.departmentId) === r.departmentId) || rows[0] || null;
|
||||
} catch (e: any) {
|
||||
ubInfo[key] = {
|
||||
name: `${r.departmentType}#${r.departmentId}`,
|
||||
memberCount: 0,
|
||||
warn: `部门信息解析失败:${e?.message || e}`,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!row) {
|
||||
ubInfo[key] = {
|
||||
name: `${r.departmentType}#${r.departmentId}`,
|
||||
memberCount: 0,
|
||||
warn: "UserBase 部门目录已查不到该部门,历史引用仍可解析,新配置请改选其他部门",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
ubInfo[key] = {
|
||||
categoryId: row.categoryId == null ? null : Number(row.categoryId),
|
||||
name: row.name || `#${r.departmentId}`,
|
||||
memberCount: Number(row.memberCount) || 0,
|
||||
warn:
|
||||
row.deleted === true
|
||||
? "该部门已删除,新配置不可选,历史引用仍可解析"
|
||||
: row.enabled === false
|
||||
? "该部门已停用,新配置不可选,历史引用仍可解析"
|
||||
: row.selectable === false
|
||||
? "该部门当前不可选(无成员或未开放),新配置不可选,历史引用仍可解析"
|
||||
: "",
|
||||
};
|
||||
}
|
||||
// 名称到手后才补默认展示名:否则切主体时机会把 "SYSTEM#id" 这类兜底串当成部门名填进去
|
||||
applyDefaultDisplayLabel();
|
||||
}
|
||||
|
||||
/** 打开/切到部门主体时才取数:不选部门主体的节点(含旧定义)不发多余请求 */
|
||||
async function ensureUbContext(): Promise<void> {
|
||||
await loadUbGroups();
|
||||
await resolveUbRefs();
|
||||
if (ubGroupKey.value) return;
|
||||
const customRef = form.deptRefs.find(r => r.departmentType === "CUSTOM");
|
||||
if (customRef) {
|
||||
let categoryId = ubInfo[ubKeyOf(customRef.departmentType, customRef.departmentId)]?.categoryId;
|
||||
if (categoryId == null) {
|
||||
try {
|
||||
const rows: any = await flowApi.deptOptions({ type: "CUSTOM" }).send();
|
||||
categoryId = (rows || []).find((r: any) => r.departmentType === "CUSTOM" && Number(r.departmentId) === customRef.departmentId)?.categoryId;
|
||||
} catch { /* Keep the original reference when category discovery is unavailable. */ }
|
||||
}
|
||||
const group = ubGroupItems.value.find(g => g.type === "CUSTOM" && g.categoryId === Number(categoryId));
|
||||
if (group) { ubGroupKey.value = group.key; return; }
|
||||
}
|
||||
const sys = ubGroupItems.value.find((g) => g.type === "SYSTEM");
|
||||
if (form.deptRefs.some((r) => r.departmentType === "SYSTEM") && sys) ubGroupKey.value = sys.key;
|
||||
else if (ubGroupItems.value.length === 1) ubGroupKey.value = ubGroupItems.value[0].key;
|
||||
}
|
||||
|
||||
watch(ubGroupKey, () => {
|
||||
void loadUbOptions();
|
||||
});
|
||||
|
||||
async function ubRetry(): Promise<void> {
|
||||
ubGroups.value = [];
|
||||
await loadUbGroups();
|
||||
// 已有组就重载列表;没组则交给 ubGroupKey 的 watch 触发,避免同一次重试发两遍
|
||||
if (ubGroupKey.value) await loadUbOptions();
|
||||
else await ensureUbContext();
|
||||
}
|
||||
|
||||
function ubNameOf(r: DeptRef): string {
|
||||
return ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.name
|
||||
|| ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)?.name
|
||||
|| `${r.departmentType}#${r.departmentId}`;
|
||||
}
|
||||
|
||||
function ubMemberCountOf(r: DeptRef): number {
|
||||
const info = ubInfo[ubKeyOf(r.departmentType, r.departmentId)];
|
||||
if (info) return info.memberCount;
|
||||
return ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)
|
||||
?.memberCount || 0;
|
||||
}
|
||||
|
||||
const ubWarnings = computed(() =>
|
||||
form.deptRefs
|
||||
.map((r) => ({
|
||||
key: ubKeyOf(r.departmentType, r.departmentId),
|
||||
name: ubNameOf(r),
|
||||
warn: ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.warn || "",
|
||||
}))
|
||||
.filter((x) => x.warn)
|
||||
.map((x) => ({ key: x.key, text: `「${x.name}」${x.warn}` }))
|
||||
);
|
||||
|
||||
/** 当前可见部门:有关键字则跨层级检索,否则按 parentId 逐级展开 */
|
||||
const ubVisible = computed<UbDept[]>(() => {
|
||||
const kw = ubKeyword.value.trim();
|
||||
if (kw) return ubOptions.value.filter((o) => o.name.includes(kw));
|
||||
const cur = ubNav.value.length ? ubNav.value[ubNav.value.length - 1].id : null;
|
||||
if (cur == null) {
|
||||
const ids = new Set(ubOptions.value.map((o) => o.departmentId));
|
||||
// 父级不在本组返回集的即为根(自定义分类平表、以及父链断裂的孤儿部门都从根显示,避免看不见)
|
||||
return ubOptions.value.filter((o) => o.parentId == null || !ids.has(o.parentId));
|
||||
}
|
||||
return ubOptions.value.filter((o) => o.parentId === cur);
|
||||
});
|
||||
|
||||
function ubHasChildren(o: UbDept): boolean {
|
||||
return ubOptions.value.some((x) => x.parentId === o.departmentId);
|
||||
}
|
||||
|
||||
function ubDrill(o: UbDept): void {
|
||||
ubNav.value = [...ubNav.value, { id: o.departmentId, name: o.name }];
|
||||
ubKeyword.value = "";
|
||||
}
|
||||
|
||||
function ubUp(): void {
|
||||
if (ubNav.value.length > 1) ubNav.value = ubNav.value.slice(0, -1);
|
||||
}
|
||||
|
||||
function ubJump(i: number): void {
|
||||
ubNav.value = ubNav.value.slice(0, i + 1);
|
||||
}
|
||||
|
||||
function isUbPicked(o: UbDept): boolean {
|
||||
return form.deptRefs.some((r) => r.departmentType === o.departmentType && r.departmentId === o.departmentId);
|
||||
}
|
||||
|
||||
function ubPick(o: UbDept): void {
|
||||
const key = ubKeyOf(o.departmentType, o.departmentId);
|
||||
if (!form.deptRefs.some((r) => ubKeyOf(r.departmentType, r.departmentId) === key)) {
|
||||
form.deptRefs.push({ departmentType: o.departmentType, departmentId: o.departmentId });
|
||||
}
|
||||
if (!ubInfo[key]?.warn) ubInfo[key] = { name: o.name, memberCount: o.memberCount, warn: "" };
|
||||
}
|
||||
|
||||
function toggleUbDept(o: UbDept): void {
|
||||
if (!o.selectable) {
|
||||
Message.warning(`「${o.name}」当前不可选(停用/删除/无成员),不能作为新配置`);
|
||||
return;
|
||||
}
|
||||
const key = ubKeyOf(o.departmentType, o.departmentId);
|
||||
const i = form.deptRefs.findIndex((r) => ubKeyOf(r.departmentType, r.departmentId) === key);
|
||||
if (i >= 0) form.deptRefs.splice(i, 1);
|
||||
else ubPick(o);
|
||||
applyDefaultDisplayLabel();
|
||||
}
|
||||
|
||||
function removeUbRef(r: DeptRef): void {
|
||||
const key = ubKeyOf(r.departmentType, r.departmentId);
|
||||
const i = form.deptRefs.findIndex((x) => ubKeyOf(x.departmentType, x.departmentId) === key);
|
||||
if (i >= 0) form.deptRefs.splice(i, 1);
|
||||
applyDefaultDisplayLabel();
|
||||
}
|
||||
|
||||
/** 勾了"含下级"则连同子树一起算(环/父子互指有 seen 兜底) */
|
||||
function ubSubtree(o: UbDept): UbDept[] {
|
||||
const out: UbDept[] = [];
|
||||
const seen = new Set<string>([ubKeyOf(o.departmentType, o.departmentId)]);
|
||||
const stack: UbDept[] = [o];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
out.push(cur);
|
||||
ubOptions.value.forEach((x) => {
|
||||
const k = ubKeyOf(x.departmentType, x.departmentId);
|
||||
if (x.parentId === cur.departmentId && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
stack.push(x);
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ubPickTargets = computed<UbDept[]>(() => {
|
||||
const out: UbDept[] = [];
|
||||
const seen = new Set<string>();
|
||||
ubVisible.value.forEach((o) => {
|
||||
(ubAddChildren.value ? ubSubtree(o) : [o]).forEach((d) => {
|
||||
const k = ubKeyOf(d.departmentType, d.departmentId);
|
||||
if (d.selectable && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(d);
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
function addUbBatch(): void {
|
||||
const list = ubPickTargets.value;
|
||||
list.forEach((o) => ubPick(o));
|
||||
applyDefaultDisplayLabel();
|
||||
Message.success(list.length ? `已加入 ${list.length} 个部门` : "没有可加入的部门");
|
||||
}
|
||||
|
||||
function userNameOf(id: number): string {
|
||||
return ctx.userNames[id] || `#${id}`;
|
||||
}
|
||||
@@ -104,6 +475,89 @@ function removeUser(id: number): void {
|
||||
if (i >= 0) form.userIds.splice(i, 1);
|
||||
}
|
||||
|
||||
// ==================== 节点显示方式(C3:与主体分开的独立规则) ====================
|
||||
const displayModeModel = computed<DisplayMode>({
|
||||
get: () => (form.displayMode === "DEPT" ? "DEPT" : "USER"),
|
||||
set: (v) => {
|
||||
// 目标态就是当前态时不写入:未显式配置的历史节点保存后仍不含 displayMode 字段
|
||||
if (v === (form.displayMode || "USER")) return;
|
||||
form.displayMode = v;
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.displayLabel,
|
||||
(v) => {
|
||||
if (v !== autoLabel) labelTouched.value = true;
|
||||
}
|
||||
);
|
||||
|
||||
/** 「默认填入、管理员可改」:只有部门主体才给默认名;主体是指定人员时一律留空由管理员自填 */
|
||||
function applyDefaultDisplayLabel(): void {
|
||||
if (labelTouched.value) return;
|
||||
const first = form.deptRefs[0];
|
||||
const next = form.type === "DEPT" && first ? ubNameOf(first) : "";
|
||||
autoLabel = next;
|
||||
form.displayLabel = next;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.type,
|
||||
() => {
|
||||
if (form.type === "DEPT") void ensureUbContext();
|
||||
applyDefaultDisplayLabel();
|
||||
}
|
||||
);
|
||||
|
||||
// ===== 高级设置分层(P2 核心/高级):只挪位置,不改字段、不改数据语义 =====
|
||||
/** 高级区是否展开;打开抽屉时若已有高级配置则默认展开,避免"配了但看不见" */
|
||||
const advOpen = ref(false);
|
||||
const activeAdvanced = computed(() => {
|
||||
const hits: string[] = [];
|
||||
if (form.countersign === "ALL") hits.push("会签");
|
||||
if (form.assignEnabled) hits.push("发起时指派");
|
||||
if (form.deadlineHours) hits.push("办理时限");
|
||||
if (form.multiOutlet) hits.push("多出口");
|
||||
return hits;
|
||||
});
|
||||
|
||||
watch(() => [props.modelValue, props.node?.uid], () => {
|
||||
if (props.modelValue) advOpen.value = activeAdvanced.value.length > 0;
|
||||
}, { immediate: true });
|
||||
|
||||
// ===== 指派预览(验收 P2-1:编辑器内要有可发现入口) =====
|
||||
const previewModal = ref<any>();
|
||||
|
||||
/** 用抽屉里的实时表单(含未保存改动)作为预览输入;预览全程只读,不回写节点 */
|
||||
function previewConfig(): PreviewNodeConfig {
|
||||
return {
|
||||
uid: props.node?.uid || "",
|
||||
name: form.name,
|
||||
// 预览口径与抽屉当前主体一致:部门主体走 UserBase 目录成员通道解析(不回退本地科室)
|
||||
type: form.type,
|
||||
deptKey: form.deptKey,
|
||||
deptRefs: [...form.deptRefs],
|
||||
userIds: [...form.userIds],
|
||||
countersign: form.countersign,
|
||||
assignPolicy: form.assignEnabled
|
||||
? {
|
||||
mode: form.assignMode,
|
||||
min: form.assignMin,
|
||||
max: form.assignMax,
|
||||
required: form.assignRequired,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function onPreviewCandidates(): void {
|
||||
if (form.type === "DEPT") {
|
||||
Message.warning("部门(UserBase)主体的设计期候选人预览尚未接入,实际办理人由服务端在发起时按目录解析");
|
||||
return;
|
||||
}
|
||||
previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid);
|
||||
}
|
||||
|
||||
function onOk(): void {
|
||||
const n = props.node;
|
||||
if (!n) {
|
||||
@@ -114,11 +568,22 @@ function onOk(): void {
|
||||
Message.warning("节点名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (form.displayMode === "DEPT" && !form.displayLabel.trim()) {
|
||||
Message.warning("按部门显示时展示名称必填(发布校验会拒绝空值)");
|
||||
return;
|
||||
}
|
||||
n.name = form.name.trim();
|
||||
n.assignee =
|
||||
form.type === "USERS"
|
||||
? { type: "USERS", userIds: [...form.userIds] }
|
||||
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||
if (form.type === "USERS") n.assignee = { type: "USERS", userIds: [...form.userIds] };
|
||||
else if (form.type === "DEPT") {
|
||||
// 只存类型化引用(departmentType + departmentId),学校范围由后端从 CAS 会话派生
|
||||
n.assignee = {
|
||||
type: "DEPT",
|
||||
deptRefs: form.deptRefs.map((r) => ({
|
||||
departmentType: r.departmentType,
|
||||
departmentId: r.departmentId,
|
||||
})),
|
||||
};
|
||||
} else n.assignee = { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||
n.countersign = form.countersign;
|
||||
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
|
||||
// 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点;
|
||||
@@ -127,6 +592,15 @@ function onOk(): void {
|
||||
n.outlets = ["同意", "退回"];
|
||||
n.branches = [[createApproval()], [createApproval()]];
|
||||
}
|
||||
n.assignPolicy = form.assignEnabled
|
||||
? {
|
||||
mode: form.assignMode,
|
||||
...(form.assignMode === "MULTI" ? { min: form.assignMin || 1, max: form.assignMax ?? undefined } : {}),
|
||||
required: form.assignRequired,
|
||||
}
|
||||
: undefined;
|
||||
n.displayMode = form.displayMode || undefined;
|
||||
n.displayLabel = form.displayLabel.trim() || undefined;
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
@@ -153,22 +627,103 @@ function onRemove(): void {
|
||||
<div class="fld">
|
||||
<label>审批人类型</label>
|
||||
<RadioGroup v-model="form.type" type="button" button-style="solid">
|
||||
<Radio label="DEPT_KEY">按科室映射</Radio>
|
||||
<Radio label="USERS">指定用户</Radio>
|
||||
<Radio label="DEPT">部门</Radio>
|
||||
<Radio label="USERS">指定人员</Radio>
|
||||
<Radio v-if="form.type === 'DEPT_KEY'" label="DEPT_KEY">本地科室(旧机制)</Radio>
|
||||
</RadioGroup>
|
||||
<div class="hint">部门取 UserBase 权威部门目录;指定人员逐个点名;本地科室是 FL 旧表,只读兼容</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.type === 'DEPT_KEY'" class="fld">
|
||||
<div v-if="form.type === 'DEPT'" class="fld">
|
||||
<label>部门(UserBase 目录)</label>
|
||||
<Select v-model="ubGroupKey" placeholder="选择部门组(系统组织 / 自定义分类)" :loading="ubLoading">
|
||||
<Option v-for="g in ubGroupItems" :key="g.key" :value="g.key" :label="g.label" />
|
||||
</Select>
|
||||
<div v-if="ubErr" class="hint warn">
|
||||
⚠ {{ ubErr }}
|
||||
<Button size="small" style="margin-left: 6px" @click="ubRetry">重试</Button>
|
||||
</div>
|
||||
<div v-else-if="!ubGroupItems.length && !ubLoading" class="hint warn">
|
||||
⚠ 部门目录没有返回任何部门组(系统组织与本校自定义分类均为空)
|
||||
</div>
|
||||
|
||||
<div class="ub-picked">
|
||||
<Tag
|
||||
v-for="r in form.deptRefs"
|
||||
:key="ubKeyOf(r.departmentType, r.departmentId)"
|
||||
closable
|
||||
color="blue"
|
||||
@on-close="removeUbRef(r)"
|
||||
>
|
||||
{{ ubNameOf(r) }} · {{ ubMemberCountOf(r) }} 人
|
||||
</Tag>
|
||||
<span v-if="!form.deptRefs.length" class="hint">尚未选择部门</span>
|
||||
</div>
|
||||
<div v-for="w in ubWarnings" :key="w.key" class="hint warn">⚠ {{ w.text }}</div>
|
||||
|
||||
<Input
|
||||
v-model="ubKeyword"
|
||||
size="small"
|
||||
placeholder="按名称筛选(跨层级;留空则逐级浏览)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<div class="ub-nav">
|
||||
<Button size="small" :disabled="ubNav.length <= 1" @click="ubUp">‹ 上级</Button>
|
||||
<span
|
||||
v-for="(p, i) in ubNav"
|
||||
:key="`${p.id}-${i}`"
|
||||
class="ub-crumb"
|
||||
:class="{ last: i === ubNav.length - 1 }"
|
||||
@click="ubJump(i)"
|
||||
>
|
||||
{{ i > 0 ? "/" : "" }}{{ p.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ub-list">
|
||||
<div
|
||||
v-for="o in ubVisible"
|
||||
:key="ubKeyOf(o.departmentType, o.departmentId)"
|
||||
class="ub-row"
|
||||
:class="{ off: !o.selectable }"
|
||||
>
|
||||
<span class="ub-check" @click="toggleUbDept(o)">{{ isUbPicked(o) ? "☑" : "☐" }}</span>
|
||||
<span class="ub-name" @click="toggleUbDept(o)">{{ o.name }}</span>
|
||||
<span v-if="o.categoryName" class="ub-cat">{{ o.categoryName }}</span>
|
||||
<span class="ub-count">{{ o.memberCount }} 人</span>
|
||||
<span v-if="!o.selectable" class="ub-offtag">{{ o.deleted ? "已删除" : !o.enabled ? "已停用" : "无成员" }}</span>
|
||||
<span v-if="ubHasChildren(o)" class="ub-drill" @click="ubDrill(o)">下级 ▸</span>
|
||||
</div>
|
||||
<div v-if="ubLoading" class="ub-empty">部门加载中…</div>
|
||||
<div v-else-if="!ubVisible.length" class="ub-empty">
|
||||
{{ ubGroupKey ? "当前层级/关键字下没有部门" : "请先选择部门组" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ub-foot">
|
||||
<Checkbox v-model="ubAddChildren">含下级部门</Checkbox>
|
||||
<Button size="small" type="primary" ghost :disabled="!ubPickTargets.length" @click="addUbBatch">
|
||||
加入 {{ ubPickTargets.length }} 个部门
|
||||
</Button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
引用按「目录类型 + 部门 id」成对保存(同值 id 在系统组织与自定义部门下不等价);
|
||||
本校范围由后端从管理员 CAS 会话取,前端不传
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.type === 'DEPT_KEY'" class="fld">
|
||||
<label>科室映射</label>
|
||||
<Select v-model="form.deptKey" filterable placeholder="请选择科室">
|
||||
<Option v-for="o in deptOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
<Option v-for="o in legacyDeptOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
<div v-if="deptHint" class="hint warn">⚠ {{ deptHint }}</div>
|
||||
<div v-if="legacyDeptHint" class="hint warn">⚠ {{ legacyDeptHint }}</div>
|
||||
<div v-else class="hint">科室 → 办理人的映射在「办理人映射」页维护;发布时校验至少 1 名启用办理人</div>
|
||||
<div class="hint warn">
|
||||
⚠ 本地科室(FL 的 flw_dept 表)是旧机制:本节点的历史引用可原样保存,
|
||||
但切到「部门」或「指定人员」后不能再选回来,新配置请改用 UserBase 部门
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="fld">
|
||||
<div v-else class="fld">
|
||||
<label>指定用户</label>
|
||||
<div class="user-add">
|
||||
<Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
|
||||
@@ -187,8 +742,42 @@ function onRemove(): void {
|
||||
</div>
|
||||
<div class="hint">会签时每名用户各自产生一条待办任务</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) -->
|
||||
<div class="fld">
|
||||
<Button size="small" type="primary" ghost @click="onPreviewCandidates">预览候选人</Button>
|
||||
<div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点显示方式(C3):与审批主体分开的独立规则,不并入上方主体块 -->
|
||||
<div class="fld sec">
|
||||
<label>节点显示方式</label>
|
||||
<RadioGroup v-model="displayModeModel" type="button" button-style="solid">
|
||||
<Radio label="USER">显示人员</Radio>
|
||||
<Radio label="DEPT">显示部门</Radio>
|
||||
</RadioGroup>
|
||||
<div class="hint">显示人员:只展示本节点自己配置/解析出的办理人,不从成员所属部门反推</div>
|
||||
<template v-if="displayModeModel === 'DEPT'">
|
||||
<div class="sub-label">展示名称<span class="req">必填</span></div>
|
||||
<Input v-model="form.displayLabel" :maxlength="30" placeholder="如:教务处" />
|
||||
<div v-if="!form.displayLabel.trim()" class="hint warn">
|
||||
⚠ 展示名称为空时发布会被拦截(后端校验 displayLabel 非空)
|
||||
</div>
|
||||
<div v-else class="hint">展示名称与审批主体分开保存:已默认填入所选部门名,可手工改成对外想叫的名字</div>
|
||||
<div v-if="form.type === 'USERS'" class="hint">
|
||||
当前主体是指定人员:展示名不由系统推断,请管理员自行填写
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 高级能力:默认收起,已配置时自动展开并在收起态标出条目(不删除、不改语义) -->
|
||||
<div class="adv-head" @click="advOpen = !advOpen">
|
||||
<span class="adv-caret">{{ advOpen ? "▾" : "▸" }}</span>
|
||||
<span class="adv-title">高级设置</span>
|
||||
<span v-if="!advOpen && activeAdvanced.length" class="adv-badge">已配置 {{ activeAdvanced.join("、") }}</span>
|
||||
<span v-else-if="!advOpen" class="adv-sub">会签/或签 · 发起时指派 · 办理时限 · 多出口路由</span>
|
||||
</div>
|
||||
<div v-show="advOpen" class="adv-body">
|
||||
<div class="fld">
|
||||
<label>多人审批方式</label>
|
||||
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
|
||||
@@ -200,6 +789,32 @@ function onRemove(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<label>发起时指派</label>
|
||||
<div class="mo-row">
|
||||
<i-switch v-model="form.assignEnabled" />
|
||||
<span class="mo-label">允许发起人在提交时指定本节点审批人</span>
|
||||
</div>
|
||||
<template v-if="form.assignEnabled">
|
||||
<div class="mo-row" style="margin-top: 8px">
|
||||
<RadioGroup v-model="form.assignMode" type="button" button-style="solid" size="small">
|
||||
<Radio label="SINGLE">单选</Radio>
|
||||
<Radio label="MULTI">可多选</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div v-if="form.assignMode === 'MULTI'" class="mo-row" style="margin-top: 8px">
|
||||
<span>最少</span>
|
||||
<InputNumber v-model="form.assignMin" :min="1" size="small" style="width: 90px" />
|
||||
<span>最多</span>
|
||||
<InputNumber v-model="form.assignMax" :min="1" size="small" style="width: 90px" placeholder="不限" />
|
||||
</div>
|
||||
<div class="mo-row" style="margin-top: 8px">
|
||||
<Checkbox v-model="form.assignRequired">提交时必选(子应用提交页提示)</Checkbox>
|
||||
</div>
|
||||
</template>
|
||||
<div class="hint">开启后,发起人在子应用提交时可从本节点候选审批人中指定实际审核人;未指定则全体候选按上方方式审批。或签/会签语义不变,仅收窄人选</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<label>办理时限(小时)</label>
|
||||
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
||||
@@ -217,6 +832,7 @@ function onRemove(): void {
|
||||
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="dw-foot">
|
||||
@@ -235,6 +851,8 @@ function onRemove(): void {
|
||||
:userids="form.userIds"
|
||||
@on-submit="onUsersPicked"
|
||||
/>
|
||||
|
||||
<AssigneePreviewModal ref="previewModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -250,6 +868,19 @@ function onRemove(): void {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sec {
|
||||
border-top: 1px dashed #e8eaec;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.sub-label {
|
||||
margin: 10px 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.req {
|
||||
margin-left: 6px;
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
.hint {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
@@ -259,6 +890,32 @@ function onRemove(): void {
|
||||
.hint.warn {
|
||||
color: #ff9900;
|
||||
}
|
||||
.adv-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
margin: 4px 0 12px;
|
||||
border-top: 1px dashed #e8eaec;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.adv-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.adv-sub,
|
||||
.adv-badge {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.adv-badge {
|
||||
color: #ff9900;
|
||||
}
|
||||
.adv-body {
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #f0f0f0;
|
||||
}
|
||||
.user-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -269,6 +926,84 @@ function onRemove(): void {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
.ub-picked {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
.ub-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ub-crumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-crumb.last {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
.ub-list {
|
||||
margin-top: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ub-row:hover {
|
||||
background: #f8f8f9;
|
||||
}
|
||||
.ub-row.off {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.ub-check {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-name {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-cat {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-count {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-offtag {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-drill {
|
||||
color: #2d8cf0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-empty {
|
||||
padding: 8px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.mo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 审批节点候选人预览弹窗(编辑器内入口,验收报告 P2-1)。
|
||||
* 只读展示 resolveNodePreview 的结果:草稿解析 + 已发布版本对账 + 明确的不可预览原因。
|
||||
* 关闭不做任何写回——它只接收抽屉里的本地表单快照。
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
import { Modal, Alert, Tag, Button } from "view-ui-plus";
|
||||
import { resolveNodePreview, type PreviewApi, type PreviewDept, type PreviewNodeConfig } from "../assignPreview";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
const api: PreviewApi = {
|
||||
deptMembers: (deptId) => flowApi.deptMembers(deptId).send() as Promise<any[]>,
|
||||
deptDirectoryMembers: (type, departmentId, scope) =>
|
||||
flowApi.deptDirectoryMembers(type, departmentId, scope as "select" | "history" | undefined).send() as Promise<any[]>,
|
||||
lookupUser: (userId) => flowApi.mapLookupUser(userId).send() as Promise<any>,
|
||||
applyPreview: (flowGuid) => flowApi.applyPreview(flowGuid).send() as Promise<any>,
|
||||
};
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const title = ref("预览候选人");
|
||||
const result = ref<Awaited<ReturnType<typeof resolveNodePreview>> | null>(null);
|
||||
|
||||
async function show(cfg: PreviewNodeConfig, depts: PreviewDept[], flowGuid = ""): Promise<void> {
|
||||
visible.value = true;
|
||||
loading.value = true;
|
||||
result.value = null;
|
||||
title.value = `预览候选人 · ${cfg.name || "未命名节点"}`;
|
||||
try {
|
||||
result.value = await resolveNodePreview(cfg, depts, api, flowGuid);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model="visible" :title="title" :width="620" :footer-hide="true">
|
||||
<div v-if="loading" class="muted" style="padding: 24px 0; text-align: center">正在解析候选人…</div>
|
||||
<template v-else-if="result">
|
||||
<Alert v-if="result.blocked" type="warning" show-icon class="pv-block">{{ result.blocked }}</Alert>
|
||||
<div class="pv-headline">
|
||||
<b>{{ result.headline }}</b>
|
||||
<span class="pv-source">数据源:{{ result.rows.length ? "FL 科室映射 / UserBase 既有接口" : "未取到候选" }}</span>
|
||||
</div>
|
||||
<div v-if="result.rows.length" class="pv-rows">
|
||||
<div v-for="r in result.rows" :key="r.userId" class="pv-row">
|
||||
<Tag :color="r.warn ? 'warning' : 'blue'">{{ r.userName }}</Tag>
|
||||
<span class="pv-id">#{{ r.userId }}</span>
|
||||
<span v-if="r.warn" class="pv-warn">{{ r.warn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">(本节点当前没有可展示的候选人)</div>
|
||||
|
||||
<div class="pv-pub" v-if="result.published">
|
||||
<div class="pv-pub-title">已发布版本对账</div>
|
||||
<div class="pv-pub-body">
|
||||
<Tag :color="result.published.state === 'ok' ? 'success' : result.published.state === 'missing' ? 'default' : 'warning'">
|
||||
{{ result.published.state === "ok" ? "可比对" : result.published.state === "missing" ? "草稿节点未发布" : "不可比对" }}
|
||||
</Tag>
|
||||
<span>{{ result.published.text }}</span>
|
||||
</div>
|
||||
<div v-if="result.published.state === 'ok' && result.published.rows.length" class="pv-rows">
|
||||
<Tag v-for="p in result.published.rows" :key="p.userId" color="green">{{ p.userName }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="pv-notes">
|
||||
<li v-for="(n, i) in result.notes" :key="i">{{ n }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div style="margin-top: 12px; text-align: right">
|
||||
<Button @click="visible = false">关闭</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pv-block { margin-bottom: 10px; }
|
||||
.pv-headline { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.pv-source { color: #808695; font-size: 12px; }
|
||||
.pv-rows { display: flex; flex-wrap: wrap; gap: 6px 0; margin-bottom: 12px; }
|
||||
.pv-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.pv-id { color: #c5c8ce; font-size: 12px; }
|
||||
.pv-warn { color: #ff9900; font-size: 12px; }
|
||||
.pv-pub { border-top: 1px dashed #e8eaec; padding-top: 10px; margin-bottom: 10px; }
|
||||
.pv-pub-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
|
||||
.pv-pub-body { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; }
|
||||
.pv-notes { margin: 0; padding-left: 18px; color: #808695; font-size: 12px; line-height: 1.7; }
|
||||
</style>
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
// 条件分支容器(2026-09-24):紫系区分并行橙;与 ParallelBox 的本质差异——无 JOIN 语义,
|
||||
// 分支按出口名互斥选路(运行时调判断服务),分支尾隐式汇合到容器后继。
|
||||
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约)。
|
||||
import { computed, inject } from "vue";
|
||||
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
|
||||
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
|
||||
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
|
||||
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
|
||||
import { computed, inject, nextTick, ref } from "vue";
|
||||
import {
|
||||
BRANCH_LABELS,
|
||||
EDITOR_CTX,
|
||||
EXPR_BUILTIN_SET,
|
||||
ConditionNode,
|
||||
MAX_BRANCHES,
|
||||
MIN_BRANCHES,
|
||||
extractExprVars,
|
||||
} from "../model";
|
||||
import NodeList from "./NodeList.vue";
|
||||
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
|
||||
|
||||
const props = defineProps<{ node: ConditionNode }>();
|
||||
|
||||
@@ -14,8 +24,10 @@ 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 > MIN_BRANCHES);
|
||||
const canAddBranch = computed(() => props.node.branches.length < MAX_BRANCHES);
|
||||
const expression = computed(() => props.node.judgeMode === "EXPRESSION");
|
||||
|
||||
const routerLabel = computed(() => {
|
||||
if (expression.value) return "表达式判断(按分支顺序取首个为真)";
|
||||
if (!props.node.routerKey) return "判断服务:未选择";
|
||||
const r = ctx.routers.find((x) => x.routerKey === props.node.routerKey);
|
||||
return r ? `判断服务:${r.name}(${props.node.routerKey})` : `判断服务:${props.node.routerKey}`;
|
||||
@@ -25,19 +37,69 @@ const routerLabel = computed(() => {
|
||||
function renameOutlet(i: number, v: string): void {
|
||||
props.node.outlets[i] = v;
|
||||
}
|
||||
|
||||
/** 出口条件就地编辑(EXPRESSION 模式;与 outlets 下标一一对应) */
|
||||
function editCondition(i: number, v: string): void {
|
||||
props.node.conditions = props.node.conditions || [];
|
||||
while (props.node.conditions.length <= i) props.node.conditions.push("");
|
||||
props.node.conditions[i] = v;
|
||||
}
|
||||
|
||||
// B5 内联试算:把该出口表达式带进共享调试弹窗(ExprEvalModal)
|
||||
const tryVisible = ref(false);
|
||||
const tryExpr = ref("");
|
||||
|
||||
function openTry(i: number): void {
|
||||
tryExpr.value = (props.node.conditions || [])[i] || "";
|
||||
tryVisible.value = true;
|
||||
}
|
||||
|
||||
// ==================== 可用变量 chips(2026-10-14 议题一 P0):内联提示 + 点击插入 ====================
|
||||
/** 可用变量(表单字段聚合键 + 内置),来自编辑器上下文;未拉到为空数组则不展示 chips */
|
||||
const availableVars = computed(() => ctx.availableVars || []);
|
||||
const isBuiltin = (k: string): boolean => EXPR_BUILTIN_SET.has(k);
|
||||
|
||||
const condInputs = ref<Record<number, HTMLInputElement | null>>({});
|
||||
function setCondInput(el: any, i: number): void {
|
||||
condInputs.value[i] = (el as HTMLInputElement) || null;
|
||||
}
|
||||
|
||||
/** chips 点击:插入到该出口表达式光标处(无输入框时追加到末尾) */
|
||||
function insertChip(i: number, key: string): void {
|
||||
const cur = (props.node.conditions || [])[i] || "";
|
||||
const el = condInputs.value[i];
|
||||
const s = el?.selectionStart ?? cur.length;
|
||||
const e = el?.selectionEnd ?? cur.length;
|
||||
editCondition(i, cur.slice(0, s) + key + cur.slice(e));
|
||||
nextTick(() => {
|
||||
const input = condInputs.value[i];
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.setSelectionRange(s + key.length, s + key.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 该出口表达式里不在可用列表中的标识符(疑似拼错提示,仅提示不拦截) */
|
||||
function unknownVars(i: number): string[] {
|
||||
const known = new Set<string>([...availableVars.value, ...EXPR_BUILTIN_SET]);
|
||||
return extractExprVars((props.node.conditions || [])[i] || "").filter((v) => !known.has(v));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="condition-box" :class="{ error: hasError }">
|
||||
<div :id="'nd-' + node.uid" class="condition-box" :class="{ error: hasError }">
|
||||
<div class="cb-title" @click="ctx.openNode(node.uid)">
|
||||
条件分支(按出口互斥选路)
|
||||
<span class="cb-hint">(点击标题配置判断服务)</span>
|
||||
<span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span>
|
||||
</div>
|
||||
<div class="cb-router">{{ routerLabel }}</div>
|
||||
<div class="cb-cols">
|
||||
<div v-for="(branch, i) in node.branches" :key="i" class="cb-col">
|
||||
<div class="cb-col-head">
|
||||
<span class="cb-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
|
||||
<span class="cb-col-no" :title="expression ? '表达式求值优先级' : undefined">
|
||||
{{ BRANCH_LABELS[i] || i + 1 }}
|
||||
</span>
|
||||
<input
|
||||
class="cb-outlet"
|
||||
:value="node.outlets[i]"
|
||||
@@ -57,13 +119,44 @@ function renameOutlet(i: number, v: string): void {
|
||||
<span class="cb-col-del" title="删除分支">×</span>
|
||||
</Poptip>
|
||||
</div>
|
||||
<div v-if="expression" class="cb-cond-row">
|
||||
<input
|
||||
:ref="(el) => setCondInput(el, i)"
|
||||
class="cb-cond"
|
||||
:value="(node.conditions || [])[i] || ''"
|
||||
placeholder="条件表达式,如 amount > 1000"
|
||||
maxlength="200"
|
||||
@input="editCondition(i, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
|
||||
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
|
||||
</div>
|
||||
<!-- 议题一 P0:可用变量 chips(表单键 + 内置),点击插入到表达式光标处 -->
|
||||
<div v-if="expression && availableVars.length" class="cb-vars">
|
||||
<span
|
||||
v-for="k in availableVars.slice(0, 14)"
|
||||
:key="k"
|
||||
class="cb-var"
|
||||
:class="{ bt: isBuiltin(k) }"
|
||||
:title="isBuiltin(k) ? '内置变量(引擎运行时注入)' : '表单变量(来自近期办件 formData)'"
|
||||
@click.stop="insertChip(i, k)"
|
||||
>{{ k }}</span>
|
||||
<span v-if="availableVars.length > 14" class="cb-vars-more" :title="availableVars.join('、')">
|
||||
+{{ availableVars.length - 14 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="(v, vi) in unknownVars(i)" :key="'u' + vi" class="cb-unknown">
|
||||
⚠ 「{{ v }}」不在可用变量列表,若为动态变量可忽略
|
||||
</div>
|
||||
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
|
||||
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canAddBranch" class="cb-add" size="small" long @click="ctx.addBranch(node.uid)">
|
||||
+ 添加分支
|
||||
</Button>
|
||||
<div v-for="(issue, k) in issues" :key="k" class="cb-issue" :class="issue.level">⚠ {{ issue.text }}</div>
|
||||
<ExprEvalModal v-model="tryVisible" :preset-expression="tryExpr" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -98,6 +191,16 @@ function renameOutlet(i: number, v: string): void {
|
||||
color: #9254de;
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.cb-empty {
|
||||
margin: 4px 0 8px;
|
||||
padding: 8px 6px;
|
||||
border: 1px dashed #d3c6e8;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #9254de;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.cb-cols {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
@@ -142,6 +245,96 @@ function renameOutlet(i: number, v: string): void {
|
||||
outline: none;
|
||||
border-color: #9254de;
|
||||
}
|
||||
.cb-cond {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #d3b8f9;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #5b3fa8;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cb-cond:focus {
|
||||
outline: none;
|
||||
border-color: #9254de;
|
||||
border-style: solid;
|
||||
}
|
||||
.cb-cond::placeholder {
|
||||
color: #c5b3e8;
|
||||
}
|
||||
.cb-cond-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cb-cond-row .cb-cond {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cb-try {
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #0b7d72;
|
||||
border: 1px solid #b7e3dc;
|
||||
border-radius: 10px;
|
||||
padding: 0 8px;
|
||||
line-height: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
.cb-try:hover {
|
||||
background: #f2fbf9;
|
||||
border-color: #0b7d72;
|
||||
}
|
||||
.cb-vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin: -2px 0 8px;
|
||||
}
|
||||
.cb-var {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #b7e3dc;
|
||||
background: #f2fbf9;
|
||||
color: #0b7d72;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
.cb-var:hover {
|
||||
background: #0b7d72;
|
||||
color: #fff;
|
||||
border-color: #0b7d72;
|
||||
}
|
||||
.cb-var.bt {
|
||||
border-color: #dcdee2;
|
||||
background: #f5f7fa;
|
||||
color: #4e5969;
|
||||
}
|
||||
.cb-var.bt:hover {
|
||||
background: #4e5969;
|
||||
color: #fff;
|
||||
border-color: #4e5969;
|
||||
}
|
||||
.cb-vars-more {
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
padding: 0 8px;
|
||||
color: #86909c;
|
||||
cursor: default;
|
||||
}
|
||||
.cb-unknown {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #ff9900;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.cb-col-del {
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 条件分支配置抽屉(2026-09-24):结构与 NotifyDrawer 同源(本地表单 + 确定回写)。
|
||||
// 只管节点名与判断服务(flw_router 下拉);出口命名/分支编排 ConditionBox 就地做。
|
||||
// 管节点名与判断方式(2026-10-10 二选一:服务/表达式);出口命名/条件表达式/分支编排 ConditionBox 就地做。
|
||||
import { computed, inject, reactive, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { ConditionNode, EDITOR_CTX } from "../model";
|
||||
import { ConditionNode, EDITOR_CTX, JudgeMode } from "../model";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -21,6 +21,8 @@ const ctx = inject(EDITOR_CTX)!;
|
||||
const form = reactive({
|
||||
name: "",
|
||||
routerKey: "",
|
||||
// 2026-10-12 SERVICE 收口:新建缺省翻转为表达式(主路径);存量归一化见 syncFromNode(勿动)
|
||||
judgeMode: "EXPRESSION" as JudgeMode,
|
||||
});
|
||||
|
||||
function syncFromNode(): void {
|
||||
@@ -28,6 +30,7 @@ function syncFromNode(): void {
|
||||
if (!n) return;
|
||||
form.name = n.name;
|
||||
form.routerKey = n.routerKey || "";
|
||||
form.judgeMode = n.judgeMode === "EXPRESSION" ? "EXPRESSION" : "SERVICE";
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -56,6 +59,16 @@ const routerHint = computed(() => {
|
||||
return `调用 ${r.serviceKey}${r.path};返回出口名即分支走向`;
|
||||
});
|
||||
|
||||
/** 切换判断方式:表达式模式下 conditions 补齐占位(与 outlets 同位) */
|
||||
function switchJudgeMode(mode: JudgeMode): void {
|
||||
form.judgeMode = mode;
|
||||
const n = props.node;
|
||||
if (n && mode === "EXPRESSION") {
|
||||
n.conditions = n.conditions || [];
|
||||
while (n.conditions.length < n.outlets.length) n.conditions.push("");
|
||||
}
|
||||
}
|
||||
|
||||
function onOk(): void {
|
||||
const n = props.node;
|
||||
if (!n) {
|
||||
@@ -66,12 +79,17 @@ function onOk(): void {
|
||||
Message.warning("节点名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (!form.routerKey) {
|
||||
if (form.judgeMode === "SERVICE" && !form.routerKey) {
|
||||
Message.warning("请选择判断服务(未配置的判断服务运行到该节点会挂起)");
|
||||
return;
|
||||
}
|
||||
n.name = form.name.trim();
|
||||
n.routerKey = form.routerKey;
|
||||
n.routerKey = form.judgeMode === "SERVICE" ? form.routerKey : "";
|
||||
n.judgeMode = form.judgeMode;
|
||||
if (form.judgeMode === "EXPRESSION") {
|
||||
n.conditions = n.conditions || [];
|
||||
while (n.conditions.length < n.outlets.length) n.conditions.push("");
|
||||
}
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
@@ -96,6 +114,24 @@ function onRemove(): void {
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<label>判断依据数据什么时候能拿到</label>
|
||||
<RadioGroup :model-value="form.judgeMode" @on-change="switchJudgeMode">
|
||||
<Radio label="EXPRESSION">
|
||||
<span class="rd-t">提交时就已确定</span>
|
||||
<span class="rd-d">用表达式判断(推荐)。变量随单据一起提交,编辑器内闭环,零对接。</span>
|
||||
</Radio>
|
||||
<Radio label="SERVICE">
|
||||
<span class="rd-t">到达该节点时才能确定</span>
|
||||
<span class="rd-d">需实时查库 / 问外部系统时使用。由子应用的判断服务返回出口。</span>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<div class="hint">
|
||||
原措辞为「EXPRESSION / SERVICE」——引擎实现细节,业务管理员难以判断,选错即返工;
|
||||
改按「数据何时可知」提问,配置者无需理解引擎实现
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.judgeMode === 'SERVICE'" class="fld">
|
||||
<label>判断服务</label>
|
||||
<Select v-model="form.routerKey" filterable placeholder="请选择判断服务(flw_router 注册)">
|
||||
<Option v-for="o in routerOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
@@ -106,6 +142,20 @@ function onRemove(): void {
|
||||
<div v-else class="hint">
|
||||
判断逻辑由业务方实现:引擎运行到该节点时同步调用判断接口,按返回出口名走对应分支
|
||||
</div>
|
||||
<div class="hint">
|
||||
仅当判断依赖流程到达时点的实时业务数据时使用;简单条件请用"表达式"(apply 时 formData
|
||||
自带判断数据)。判断端点与登记由
|
||||
<code>yh-flow-callback-spring-boot-starter</code>(流程中心接入件)自动完成,见《子应用接入流程中心规范》§6.6。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fld">
|
||||
<label>表达式说明</label>
|
||||
<div class="hint">
|
||||
每个出口在分支列头部就地填写 AviatorScript 条件(如 amount > 1000);按分支顺序取
|
||||
首个为真的出口。变量=表单字段 + 内置(schId/bizType/bizId/submitterId/instanceId 等)。
|
||||
变量未提供或条件全不为真时流程挂起(不配默认出口),可在"条件路由"页调试表达式
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
@@ -150,6 +200,19 @@ function onRemove(): void {
|
||||
.hint.warn {
|
||||
color: #ff9900;
|
||||
}
|
||||
/* 判断方式业务语言化(2026-10-15):标签 + 说明两行式,配置者按"数据何时可知"作答 */
|
||||
.rd-t {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rd-d {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
.dw-foot {
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
|
||||
@@ -20,7 +20,7 @@ function onOpen(e: MouseEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="node-card" :class="{ error: hasError }" @click="onOpen">
|
||||
<div :id="'nd-' + node.uid" class="node-card" :class="{ error: hasError }" @click="onOpen">
|
||||
<div class="nc-head">
|
||||
<span class="nc-icon">审</span>
|
||||
<span class="nc-name">{{ node.name || "未命名节点" }}</span>
|
||||
@@ -55,7 +55,7 @@ function onOpen(e: MouseEvent): void {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.node-card:hover {
|
||||
border-color: #2d8cf0;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.node-card.error {
|
||||
border-color: #ed4014;
|
||||
@@ -72,7 +72,7 @@ function onOpen(e: MouseEvent): void {
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
background: #e8f3ff;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -19,7 +19,7 @@ function onOpen(e: MouseEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notify-card" :class="{ error: hasError }" @click="onOpen">
|
||||
<div :id="'nd-' + node.uid" class="notify-card" :class="{ error: hasError }" @click="onOpen">
|
||||
<div class="nc-head">
|
||||
<span class="nc-icon">抄</span>
|
||||
<span class="nc-name">{{ node.name || "未命名节点" }}</span>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// 抄送节点配置抽屉(2026-09-23):结构与 ApprovalDrawer 同源(本地表单 + 确定回写),
|
||||
// 去掉审批专属的会签/时限;选人同用 UserCheckModal(teacherTree),科室口径同审批人。
|
||||
// 去掉审批专属的会签/时限;选人同用 UserCheckModal(teacherTree),主体口径同审批人
|
||||
// (C1:新配置只有 UserBase 部门 / 指定人员,本地科室 DEPT_KEY 只读兼容,切走不可再选)。
|
||||
// 抄送不配展示规则(C3 只作用于审批节点)。
|
||||
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 { NotifyNode, EDITOR_CTX } from "../model";
|
||||
import type { DeptRef } from "../model";
|
||||
|
||||
/** 抄送对象三态:DEPT=UserBase 部门(新)|USERS=指定人员|DEPT_KEY=本地科室(只读兼容) */
|
||||
type SubjectType = "DEPT" | "DEPT_KEY" | "USERS";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -22,8 +28,9 @@ const ctx = inject(EDITOR_CTX)!;
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
type: "DEPT_KEY" as "DEPT_KEY" | "USERS",
|
||||
type: "DEPT" as SubjectType,
|
||||
deptKey: "",
|
||||
deptRefs: [] as DeptRef[],
|
||||
userIds: [] as number[],
|
||||
});
|
||||
|
||||
@@ -35,13 +42,67 @@ const userModalSources = [
|
||||
},
|
||||
];
|
||||
|
||||
// ==================== 部门主体(UserBase 目录)状态 ====================
|
||||
interface UbDept {
|
||||
departmentType: "SYSTEM" | "CUSTOM";
|
||||
departmentId: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
enabled: boolean;
|
||||
deleted: boolean;
|
||||
selectable: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
/** 引用回显:只有 deptResolve 能给出已停用/已软删部门的名称,故选中与载入都归一到这张表 */
|
||||
interface UbInfo {
|
||||
categoryId?: number | null;
|
||||
name: string;
|
||||
memberCount: number;
|
||||
warn: string;
|
||||
}
|
||||
|
||||
const ubGroups = ref<any[]>([]);
|
||||
const ubGroupKey = ref("");
|
||||
const ubOptions = ref<UbDept[]>([]);
|
||||
/** 逐级下钻路径(末项即当前层;id=null 为组根) */
|
||||
const ubNav = ref<{ id: number | null; name: string }[]>([]);
|
||||
const ubKeyword = ref("");
|
||||
const ubAddChildren = ref(false);
|
||||
const ubLoading = ref(false);
|
||||
const ubErr = ref("");
|
||||
const ubInfo = reactive<Record<string, UbInfo>>({});
|
||||
/** 并发去重:载入节点与切主体可能同时触发,早退会让自动选组落空,故复用同一 in-flight 请求。
|
||||
* 必须与上面的状态同批声明——syncFromNode 的 immediate watch 会同步走到 loadUbGroups,声明靠后会 TDZ */
|
||||
let ubGroupsPromise: Promise<void> | null = null;
|
||||
|
||||
function ubKeyOf(type: string, id: number | string): string {
|
||||
return `${type}:${id}`;
|
||||
}
|
||||
|
||||
function syncFromNode(): void {
|
||||
ubGroupKey.value = "";
|
||||
const n = props.node;
|
||||
if (!n) return;
|
||||
form.name = n.name;
|
||||
form.type = n.receivers?.type === "USERS" ? "USERS" : "DEPT_KEY";
|
||||
form.deptKey = n.receivers?.type === "DEPT_KEY" ? n.receivers.deptKey : "";
|
||||
form.userIds = n.receivers?.type === "USERS" ? [...n.receivers.userIds] : [];
|
||||
const a = n.receivers;
|
||||
// 旧机制空值不视为"选了本地科室",新节点直接落到部门口径
|
||||
form.type = a && !(a.type === "DEPT_KEY" && !a.deptKey.trim()) ? a.type : "DEPT";
|
||||
form.deptKey = a?.type === "DEPT_KEY" ? a.deptKey : "";
|
||||
// 不带 name:名称只从目录解析回显,避免"存了个当时的名字"被误当关联依据
|
||||
form.deptRefs =
|
||||
a?.type === "DEPT"
|
||||
? (a.deptRefs || []).map((r) => ({
|
||||
departmentType: r.departmentType,
|
||||
departmentId: Number(r.departmentId),
|
||||
}))
|
||||
: [];
|
||||
form.userIds = a?.type === "USERS" ? [...a.userIds] : [];
|
||||
if (form.type === "DEPT") {
|
||||
void ensureUbContext();
|
||||
void resolveUbRefs();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -52,8 +113,8 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/** 科室下拉项:mapList 聚合 + 当前值(可能已不在映射表中)兜底,避免选项丢失 */
|
||||
const deptOptions = computed(() => {
|
||||
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
|
||||
const legacyDeptOptions = computed(() => {
|
||||
const list = ctx.depts.map((d) => ({
|
||||
value: d.deptKey,
|
||||
label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`,
|
||||
@@ -65,7 +126,7 @@ const deptOptions = computed(() => {
|
||||
});
|
||||
|
||||
/** 当前科室的映射状态提示(抄送运行时宽容:无人只跳过不阻塞,故仅提示不谈"拦截") */
|
||||
const deptHint = computed(() => {
|
||||
const legacyDeptHint = computed(() => {
|
||||
if (form.type !== "DEPT_KEY" || !form.deptKey) return "";
|
||||
const d = ctx.depts.find((x) => x.deptKey === form.deptKey);
|
||||
if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`;
|
||||
@@ -73,6 +134,283 @@ const deptHint = computed(() => {
|
||||
return "";
|
||||
});
|
||||
|
||||
// ==================== 部门目录取数(真实接口,失败如实上抛到界面) ====================
|
||||
const ubGroupItems = computed(() =>
|
||||
ubGroups.value.map((g: any) => {
|
||||
const type: "SYSTEM" | "CUSTOM" = g.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM";
|
||||
const categoryId = g.categoryId == null ? null : Number(g.categoryId);
|
||||
const name = g.name || (type === "SYSTEM" ? "系统组织" : `自定义分类#${categoryId ?? "?"}`);
|
||||
return {
|
||||
key: ubKeyOf(type, categoryId ?? ""),
|
||||
type,
|
||||
categoryId,
|
||||
name,
|
||||
label: `${name}(${type === "SYSTEM" ? "系统组织" : "自定义部门"}·${Number(g.departmentCount) || 0} 个部门)`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function ubGroupByKey(key: string) {
|
||||
return ubGroupItems.value.find((g) => g.key === key) || null;
|
||||
}
|
||||
|
||||
function loadUbGroups(): Promise<void> {
|
||||
if (ubGroups.value.length) return Promise.resolve();
|
||||
if (!ubGroupsPromise) {
|
||||
ubGroupsPromise = (async () => {
|
||||
ubLoading.value = true;
|
||||
ubErr.value = "";
|
||||
try {
|
||||
// 显式泛型的 get<T[]> 在 alova 类型里会把 send() 收成 {},先经 any 落一次再入列表
|
||||
const raw: any = await flowApi.deptGroups().send();
|
||||
ubGroups.value = raw || [];
|
||||
} catch (e: any) {
|
||||
ubErr.value = `部门组加载失败:${e?.message || e}`;
|
||||
} finally {
|
||||
ubLoading.value = false;
|
||||
}
|
||||
})().finally(() => {
|
||||
ubGroupsPromise = null;
|
||||
});
|
||||
}
|
||||
return ubGroupsPromise;
|
||||
}
|
||||
|
||||
async function loadUbOptions(): Promise<void> {
|
||||
const g = ubGroupByKey(ubGroupKey.value);
|
||||
ubOptions.value = [];
|
||||
if (!g) return;
|
||||
ubNav.value = [{ id: null, name: g.name }];
|
||||
ubLoading.value = true;
|
||||
ubErr.value = "";
|
||||
try {
|
||||
// 系统组织是层级树,一次取全子树后在前端按 parentId 重建;自定义分类是平表,不必展开子树
|
||||
const raw: any = await flowApi
|
||||
.deptOptions({
|
||||
type: g.type,
|
||||
categoryId: g.categoryId ?? undefined,
|
||||
withChildren: g.type === "SYSTEM" ? true : undefined,
|
||||
})
|
||||
.send();
|
||||
const rows: any[] = raw || [];
|
||||
ubOptions.value = rows.map((r: any): UbDept => ({
|
||||
departmentType: r.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM",
|
||||
departmentId: Number(r.departmentId),
|
||||
name: r.name || `#${r.departmentId}`,
|
||||
parentId: r.parentId == null ? null : Number(r.parentId),
|
||||
categoryId: r.categoryId == null ? null : Number(r.categoryId),
|
||||
categoryName: r.categoryName || "",
|
||||
enabled: r.enabled !== false,
|
||||
deleted: r.deleted === true,
|
||||
selectable: r.selectable !== false,
|
||||
memberCount: Number(r.memberCount) || 0,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
ubErr.value = `部门列表加载失败:${e?.message || e}`;
|
||||
} finally {
|
||||
ubLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 已存引用逐条解析名称(含停用/软删):这是唯一能让"看不见部门"变成"看得见并警告"的通道 */
|
||||
async function resolveUbRefs(): Promise<void> {
|
||||
for (const r of form.deptRefs) {
|
||||
const key = ubKeyOf(r.departmentType, r.departmentId);
|
||||
if (ubInfo[key]) continue;
|
||||
let row: any;
|
||||
try {
|
||||
const raw: any = await flowApi.deptResolve(r.departmentType, r.departmentId).send();
|
||||
const rows: any[] = raw || [];
|
||||
row = rows.find((x: any) => Number(x.departmentId) === r.departmentId) || rows[0] || null;
|
||||
} catch (e: any) {
|
||||
ubInfo[key] = {
|
||||
name: `${r.departmentType}#${r.departmentId}`,
|
||||
memberCount: 0,
|
||||
warn: `部门信息解析失败:${e?.message || e}`,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!row) {
|
||||
ubInfo[key] = {
|
||||
name: `${r.departmentType}#${r.departmentId}`,
|
||||
memberCount: 0,
|
||||
warn: "UserBase 部门目录已查不到该部门,历史引用仍可解析,新配置请改选其他部门",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
ubInfo[key] = {
|
||||
categoryId: row.categoryId == null ? null : Number(row.categoryId),
|
||||
name: row.name || `#${r.departmentId}`,
|
||||
memberCount: Number(row.memberCount) || 0,
|
||||
warn:
|
||||
row.deleted === true
|
||||
? "该部门已删除,新配置不可选,历史引用仍可解析"
|
||||
: row.enabled === false
|
||||
? "该部门已停用,新配置不可选,历史引用仍可解析"
|
||||
: row.selectable === false
|
||||
? "该部门当前不可选(无成员或未开放),新配置不可选,历史引用仍可解析"
|
||||
: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开/切到部门主体时才取数:不选部门主体的节点(含旧定义)不发多余请求 */
|
||||
async function ensureUbContext(): Promise<void> {
|
||||
await loadUbGroups();
|
||||
await resolveUbRefs();
|
||||
if (ubGroupKey.value) return;
|
||||
const customRef = form.deptRefs.find(r => r.departmentType === "CUSTOM");
|
||||
if (customRef) {
|
||||
let categoryId = ubInfo[ubKeyOf(customRef.departmentType, customRef.departmentId)]?.categoryId;
|
||||
if (categoryId == null) {
|
||||
try {
|
||||
const rows: any = await flowApi.deptOptions({ type: "CUSTOM" }).send();
|
||||
categoryId = (rows || []).find((r: any) => r.departmentType === "CUSTOM" && Number(r.departmentId) === customRef.departmentId)?.categoryId;
|
||||
} catch { /* Keep the original reference when category discovery is unavailable. */ }
|
||||
}
|
||||
const group = ubGroupItems.value.find(g => g.type === "CUSTOM" && g.categoryId === Number(categoryId));
|
||||
if (group) { ubGroupKey.value = group.key; return; }
|
||||
}
|
||||
const sys = ubGroupItems.value.find((g) => g.type === "SYSTEM");
|
||||
if (form.deptRefs.some((r) => r.departmentType === "SYSTEM") && sys) ubGroupKey.value = sys.key;
|
||||
else if (ubGroupItems.value.length === 1) ubGroupKey.value = ubGroupItems.value[0].key;
|
||||
}
|
||||
|
||||
watch(ubGroupKey, () => {
|
||||
void loadUbOptions();
|
||||
});
|
||||
|
||||
async function ubRetry(): Promise<void> {
|
||||
ubGroups.value = [];
|
||||
await loadUbGroups();
|
||||
// 已有组就重载列表;没组则交给 ubGroupKey 的 watch 触发,避免同一次重试发两遍
|
||||
if (ubGroupKey.value) await loadUbOptions();
|
||||
else await ensureUbContext();
|
||||
}
|
||||
|
||||
function ubNameOf(r: DeptRef): string {
|
||||
return ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.name
|
||||
|| ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)?.name
|
||||
|| `${r.departmentType}#${r.departmentId}`;
|
||||
}
|
||||
|
||||
function ubMemberCountOf(r: DeptRef): number {
|
||||
const info = ubInfo[ubKeyOf(r.departmentType, r.departmentId)];
|
||||
if (info) return info.memberCount;
|
||||
return ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)
|
||||
?.memberCount || 0;
|
||||
}
|
||||
|
||||
const ubWarnings = computed(() =>
|
||||
form.deptRefs
|
||||
.map((r) => ({
|
||||
key: ubKeyOf(r.departmentType, r.departmentId),
|
||||
name: ubNameOf(r),
|
||||
warn: ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.warn || "",
|
||||
}))
|
||||
.filter((x) => x.warn)
|
||||
.map((x) => ({ key: x.key, text: `「${x.name}」${x.warn}` }))
|
||||
);
|
||||
|
||||
/** 当前可见部门:有关键字则跨层级检索,否则按 parentId 逐级展开 */
|
||||
const ubVisible = computed<UbDept[]>(() => {
|
||||
const kw = ubKeyword.value.trim();
|
||||
if (kw) return ubOptions.value.filter((o) => o.name.includes(kw));
|
||||
const cur = ubNav.value.length ? ubNav.value[ubNav.value.length - 1].id : null;
|
||||
if (cur == null) {
|
||||
const ids = new Set(ubOptions.value.map((o) => o.departmentId));
|
||||
// 父级不在本组返回集的即为根(自定义分类平表、以及父链断裂的孤儿部门都从根显示,避免看不见)
|
||||
return ubOptions.value.filter((o) => o.parentId == null || !ids.has(o.parentId));
|
||||
}
|
||||
return ubOptions.value.filter((o) => o.parentId === cur);
|
||||
});
|
||||
|
||||
function ubHasChildren(o: UbDept): boolean {
|
||||
return ubOptions.value.some((x) => x.parentId === o.departmentId);
|
||||
}
|
||||
|
||||
function ubDrill(o: UbDept): void {
|
||||
ubNav.value = [...ubNav.value, { id: o.departmentId, name: o.name }];
|
||||
ubKeyword.value = "";
|
||||
}
|
||||
|
||||
function ubUp(): void {
|
||||
if (ubNav.value.length > 1) ubNav.value = ubNav.value.slice(0, -1);
|
||||
}
|
||||
|
||||
function ubJump(i: number): void {
|
||||
ubNav.value = ubNav.value.slice(0, i + 1);
|
||||
}
|
||||
|
||||
function isUbPicked(o: UbDept): boolean {
|
||||
return form.deptRefs.some((r) => r.departmentType === o.departmentType && r.departmentId === o.departmentId);
|
||||
}
|
||||
|
||||
function ubPick(o: UbDept): void {
|
||||
const key = ubKeyOf(o.departmentType, o.departmentId);
|
||||
if (!form.deptRefs.some((r) => ubKeyOf(r.departmentType, r.departmentId) === key)) {
|
||||
form.deptRefs.push({ departmentType: o.departmentType, departmentId: o.departmentId });
|
||||
}
|
||||
if (!ubInfo[key]?.warn) ubInfo[key] = { name: o.name, memberCount: o.memberCount, warn: "" };
|
||||
}
|
||||
|
||||
function toggleUbDept(o: UbDept): void {
|
||||
if (!o.selectable) {
|
||||
Message.warning(`「${o.name}」当前不可选(停用/删除/无成员),不能作为新配置`);
|
||||
return;
|
||||
}
|
||||
const key = ubKeyOf(o.departmentType, o.departmentId);
|
||||
const i = form.deptRefs.findIndex((r) => ubKeyOf(r.departmentType, r.departmentId) === key);
|
||||
if (i >= 0) form.deptRefs.splice(i, 1);
|
||||
else ubPick(o);
|
||||
}
|
||||
|
||||
function removeUbRef(r: DeptRef): void {
|
||||
const key = ubKeyOf(r.departmentType, r.departmentId);
|
||||
const i = form.deptRefs.findIndex((x) => ubKeyOf(x.departmentType, x.departmentId) === key);
|
||||
if (i >= 0) form.deptRefs.splice(i, 1);
|
||||
}
|
||||
|
||||
/** 勾了"含下级"则连同子树一起算(环/父子互指有 seen 兜底) */
|
||||
function ubSubtree(o: UbDept): UbDept[] {
|
||||
const out: UbDept[] = [];
|
||||
const seen = new Set<string>([ubKeyOf(o.departmentType, o.departmentId)]);
|
||||
const stack: UbDept[] = [o];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
out.push(cur);
|
||||
ubOptions.value.forEach((x) => {
|
||||
const k = ubKeyOf(x.departmentType, x.departmentId);
|
||||
if (x.parentId === cur.departmentId && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
stack.push(x);
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ubPickTargets = computed<UbDept[]>(() => {
|
||||
const out: UbDept[] = [];
|
||||
const seen = new Set<string>();
|
||||
ubVisible.value.forEach((o) => {
|
||||
(ubAddChildren.value ? ubSubtree(o) : [o]).forEach((d) => {
|
||||
const k = ubKeyOf(d.departmentType, d.departmentId);
|
||||
if (d.selectable && !seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(d);
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
function addUbBatch(): void {
|
||||
const list = ubPickTargets.value;
|
||||
list.forEach((o) => ubPick(o));
|
||||
Message.success(list.length ? `已加入 ${list.length} 个部门` : "没有可加入的部门");
|
||||
}
|
||||
|
||||
function userNameOf(id: number): string {
|
||||
return ctx.userNames[id] || `#${id}`;
|
||||
}
|
||||
@@ -108,10 +446,17 @@ function onOk(): void {
|
||||
return;
|
||||
}
|
||||
n.name = form.name.trim();
|
||||
n.receivers =
|
||||
form.type === "USERS"
|
||||
? { type: "USERS", userIds: [...form.userIds] }
|
||||
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||
if (form.type === "USERS") n.receivers = { type: "USERS", userIds: [...form.userIds] };
|
||||
else if (form.type === "DEPT") {
|
||||
// 只存类型化引用(departmentType + departmentId),本校范围由后端从 CAS 会话派生
|
||||
n.receivers = {
|
||||
type: "DEPT",
|
||||
deptRefs: form.deptRefs.map((r) => ({
|
||||
departmentType: r.departmentType,
|
||||
departmentId: r.departmentId,
|
||||
})),
|
||||
};
|
||||
} else n.receivers = { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
@@ -138,22 +483,103 @@ function onRemove(): void {
|
||||
<div class="fld">
|
||||
<label>抄送对象类型</label>
|
||||
<RadioGroup v-model="form.type" type="button" button-style="solid">
|
||||
<Radio label="DEPT_KEY">按科室映射</Radio>
|
||||
<Radio label="USERS">指定用户</Radio>
|
||||
<Radio label="DEPT">部门</Radio>
|
||||
<Radio label="USERS">指定人员</Radio>
|
||||
<Radio v-if="form.type === 'DEPT_KEY'" label="DEPT_KEY">本地科室(旧机制)</Radio>
|
||||
</RadioGroup>
|
||||
<div class="hint">部门取 UserBase 权威部门目录;指定人员逐个点名;本地科室是 FL 旧表,只读兼容</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.type === 'DEPT_KEY'" class="fld">
|
||||
<div v-if="form.type === 'DEPT'" class="fld">
|
||||
<label>部门(UserBase 目录)</label>
|
||||
<Select v-model="ubGroupKey" placeholder="选择部门组(系统组织 / 自定义分类)" :loading="ubLoading">
|
||||
<Option v-for="g in ubGroupItems" :key="g.key" :value="g.key" :label="g.label" />
|
||||
</Select>
|
||||
<div v-if="ubErr" class="hint warn">
|
||||
⚠ {{ ubErr }}
|
||||
<Button size="small" style="margin-left: 6px" @click="ubRetry">重试</Button>
|
||||
</div>
|
||||
<div v-else-if="!ubGroupItems.length && !ubLoading" class="hint warn">
|
||||
⚠ 部门目录没有返回任何部门组(系统组织与本校自定义分类均为空)
|
||||
</div>
|
||||
|
||||
<div class="ub-picked">
|
||||
<Tag
|
||||
v-for="r in form.deptRefs"
|
||||
:key="ubKeyOf(r.departmentType, r.departmentId)"
|
||||
closable
|
||||
color="cyan"
|
||||
@on-close="removeUbRef(r)"
|
||||
>
|
||||
{{ ubNameOf(r) }} · {{ ubMemberCountOf(r) }} 人
|
||||
</Tag>
|
||||
<span v-if="!form.deptRefs.length" class="hint">尚未选择部门</span>
|
||||
</div>
|
||||
<div v-for="w in ubWarnings" :key="w.key" class="hint warn">⚠ {{ w.text }}</div>
|
||||
|
||||
<Input
|
||||
v-model="ubKeyword"
|
||||
size="small"
|
||||
placeholder="按名称筛选(跨层级;留空则逐级浏览)"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
<div class="ub-nav">
|
||||
<Button size="small" :disabled="ubNav.length <= 1" @click="ubUp">‹ 上级</Button>
|
||||
<span
|
||||
v-for="(p, i) in ubNav"
|
||||
:key="`${p.id}-${i}`"
|
||||
class="ub-crumb"
|
||||
:class="{ last: i === ubNav.length - 1 }"
|
||||
@click="ubJump(i)"
|
||||
>
|
||||
{{ i > 0 ? "/" : "" }}{{ p.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ub-list">
|
||||
<div
|
||||
v-for="o in ubVisible"
|
||||
:key="ubKeyOf(o.departmentType, o.departmentId)"
|
||||
class="ub-row"
|
||||
:class="{ off: !o.selectable }"
|
||||
>
|
||||
<span class="ub-check" @click="toggleUbDept(o)">{{ isUbPicked(o) ? "☑" : "☐" }}</span>
|
||||
<span class="ub-name" @click="toggleUbDept(o)">{{ o.name }}</span>
|
||||
<span v-if="o.categoryName" class="ub-cat">{{ o.categoryName }}</span>
|
||||
<span class="ub-count">{{ o.memberCount }} 人</span>
|
||||
<span v-if="!o.selectable" class="ub-offtag">{{ o.deleted ? "已删除" : !o.enabled ? "已停用" : "无成员" }}</span>
|
||||
<span v-if="ubHasChildren(o)" class="ub-drill" @click="ubDrill(o)">下级 ▸</span>
|
||||
</div>
|
||||
<div v-if="ubLoading" class="ub-empty">部门加载中…</div>
|
||||
<div v-else-if="!ubVisible.length" class="ub-empty">
|
||||
{{ ubGroupKey ? "当前层级/关键字下没有部门" : "请先选择部门组" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ub-foot">
|
||||
<Checkbox v-model="ubAddChildren">含下级部门</Checkbox>
|
||||
<Button size="small" type="primary" ghost :disabled="!ubPickTargets.length" @click="addUbBatch">
|
||||
加入 {{ ubPickTargets.length }} 个部门
|
||||
</Button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
引用按「目录类型 + 部门 id」成对保存(同值 id 在系统组织与自定义部门下不等价);
|
||||
本校范围由后端从管理员 CAS 会话取,前端不传
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.type === 'DEPT_KEY'" class="fld">
|
||||
<label>科室映射</label>
|
||||
<Select v-model="form.deptKey" filterable placeholder="请选择科室">
|
||||
<Option v-for="o in deptOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
<Option v-for="o in legacyDeptOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||
</Select>
|
||||
<div v-if="deptHint" class="hint warn">⚠ {{ deptHint }}</div>
|
||||
<div v-if="legacyDeptHint" class="hint warn">⚠ {{ legacyDeptHint }}</div>
|
||||
<div v-else class="hint">科室启用中的全部人员都会收到抄送;运行时无人可抄送时只跳过,不阻塞流程</div>
|
||||
<div class="hint warn">
|
||||
⚠ 本地科室(FL 的 flw_dept 表)是旧机制:本节点的历史引用可原样保存,
|
||||
但切到「部门」或「指定人员」后不能再选回来,新配置请改用 UserBase 部门
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="fld">
|
||||
<div v-else class="fld">
|
||||
<label>指定用户</label>
|
||||
<div class="user-add">
|
||||
<Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
|
||||
@@ -172,7 +598,6 @@ function onRemove(): void {
|
||||
</div>
|
||||
<div class="hint">抄送是知会:每人一条站内通知,不产生待办、不需要办理</div>
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
|
||||
<div class="dw-foot">
|
||||
@@ -225,6 +650,84 @@ function onRemove(): void {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
.ub-picked {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 0;
|
||||
}
|
||||
.ub-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ub-crumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-crumb.last {
|
||||
color: #2d8cf0;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
.ub-list {
|
||||
margin-top: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ub-row:hover {
|
||||
background: #f8f8f9;
|
||||
}
|
||||
.ub-row.off {
|
||||
color: #c5c8ce;
|
||||
}
|
||||
.ub-check {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-name {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-cat {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-count {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-offtag {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-drill {
|
||||
color: #2d8cf0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ub-empty {
|
||||
padding: 8px;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ub-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dw-foot {
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
|
||||
@@ -90,7 +90,7 @@ function renameOutlet(i: number, v: string): void {
|
||||
}
|
||||
.ol-title {
|
||||
text-align: center;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -139,7 +139,7 @@ function renameOutlet(i: number, v: string): void {
|
||||
}
|
||||
.ol-col-no {
|
||||
flex: none;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -150,12 +150,12 @@ function renameOutlet(i: number, v: string): void {
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ol-outlet:focus {
|
||||
outline: none;
|
||||
border-color: #2d8cf0;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.ol-col-del {
|
||||
flex: none;
|
||||
@@ -170,8 +170,8 @@ function renameOutlet(i: number, v: string): void {
|
||||
.ol-add {
|
||||
margin-top: 12px;
|
||||
border-style: dashed;
|
||||
color: #2d8cf0;
|
||||
border-color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
border-color: var(--brand);
|
||||
background: transparent;
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
@@ -182,7 +182,7 @@ function renameOutlet(i: number, v: string): void {
|
||||
.ol-add:hover {
|
||||
background: #f0faff;
|
||||
color: #2b85e4;
|
||||
border-color: #2d8cf0;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
.ol-issue {
|
||||
text-align: center;
|
||||
|
||||
@@ -23,6 +23,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
|
||||
<div class="plus-btn" title="在此插入节点">+</div>
|
||||
<template #content>
|
||||
<div class="menu-group">常用节点</div>
|
||||
<div class="menu-item" @click="pick('approval')">
|
||||
<span class="mi-icon approval">审</span>
|
||||
<span class="mi-text">
|
||||
@@ -37,6 +38,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
<i>知会指定人,不产生待办、不阻塞流程</i>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 高级结构:只是分组展示,四类插入能力一个都没少,插入后的 JSON 语义完全不变 -->
|
||||
<div class="menu-group adv">高级结构</div>
|
||||
<div class="menu-item" @click="pick('parallel')">
|
||||
<span class="mi-icon parallel">⇉</span>
|
||||
<span class="mi-text">
|
||||
@@ -75,8 +78,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 1px solid #2d8cf0;
|
||||
color: #2d8cf0;
|
||||
border: 1px solid var(--brand);
|
||||
color: var(--brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -90,10 +93,21 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
}
|
||||
.plus-wrap:hover .plus-btn {
|
||||
opacity: 1;
|
||||
background: #2d8cf0;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
|
||||
}
|
||||
.menu-group {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 11px;
|
||||
color: #808695;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.menu-group.adv {
|
||||
margin-top: 6px;
|
||||
border-top: 1px dashed #e8eaec;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -117,7 +131,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
font-size: 12px;
|
||||
}
|
||||
.mi-icon.approval {
|
||||
background: #2d8cf0;
|
||||
background: var(--brand);
|
||||
}
|
||||
.mi-icon.notify {
|
||||
background: #13a8a8;
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
EditorItem,
|
||||
EditorModel,
|
||||
EditorSettings,
|
||||
FlowVariable,
|
||||
VARIABLE_TYPES,
|
||||
NotifyNode,
|
||||
ParallelNode,
|
||||
createApproval,
|
||||
@@ -42,21 +44,42 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
/**
|
||||
* 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后
|
||||
* 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。
|
||||
* walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。
|
||||
* firstEdgeCondition(2026-10-10):EXPRESSION 模式分支首边的条件表达式,与出口名同位写入。
|
||||
* 空出口分支(2026-10-12,"直接通过"):items 为空时 walk 原样返回起点作为尾并携带出口名/条件,
|
||||
* 汇合连线即"起点→汇合点"的命名直连边——与引擎 cond→汇合点直连边双向保真。
|
||||
*/
|
||||
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string): string[] => {
|
||||
interface Tail {
|
||||
id: string;
|
||||
name?: string;
|
||||
condition?: string;
|
||||
}
|
||||
const tailEdge = (from: string, to: string, t: Tail): DefEdgeJson => {
|
||||
const e: DefEdgeJson = { from, to };
|
||||
if (t.name) e.name = t.name;
|
||||
if (t.condition !== undefined && t.condition !== null && t.condition !== "") {
|
||||
e.condition = t.condition;
|
||||
}
|
||||
return e;
|
||||
};
|
||||
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string,
|
||||
firstEdgeCondition?: string): Tail[] => {
|
||||
let cursor: string | null = prev;
|
||||
let pending: string[] = [];
|
||||
let pending: Tail[] = [];
|
||||
let first = true;
|
||||
const linkTo = (toId: string): void => {
|
||||
if (cursor !== null) {
|
||||
if (first && firstEdgeName) {
|
||||
edges.push({ from: cursor, to: toId, name: firstEdgeName });
|
||||
const e: DefEdgeJson = { from: cursor, to: toId, name: firstEdgeName };
|
||||
if (firstEdgeCondition !== undefined && firstEdgeCondition !== null
|
||||
&& firstEdgeCondition !== "") {
|
||||
e.condition = firstEdgeCondition;
|
||||
}
|
||||
edges.push(e);
|
||||
} else {
|
||||
edges.push({ from: cursor, to: toId });
|
||||
}
|
||||
}
|
||||
pending.forEach((t) => edges.push({ from: t, to: toId }));
|
||||
pending.forEach((t) => edges.push(tailEdge(t.id, toId, t)));
|
||||
pending = [];
|
||||
first = false;
|
||||
};
|
||||
@@ -71,12 +94,21 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
};
|
||||
if (item.countersign === "ANY") node.countersign = "ANY";
|
||||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||||
if (item.assignPolicy) node.assignPolicy = { ...item.assignPolicy };
|
||||
// C3 展示规则与审批主体分开存:不配=按人员(历史定义零字段变化,读取行为不变)
|
||||
if (item.displayMode === "DEPT") {
|
||||
node.displayMode = "DEPT";
|
||||
node.displayLabel = item.displayLabel || "";
|
||||
} else if (item.displayMode === "USER") {
|
||||
node.displayMode = "USER";
|
||||
if (item.displayLabel) node.displayLabel = item.displayLabel;
|
||||
}
|
||||
nodes.push(node);
|
||||
linkTo(id);
|
||||
if (item.outlets && item.outlets.length >= 2 && item.branches) {
|
||||
// 多出口(2026-10-03 P2):APPROVAL 的多条命名出边即按钮选路,
|
||||
// 分支尾隐式汇合到后继(与条件分支容器完全同构)
|
||||
const tails: string[] = [];
|
||||
const tails: Tail[] = [];
|
||||
item.branches.forEach((branch, bi) => {
|
||||
tails.push(...walk(branch, id, item.outlets![bi]));
|
||||
});
|
||||
@@ -102,16 +134,21 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
}
|
||||
if (item.kind === "condition") {
|
||||
const condId = `${item.uid}c`;
|
||||
nodes.push({
|
||||
const node: DefNodeJson = {
|
||||
id: condId,
|
||||
type: "CONDITION",
|
||||
name: item.name || "条件判断",
|
||||
routerKey: item.routerKey || "",
|
||||
});
|
||||
};
|
||||
// SERVICE 是后端缺省,不写字段保持最小 JSON;EXPRESSION 显式携带
|
||||
if (item.judgeMode === "EXPRESSION") node.judgeMode = "EXPRESSION";
|
||||
nodes.push(node);
|
||||
linkTo(condId);
|
||||
const tails: string[] = [];
|
||||
const tails: Tail[] = [];
|
||||
const expression = item.judgeMode === "EXPRESSION";
|
||||
item.branches.forEach((branch, bi) => {
|
||||
tails.push(...walk(branch, condId, item.outlets[bi]));
|
||||
const cond = expression ? (item.conditions || [])[bi] || "" : undefined;
|
||||
tails.push(...walk(branch, condId, item.outlets[bi], cond));
|
||||
});
|
||||
cursor = null;
|
||||
pending = tails;
|
||||
@@ -123,17 +160,27 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
||||
linkTo(split);
|
||||
item.branches.forEach((branch) => {
|
||||
walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join }));
|
||||
walk(branch, split).forEach((tail) => edges.push(tailEdge(tail.id, join, tail)));
|
||||
});
|
||||
cursor = join;
|
||||
});
|
||||
const tails: string[] = [];
|
||||
if (cursor !== null) tails.push(cursor);
|
||||
const tails: Tail[] = [];
|
||||
// 空出口分支:无任何节点消费(first 未翻转、cursor 停在起点)——出口名/条件随尾携带,
|
||||
// 由汇合点连线落成命名直连边(非空分支的 first 边已由 linkTo 写过,此处 first=false 不受影响)
|
||||
if (cursor !== null) {
|
||||
const t: Tail = { id: cursor };
|
||||
if (first && firstEdgeName) t.name = firstEdgeName;
|
||||
if (first && firstEdgeCondition !== undefined && firstEdgeCondition !== null
|
||||
&& firstEdgeCondition !== "") {
|
||||
t.condition = firstEdgeCondition;
|
||||
}
|
||||
tails.push(t);
|
||||
}
|
||||
tails.push(...pending);
|
||||
return tails;
|
||||
};
|
||||
|
||||
walk(model.list, "start").forEach((tail) => edges.push({ from: tail, to: "end" }));
|
||||
walk(model.list, "start").forEach((tail) => edges.push(tailEdge(tail.id, "end", tail)));
|
||||
const endNode: DefNodeJson = { id: "end", type: "END" };
|
||||
if (model.endName) endNode.name = model.endName;
|
||||
nodes.push(endNode);
|
||||
@@ -144,6 +191,19 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
start: "start",
|
||||
nodes,
|
||||
edges,
|
||||
// D1 变量契约:始终显式写出(无业务变量=空集)。后端新发布版本要求声明,
|
||||
// 省略会被 VariableContract.validateDefinition 以"必须声明 variables"拦下
|
||||
variables: (model.variables || []).map((v) => {
|
||||
// 值域以编辑态逗号串为准(enumText 是 UI 专用字段,不进 contentJson)
|
||||
const enumValues = (v.enumText || "").split(/[,,]/).map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
const stringLike = v.type === "string" || v.type === "stringArray";
|
||||
return {
|
||||
key: v.key,
|
||||
type: v.type,
|
||||
...(v.required ? { required: true } : {}),
|
||||
...(stringLike && enumValues.length ? { enum: enumValues } : {}),
|
||||
};
|
||||
}),
|
||||
settings: {
|
||||
rejectPolicy: "END_REJECTED",
|
||||
urge: { ...model.settings.urge },
|
||||
@@ -156,9 +216,43 @@ export function toContentJson(model: EditorModel): DefJson {
|
||||
* bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射;
|
||||
* id 仅要求"非空且唯一"(DefValidator 口径)。
|
||||
*/
|
||||
/** 变量契约读侧:只认后端已知类型,未知类型整项丢弃并留 console 警告(不用假类型继续编辑) */
|
||||
function parseVariables(raw: unknown): FlowVariable[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: FlowVariable[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const v = item as Record<string, any>;
|
||||
if (typeof v.key !== "string" || !v.key || VARIABLE_TYPES.indexOf(v.type) < 0) {
|
||||
console.warn("[def-editor] 跳过不合法的变量声明:", v);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
key: v.key,
|
||||
type: v.type,
|
||||
required: v.required === true,
|
||||
enum: Array.isArray(v.enum) ? v.enum.filter((x: unknown) => typeof x === "string") : undefined,
|
||||
enumText: Array.isArray(v.enum) ? v.enum.filter((x: unknown) => typeof x === "string").join(",") : "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function cloneAssignee(a: Assignee | null): Assignee {
|
||||
if (!a) return { type: "DEPT_KEY", deptKey: "" };
|
||||
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey };
|
||||
if (a.type === "USERS") return { type: "USERS", userIds: [...a.userIds] };
|
||||
if (a.type === "DEPT") {
|
||||
// 只持久化类型化引用两要素(+id);name 是展示快照,不进 contentJson,
|
||||
// 避免"存了个当时的部门名"变成下一轮的关联依据(名称一律由目录解析回显)
|
||||
return {
|
||||
type: "DEPT",
|
||||
deptRefs: (a.deptRefs || []).map((r) => ({
|
||||
departmentType: r.departmentType,
|
||||
departmentId: r.departmentId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { type: "DEPT_KEY", deptKey: a.deptKey };
|
||||
}
|
||||
|
||||
export interface ParsedDef {
|
||||
@@ -166,6 +260,8 @@ export interface ParsedDef {
|
||||
name: string;
|
||||
list: EditorItem[];
|
||||
settings: EditorSettings;
|
||||
/** 变量契约(D1):原样带出;历史定义为 undefined(保存时补空集,不静默造声明) */
|
||||
variables?: FlowVariable[];
|
||||
/** 端点 name 原样带出(见 EditorModel.startName 注释) */
|
||||
startName?: string;
|
||||
endName?: string;
|
||||
@@ -311,14 +407,23 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
// uid 复用 JSON id 并剥掉生成态的 "c" 尾缀:uid+"c" 是本编辑器的 JSON id 生成约定,
|
||||
// 剥尾保证"图→树→图→树→图"的 id 收敛(否则每轮追加一个 c,往返不幂等)
|
||||
const uidRaw = condId.endsWith("c") ? condId.slice(0, -1) : condId;
|
||||
// 出口条件反解(2026-10-10):EXPRESSION 模式出边 condition 与 outlets 同位
|
||||
const conditions = heads.map((head) => {
|
||||
const e = edges.find((x) => x.from === condId && x.to === head);
|
||||
return e && e.condition ? String(e.condition) : "";
|
||||
});
|
||||
return {
|
||||
item: {
|
||||
uid: UsedIds.take(uidRaw),
|
||||
kind: "condition",
|
||||
name: n.name || "条件判断",
|
||||
routerKey: n.routerKey || "",
|
||||
judgeMode: n.judgeMode === "EXPRESSION" ? "EXPRESSION" : "SERVICE",
|
||||
conditions,
|
||||
outlets,
|
||||
branches: branches.map((b) => (b.length ? b : [createApproval()])),
|
||||
// 空出口分支(2026-10-12):保留空数组("直接通过"直达汇合点),
|
||||
// 不再造无审批人占位卡(曾导致打开即报"未选择科室映射"、保存还会凭空改语义)
|
||||
branches,
|
||||
},
|
||||
mergeId,
|
||||
};
|
||||
@@ -361,7 +466,7 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
}
|
||||
const node = toApprovalNode(n);
|
||||
node.outlets = outlets;
|
||||
node.branches = branches.map((b) => (b.length ? b : [createApproval()]));
|
||||
node.branches = branches; // 空出口泳道保留(直达汇合点),不造占位卡
|
||||
list.push(node);
|
||||
cur = mergeId;
|
||||
} else {
|
||||
@@ -418,6 +523,7 @@ export function fromContentJson(def: any): ParsedDef {
|
||||
code: def.code || "",
|
||||
name: def.name || "",
|
||||
list: firstWalk.list,
|
||||
variables: parseVariables(def.variables),
|
||||
startName: starts[0].name || undefined,
|
||||
endName: byId[firstWalk.endedAt]?.name || undefined,
|
||||
settings: {
|
||||
@@ -442,6 +548,9 @@ function toApprovalNode(n: any): ApprovalNode {
|
||||
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
||||
countersign,
|
||||
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
|
||||
assignPolicy: n.assignPolicy ? { ...n.assignPolicy } : undefined,
|
||||
displayMode: n.displayMode === "DEPT" ? "DEPT" : n.displayMode === "USER" ? "USER" : undefined,
|
||||
displayLabel: n.displayLabel ? String(n.displayLabel) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NotifyNode,
|
||||
RouterOption,
|
||||
UrgeSetting,
|
||||
VARIABLE_TYPES,
|
||||
collectUserIds,
|
||||
createApproval,
|
||||
createCondition,
|
||||
@@ -55,22 +56,73 @@ const baseGuid = ref("");
|
||||
/** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */
|
||||
const sourceStatus = ref(0);
|
||||
const sourceVersion = ref(0);
|
||||
/** 源流程线 flowGuid(新建态为空,右面板显示「保存后生成」) */
|
||||
const sourceFlowGuid = ref("");
|
||||
/** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */
|
||||
const appKey = ref("");
|
||||
/** 应用清单下拉(本校已标注 ∪ 已注册回调),拉取失败降级为仅手输 */
|
||||
const appKeys = ref<string[]>([]);
|
||||
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
|
||||
const appOptions = ref<{ appKey: string; appName: string }[]>([]);
|
||||
/**
|
||||
* 关联表单坐标(flw_def.form_uuid / form_version,v3/TASK_1):行字段、不进 contentJson。
|
||||
* 版本由选中时那一条目录行带出(中心当前发布版),不给手填——存的就是服务端核验过的坐标。
|
||||
*/
|
||||
const formUuid = ref("");
|
||||
const formVersion = ref<number | undefined>(undefined);
|
||||
/** 表单中心目录(同源代理);拉不到时选择器禁用并说明原因,不假装"这所学校没有表单" */
|
||||
const formOptions = ref<any[]>([]);
|
||||
const formCenterError = ref("");
|
||||
|
||||
const model = reactive<EditorModel>(createEmptyModel());
|
||||
/** 科室映射聚合(mapList):仅用于下拉选项与"无启用办理人"提示 */
|
||||
/** 科室列表(deptList 聚合行):仅用于下拉选项与"无启用人员"提示 */
|
||||
const depts = reactive<DeptOption[]>([]);
|
||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||
const userNames = reactive<Record<number, string>>({});
|
||||
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||||
const routers = reactive<RouterOption[]>([]);
|
||||
/** 可用变量 chips:近期办件 formData 顶层键聚合(去重截 40)+ 内置变量;拉取失败仅内置 */
|
||||
const availableVars = reactive<string[]>([]);
|
||||
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
|
||||
const issues = computed<Issue[]>(() => validateModel(model, depts, routers, availableVars));
|
||||
const defJson = computed(() => toContentJson(model));
|
||||
|
||||
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
|
||||
const stats = computed(() => {
|
||||
const s: Record<string, number> = { approval: 0, notify: 0, condition: 0, parallel: 0 };
|
||||
walkItems(model.list, (item) => {
|
||||
if (s[item.kind] !== undefined) s[item.kind] += 1;
|
||||
});
|
||||
return s;
|
||||
});
|
||||
|
||||
/** 校验清单(右面板分区3):错误项在前、警告项在后(原型 E2 顺序) */
|
||||
const issueList = computed<Issue[]>(() => [
|
||||
...issues.value.filter((i) => i.level === "error"),
|
||||
...issues.value.filter((i) => i.level !== "error"),
|
||||
]);
|
||||
|
||||
/** 点清单项 → 画布滚动到对应节点并高亮 ~3s;uid 为空(空容器类)不可定位 */
|
||||
function locateIssue(uid: string): void {
|
||||
const el = document.getElementById("nd-" + uid);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el.classList.add("nd-locate");
|
||||
window.setTimeout(() => el.classList.remove("nd-locate"), 2800);
|
||||
}
|
||||
|
||||
/** flowGuid 复制:clipboard 不可用/被拒时降级 console,不打断编辑 */
|
||||
function copyGuid(): void {
|
||||
const g = sourceFlowGuid.value;
|
||||
if (!g) return;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(g).then(
|
||||
() => Message.success("flowGuid 已复制"),
|
||||
() => console.warn("flowGuid 复制失败,请手工选取:", g),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.warn("flowGuid 复制失败,请手工选取:", g);
|
||||
}
|
||||
|
||||
/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
|
||||
function canonicalJson(content: any): string {
|
||||
const copy: any = JSON.parse(JSON.stringify(content));
|
||||
@@ -100,6 +152,10 @@ const conditionDrawer = ref(false);
|
||||
const settingsDrawer = ref(false);
|
||||
const jsonDrawer = ref(false);
|
||||
const editingUid = ref("");
|
||||
// 议题二③:升版本改动说明弹窗(仅「本次保存将创建新版本」时出现,可跳过)
|
||||
const logModal = ref(false);
|
||||
const logText = ref("");
|
||||
const LOG_CHIPS = ["修复审批人缺失", "调整审批链", "新增抄送对象", "修改条件表达式", "调整流程设置"];
|
||||
|
||||
const editingNode = computed<ApprovalNode | null>(() => {
|
||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||
@@ -120,20 +176,17 @@ const editingCondition = computed<ConditionNode | null>(() => {
|
||||
|
||||
async function loadDepts(): Promise<void> {
|
||||
try {
|
||||
const rows: any[] = (await flowApi.mapList().send()) || [];
|
||||
const grouped: Record<string, DeptOption> = {};
|
||||
rows.forEach((r) => {
|
||||
const key = r.deptKey;
|
||||
if (!key) return;
|
||||
if (!grouped[key]) grouped[key] = { deptKey: key, deptName: r.deptName || "", count: 0 };
|
||||
if (!grouped[key].deptName && r.deptName) grouped[key].deptName = r.deptName;
|
||||
// 状态 0=启用(与办理人映射页口径一致),发布校验要求至少 1 名启用办理人
|
||||
if (r.status === 0) grouped[key].count += 1;
|
||||
});
|
||||
depts.splice(0, depts.length, ...Object.values(grouped));
|
||||
// 科室管理(2026-10-10 切源):deptList 返回聚合行 {deptKey,deptName,status,count}
|
||||
const rows: any[] = (await flowApi.deptList().send()) || [];
|
||||
depts.splice(0, depts.length, ...rows.map((r) => ({
|
||||
deptKey: String(r.deptKey),
|
||||
deptName: r.deptName || "",
|
||||
// 停用科室不进编辑器下拉(与发布校验口径一致:解析不出办理人)
|
||||
count: r.status === 0 ? r.count : 0,
|
||||
})));
|
||||
} catch {
|
||||
// 映射表拉取失败不阻塞编辑:审批人下拉退化为"仅当前值",校验降级为提示
|
||||
Message.warning("办理人映射拉取失败,科室下拉可能不完整");
|
||||
// 科室拉取失败不阻塞编辑:审批人下拉退化为"仅当前值",校验降级为提示
|
||||
Message.warning("科室数据拉取失败,科室下拉可能不完整");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,26 +213,91 @@ async function loadRouters(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表达式可用变量:近期 50 条办件 formData 顶层键聚合(去重截 40)+ 内置,口径同调试弹窗;失败仅内置 */
|
||||
const BUILTIN_VARS = ["schId", "instanceId", "instanceVersion", "bizType", "bizId", "nodeId", "nodeName", "submitterId"];
|
||||
async function loadVars(): Promise<void> {
|
||||
const keys = new Set<string>();
|
||||
try {
|
||||
const rows: any[] = (await flowApi.adminInstances({ page: 1, size: 50 }).send())?.content || [];
|
||||
rows.forEach((r) => {
|
||||
try {
|
||||
const v = JSON.parse(r.formDataJson || "{}");
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) Object.keys(v).forEach((k) => keys.add(k));
|
||||
} catch {
|
||||
/* 脏 formData 跳过,不影响其它办件的键聚合 */
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* 办件列表拉取失败:退化为仅内置变量,不阻塞编辑 */
|
||||
}
|
||||
const merged = Array.from(new Set([...Array.from(keys).slice(0, 40), ...BUILTIN_VARS]));
|
||||
availableVars.splice(0, availableVars.length, ...merged);
|
||||
}
|
||||
|
||||
function applyParsed(parsed: ParsedDef): void {
|
||||
model.code = parsed.code || model.code;
|
||||
model.name = parsed.name || model.name;
|
||||
model.list = parsed.list;
|
||||
model.settings = parsed.settings;
|
||||
model.variables = parsed.variables;
|
||||
// 端点 name 随定义带出,保存时回写(新建流程为空,生成 JSON 不含该字段)
|
||||
model.startName = parsed.startName;
|
||||
model.endName = parsed.endName;
|
||||
void resolveUserNames();
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量契约编辑(D1)。前端只做"明显配错"的拦止(key 形态/类型/重复),
|
||||
* 表达式引用是否齐备、类型与值域是否成立,权威判据仍是后端 VariableContract 的发布/发起校验。
|
||||
*/
|
||||
function addVariable(): void {
|
||||
if (!model.variables) model.variables = [];
|
||||
model.variables.push({ key: "", type: "string", required: false, enumText: "" });
|
||||
}
|
||||
|
||||
function removeVariable(index: number): void {
|
||||
(model.variables || []).splice(index, 1);
|
||||
}
|
||||
|
||||
const BUILTIN_VAR_KEYS = ["instanceId", "instanceVersion", "schId", "bizType", "bizId", "deptId", "deptName"];
|
||||
|
||||
function hasInvalidVariables(): boolean {
|
||||
const list = model.variables || [];
|
||||
const keys = new Set<string>();
|
||||
for (const v of list) {
|
||||
const key = (v.key || "").trim();
|
||||
if (!key || !/^[A-Za-z0-9_]{1,64}$/.test(key)) return true;
|
||||
if (BUILTIN_VAR_KEYS.includes(key)) return true;
|
||||
if (!VARIABLE_TYPES.includes(v.type)) return true;
|
||||
if (keys.has(key)) return true;
|
||||
keys.add(key);
|
||||
const values = (v.enumText || "").split(/[,,]/).map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
if (values.length && v.type !== "string" && v.type !== "stringArray") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
loading.value = true; try {
|
||||
await loadDepts();
|
||||
await loadRouters();
|
||||
void loadVars();
|
||||
try {
|
||||
appKeys.value = ((await flowApi.defAppKeys().send()) as string[]) || [];
|
||||
const rows: any[] = (await flowApi.appList().send()) || [];
|
||||
appOptions.value = rows
|
||||
.filter((r) => r.enabled === 1)
|
||||
.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
|
||||
} catch {
|
||||
appKeys.value = [];
|
||||
appOptions.value = [];
|
||||
}
|
||||
// 表单中心目录:授权没配/中心不可达时选择器禁用并说明原因(空列表 ≠ "没有可用表单")
|
||||
try {
|
||||
const page: any = await flowApi.formCenterForms({ page: 1, pageSize: 100 }).send();
|
||||
formOptions.value = page?.items || [];
|
||||
formCenterError.value = "";
|
||||
} catch (e: any) {
|
||||
formOptions.value = [];
|
||||
formCenterError.value = String(e?.message || e || "表单中心目录不可达");
|
||||
}
|
||||
|
||||
const queryId = route.query.id ? Number(route.query.id) : null;
|
||||
@@ -197,9 +315,16 @@ async function load(): Promise<void> {
|
||||
const d: any = await flowApi.defGet(queryId).send();
|
||||
sourceStatus.value = d?.status ?? 0;
|
||||
sourceVersion.value = d?.version ?? 0;
|
||||
sourceFlowGuid.value = d?.flowGuid || queryGuid || "";
|
||||
// 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」
|
||||
if (d?.status === 1 || d?.status === 2) {
|
||||
Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)");
|
||||
}
|
||||
model.code = d?.code || "";
|
||||
model.name = d?.name || "";
|
||||
appKey.value = d?.appKey || "";
|
||||
formUuid.value = d?.formUuid || "";
|
||||
formVersion.value = d?.formVersion == null ? undefined : Number(d.formVersion);
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||
} catch (e: any) {
|
||||
@@ -287,6 +412,11 @@ function addBranch(containerUid: string): void {
|
||||
const no = item.branches.length + 1;
|
||||
item.branches.push([createApproval()]);
|
||||
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||||
// EXPRESSION 模式条件与 outlets 同位(SERVICE 模式不写,序列化时不携带)
|
||||
if (item.judgeMode === "EXPRESSION") {
|
||||
item.conditions = item.conditions || [];
|
||||
while (item.conditions.length < item.outlets.length) item.conditions.push("");
|
||||
}
|
||||
} else if (item.kind === "approval" && item.outlets && item.outlets.length >= 2) {
|
||||
const no = (item.branches?.length || 0) + 1;
|
||||
(item.branches = item.branches || []).push([createApproval()]);
|
||||
@@ -305,6 +435,7 @@ function removeBranch(containerUid: string, index: number): void {
|
||||
}
|
||||
branches.splice(index, 1);
|
||||
if (item.kind === "condition" || isOutlet) item.outlets!.splice(index, 1);
|
||||
if (item.kind === "condition" && item.conditions) item.conditions.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 退出多出口:仅保留出口①泳道内容,替换为「本节点(去 outlets) + ①泳道节点序列」 */
|
||||
@@ -329,7 +460,12 @@ provide<EditorCtx>(EDITOR_CTX, {
|
||||
model,
|
||||
depts,
|
||||
userNames,
|
||||
// 升版本/编辑草稿时由 defGet 带回;新建态为空串(预览据此说明"没有可比对的已发布版本")
|
||||
get flowGuid() {
|
||||
return sourceFlowGuid.value || "";
|
||||
},
|
||||
routers,
|
||||
availableVars,
|
||||
issueOf,
|
||||
openNode,
|
||||
removeNode,
|
||||
@@ -350,13 +486,32 @@ function onJsonApplied(p: ParsedDef): void {
|
||||
applyParsed(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选表=成对记下 uuid 与中心当时发布版;清空则两列一起清(后端要求成对,半个坐标存不下)。
|
||||
* 版本号一律取自目录行,页面不提供手填——填一个中心没有的版本,只会在发布期撞"坐标不符"。
|
||||
*/
|
||||
function onFormChange(uuid: string): void {
|
||||
const hit = uuid ? formOptions.value.find((f) => f.formUuid === uuid) : null;
|
||||
formUuid.value = hit ? hit.formUuid : "";
|
||||
formVersion.value = hit ? Number(hit.version) : undefined;
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!model.name.trim()) {
|
||||
Message.warning("请填写流程名称");
|
||||
return;
|
||||
}
|
||||
if (!model.code.trim()) {
|
||||
Message.warning("请填写流程编码");
|
||||
// A3:内部编码由后端生成,普通管理员不手填(新建时留空;编辑时只读回显既有编码)
|
||||
if (hasInvalidVariables()) {
|
||||
Message.warning("变量契约不完整:每项都需要 key 与类型(key 只允许字母/数字/下划线,且不能占用内置键)");
|
||||
return;
|
||||
}
|
||||
// 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度,
|
||||
// 不再是新发起资格(资格=flw_usage_grant 独立授权)。显式传入时后端仍校白名单。
|
||||
// 线级停发入口提示见列表页;此处不阻断无归属定义的保存。
|
||||
// 关联表单必须成对:改造前只有关联、没有版本号的存量行,重新选一次才能保存(后端同样会拒)
|
||||
if (formUuid.value && !formVersion.value) {
|
||||
Message.warning("该流程关联的表单还没有确切版本号,请在「关联表单」里重新选择一次(版本由表单中心给出,不可手填)");
|
||||
return;
|
||||
}
|
||||
const errors = countErrors(issues.value);
|
||||
@@ -364,39 +519,54 @@ async function onSave(): Promise<void> {
|
||||
Message.error(`校验未通过:${errors[0].text}`);
|
||||
return;
|
||||
}
|
||||
// 新建:同编码软提示(编码是分类标签,多条流程线可共用同一编码,这里只提醒不阻断)
|
||||
if (!editId.value && !baseGuid.value) {
|
||||
const rows: any[] = (await flowApi.defList().send()) || [];
|
||||
const sameCode = rows.filter((r) => r.code === model.code.trim()).length;
|
||||
if (sameCode > 0) {
|
||||
Modal.confirm({
|
||||
title: "存在同编码流程",
|
||||
content: `编码「${model.code.trim()}」已有 ${sameCode} 条流程,保存将新建一条独立流程线(版本从 v1 开始)。继续吗?`,
|
||||
okText: "继续保存",
|
||||
cancelText: "取消",
|
||||
onOk: () => void doSave(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 新建:编码由后端生成且唯一,不再需要"同编码会开出新流程线"的软确认
|
||||
await doSave();
|
||||
}
|
||||
|
||||
async function doSave(): Promise<void> {
|
||||
// 议题二③:本次保存将复制出新版本行时先问改动说明(挂在 doSave 入口,onSave 与同编码软确认两条路径都经过)
|
||||
if (willCreateVersion()) {
|
||||
logText.value = "";
|
||||
logModal.value = true;
|
||||
return;
|
||||
}
|
||||
await submitSave();
|
||||
}
|
||||
|
||||
/** 升版本判据:baseGuid 入口(列表「升版本」)必带 baseGuid;编辑已发布/停用版会走后端复制升版分支 */
|
||||
function willCreateVersion(): boolean {
|
||||
return (!editId.value && !!baseGuid.value) || (!!editId.value && sourceStatus.value !== 0);
|
||||
}
|
||||
|
||||
/** 常用改动类型点选:追加到说明文本(分号连接),可再手工补充 */
|
||||
function appendLogChip(text: string): void {
|
||||
const cur = logText.value.trim();
|
||||
logText.value = cur ? `${cur};${text}` : text;
|
||||
}
|
||||
|
||||
async function submitSave(changeLog?: string): Promise<void> {
|
||||
logModal.value = false;
|
||||
const content = JSON.stringify(toContentJson(model));
|
||||
saving.value = true;
|
||||
try {
|
||||
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
|
||||
await flowApi.defSave({
|
||||
id: editId.value || undefined,
|
||||
code: model.code.trim(),
|
||||
// 编码不手填:新建传 undefined(后端生成稳定唯一码),编辑传既有编码(保持不变)
|
||||
code: model.code.trim() || undefined,
|
||||
name: model.name.trim(),
|
||||
contentJson: content,
|
||||
baseGuid: baseGuid.value || undefined,
|
||||
appKey: (appKey.value || "").trim() || undefined,
|
||||
changeLog: changeLog || undefined,
|
||||
// 关联表单成对提交:空串=显式取消关联(后端同时清空版本),有值=uuid + 中心当时发布版
|
||||
formUuid: formUuid.value,
|
||||
formVersion: formUuid.value ? formVersion.value : undefined,
|
||||
}).send();
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
Message.success("已保存草稿");
|
||||
Message.success(willCreateVersion()
|
||||
? (changeLog ? "已保存草稿 · 改动说明已留存" : "已保存草稿(未填写改动说明)")
|
||||
: "已保存草稿");
|
||||
router.push("/admin/defs");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -464,34 +634,17 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})</Tag>
|
||||
<Tag v-else color="green">新建</Tag>
|
||||
</div>
|
||||
<div class="hd-mid">
|
||||
<label>名称 <Input v-model="model.name" placeholder="流程名称" style="width: 200px" :maxlength="30" /></label>
|
||||
<label>
|
||||
编码
|
||||
<Input
|
||||
v-model="model.code"
|
||||
placeholder="内部管理键,发布后不可改"
|
||||
style="width: 220px"
|
||||
:disabled="!!editId || !!baseGuid"
|
||||
:maxlength="40"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
应用
|
||||
<Select v-model="appKey" placeholder="归属应用,可空" style="width: 150px"
|
||||
clearable filterable allow-create>
|
||||
<Option v-for="k in appKeys" :key="k" :value="k">{{ k }}</Option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hd-right">
|
||||
<Button @click="settingsDrawer = true">流程设置</Button>
|
||||
<Button @click="jsonDrawer = true">源码</Button>
|
||||
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||||
<!-- 快捷入口上移顶栏(2026-10-15 痛点 7):右栏「快捷入口」区删除,动作类入口统一收敛到顶栏 -->
|
||||
<Button size="small" :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
||||
<Button size="small" @click="jsonDrawer = true">源码</Button>
|
||||
<Button size="small" @click="settingsDrawer = true">流程设置</Button>
|
||||
<span class="hd-divider" />
|
||||
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ed-body">
|
||||
<main v-if="!loading" class="ed-canvas">
|
||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
|
||||
<div class="ed-flow">
|
||||
@@ -500,25 +653,144 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
<div class="capsule end">流程结束</div>
|
||||
</div>
|
||||
</main>
|
||||
<main v-else-if="loading" class="ed-canvas loading">
|
||||
<main v-else class="ed-canvas loading">
|
||||
<Spin size="large" fix />
|
||||
</main>
|
||||
|
||||
<footer class="ed-status" :class="{ bad: countErrors(issues).length }">
|
||||
<span v-if="countErrors(issues).length" class="st-bad">
|
||||
✗ {{ countErrors(issues).length }} 个问题:{{ countErrors(issues).map((i) => i.text).join(";") }}
|
||||
<!-- 议题一:右信息面板(五分区,规格对齐原型 E1) -->
|
||||
<aside class="ed-info">
|
||||
<section class="ip-sec">
|
||||
<h4>流程信息<span class="ip-mini">可编辑 · 保存走现有 defSave</span></h4>
|
||||
<div class="ip-fld">
|
||||
<label>名称</label>
|
||||
<Input v-model="model.name" placeholder="流程名称" :maxlength="30" />
|
||||
</div>
|
||||
<div class="ip-fld">
|
||||
<label>内部编码<span class="ip-mini">后端生成 · 只读</span></label>
|
||||
<Input :value="model.code" readonly :placeholder="editId ? '' : '保存后由后端生成唯一编码'" />
|
||||
<div v-if="!editId && !model.code" class="ip-mini">
|
||||
编码是内部标识,由服务端生成且不随名称变化;业务方绑定流程请用 flowGuid。
|
||||
</div>
|
||||
</div>
|
||||
<div class="ip-fld">
|
||||
<label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label>
|
||||
<Select v-model="appKey" placeholder="归属应用(可留空)" filterable clearable>
|
||||
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="ip-fld">
|
||||
<label>关联表单<span class="ip-mini">可选 · 表单中心 · 成对存版本</span></label>
|
||||
<Select :value="formUuid" clearable filterable placeholder="不关联(原生办理)"
|
||||
:disabled="!!formCenterError" @on-change="onFormChange">
|
||||
<Option v-for="f in formOptions" :key="f.formUuid" :value="f.formUuid">
|
||||
{{ f.name }}({{ f.appCode }}/{{ f.code }} · v{{ f.version }})
|
||||
</Option>
|
||||
</Select>
|
||||
<div v-if="formCenterError" class="ip-mini" style="color: #ed4014">
|
||||
{{ formCenterError }}
|
||||
</div>
|
||||
<div v-else-if="formUuid && !formVersion" class="ip-mini" style="color: #ed4014">
|
||||
历史关联缺版本号,请重新选择表单后再保存
|
||||
</div>
|
||||
<div v-else-if="formUuid" class="ip-mini">已绑定 v{{ formVersion }}(发布时按这组坐标核验快照)</div>
|
||||
<div v-else class="ip-mini">目录只列中心已发布、且已授权给本应用的表单</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ip-sec">
|
||||
<h4>变量契约<span class="ip-mini">业务应用发起时提供 · 与表单中心无关</span></h4>
|
||||
<div class="ip-tip">
|
||||
条件分支的表达式只能引用这里声明的变量(或内置变量)。不使用条件变量的串行流程可留空。
|
||||
</div>
|
||||
<div v-for="(v, i) in model.variables" :key="i" class="var-row">
|
||||
<Input v-model="v.key" size="small" placeholder="变量 key" class="var-key" :maxlength="64" />
|
||||
<Select v-model="v.type" size="small" class="var-type">
|
||||
<Option v-for="t in VARIABLE_TYPES" :key="t" :value="t" :label="t" />
|
||||
</Select>
|
||||
<Checkbox v-model="v.required" size="small">必填</Checkbox>
|
||||
<Input v-model="v.enumText" size="small" class="var-enum" placeholder="值域(逗号分隔,可空)"
|
||||
v-if="v.type === 'string' || v.type === 'stringArray'" />
|
||||
<Button size="small" @click="removeVariable(i)">删除</Button>
|
||||
</div>
|
||||
<div v-if="!(model.variables && model.variables.length)" class="ip-mini">
|
||||
未声明业务变量(保存后写入空集合,发布时按"无变量"校验)
|
||||
</div>
|
||||
<Button size="small" class="var-add" @click="addVariable">+ 添加变量</Button>
|
||||
</section>
|
||||
|
||||
<section class="ip-sec">
|
||||
<h4>状态与版本<span class="ip-mini">只读</span></h4>
|
||||
<div class="ip-tags">
|
||||
<Tag v-if="editId && sourceStatus === 0" color="orange">草稿</Tag>
|
||||
<Tag v-else-if="editId" color="red">{{ DEF_STATUS[sourceStatus] }} · 保存将新建草稿版本</Tag>
|
||||
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}</Tag>
|
||||
<Tag v-else color="green">新建</Tag>
|
||||
<Tag v-if="sourceVersion" color="gray">v{{ sourceVersion }}</Tag>
|
||||
</div>
|
||||
<div class="ip-guid">
|
||||
<span v-if="sourceFlowGuid" class="ip-mono">{{ sourceFlowGuid }}</span>
|
||||
<span v-else class="ip-mini">保存后生成</span>
|
||||
<Button v-if="sourceFlowGuid" size="small" @click="copyGuid">复制</Button>
|
||||
</div>
|
||||
<div class="ip-tip">flowGuid 发布后不变,业务方绑定流程用此键<br>保存仅生成草稿;发布校验以后端为准</div>
|
||||
</section>
|
||||
|
||||
<section class="ip-sec">
|
||||
<h4>校验问题清单
|
||||
<span class="ip-mini">{{ countErrors(issues).length ? `${issueList.length} 项待处理`
|
||||
: (countWarns(issues).length ? `${countWarns(issues).length} 个警告` : "无问题") }}</span>
|
||||
</h4>
|
||||
<div v-if="!issueList.length" class="ip-ok">✓ 校验通过</div>
|
||||
<div v-else-if="!countErrors(issues).length" class="ip-ok">
|
||||
✓ 校验通过({{ countWarns(issues).length }} 个警告)
|
||||
</div>
|
||||
<div v-for="(i, idx) in issueList" :key="`${i.uid}-${idx}`" class="ip-issue"
|
||||
:class="[(i.level === 'error' ? 'err' : 'warn'), (i.uid ? 'loc' : 'fix')]"
|
||||
@click="i.uid && locateIssue(i.uid)">
|
||||
<span class="ip-ic">{{ i.level === "error" ? "✗" : "!" }}</span>
|
||||
<span class="ip-tx">{{ i.path }}:{{ i.msg }}
|
||||
<span v-if="i.level !== 'error'" class="ip-sub">(警告,不阻塞保存)</span>
|
||||
</span>
|
||||
<span v-else class="st-ok">
|
||||
✓ 校验通过<span v-if="countWarns(issues).length">({{ countWarns(issues).length }} 个警告)</span>
|
||||
</span>
|
||||
<span class="st-right">保存仅生成草稿;发布校验以后端为准</span>
|
||||
</footer>
|
||||
<span v-if="i.uid" class="ip-go">定位 →</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ip-sec">
|
||||
<h4>结构统计</h4>
|
||||
<div class="ip-stats">
|
||||
<div class="ip-stat"><b>{{ stats.approval }}</b><span>审批节点</span></div>
|
||||
<div class="ip-stat"><b>{{ stats.notify }}</b><span>抄送节点</span></div>
|
||||
<div class="ip-stat"><b>{{ stats.condition }}</b><span>条件分支</span></div>
|
||||
<div class="ip-stat"><b>{{ stats.parallel }}</b><span>并行分支</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
||||
<NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" />
|
||||
<ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = false" />
|
||||
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
||||
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
|
||||
|
||||
<!-- 议题二③:升版本改动说明(仅「本次保存将创建新版本」时出现,跳过也放行) -->
|
||||
<Modal v-model="logModal" title="保存为新版本草稿" :mask-closable="false" width="520">
|
||||
<p class="cl-note">
|
||||
当前为「升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})」,
|
||||
保存将创建新版本草稿(flowGuid 不变,版本号自动 +1)。
|
||||
</p>
|
||||
<div class="cl-label">本次改动说明<span class="cl-skip">可跳过</span></div>
|
||||
<div class="cl-chips">
|
||||
<span v-for="c in LOG_CHIPS" :key="c" class="cl-chip" @click="appendLogChip(c)">{{ c }}</span>
|
||||
</div>
|
||||
<Input v-model="logText" type="textarea" :rows="3" :maxlength="500" show-word-limit
|
||||
placeholder="一句话说清本版改了什么,如:教务处节点补充审批人配置;版本历史与列表可见" />
|
||||
<template #footer>
|
||||
<span class="cl-hint">跳过也允许保存——但「多一条记录」的区分度就没了</span>
|
||||
<Button :disabled="saving" @click="submitSave()">跳过</Button>
|
||||
<Button type="primary" :loading="saving" @click="submitSave(logText.trim())">保存草稿</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -527,7 +799,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 撑满布局壳可视区:100vh − 56(顶栏) − 56(面包屑 .path-nav) − 24(main-container 下内边距)
|
||||
(实测值,见 layout.css .header-container/.path-nav/.base-wrap-main .main-container) */
|
||||
(实测值,见通道 base.layout.css .header-container/.path-nav/.base-wrap-main .main-container) */
|
||||
height: calc(100vh - 136px);
|
||||
background: #f5f7fa;
|
||||
}
|
||||
@@ -553,36 +825,31 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
font-size: 18px;
|
||||
}
|
||||
.hd-back:hover {
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
}
|
||||
.hd-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
.hd-mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.hd-mid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #808695;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hd-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 顶栏工具栏分组竖线(2026-10-15 痛点 7):左侧「查看/配置类」与右侧「保存草稿」主操作拉开层级 */
|
||||
.hd-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #e5e6eb;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.ed-canvas {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px 16px 60px;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
@@ -621,28 +888,189 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
color: #808695;
|
||||
border: 1.5px solid #808695;
|
||||
}
|
||||
.ed-status {
|
||||
/* 升版本改动说明弹窗(议题二③,规格对齐原型 V2) */
|
||||
.cl-note {
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd77a;
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
color: #8a5a17;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cl-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cl-skip {
|
||||
font-weight: 400;
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.cl-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.cl-chip {
|
||||
border: 1px solid #e3e6eb;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 3px 11px;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cl-chip:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
.cl-hint {
|
||||
float: left;
|
||||
font-size: 12px;
|
||||
color: #b0b6c1;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
/* 议题一:左画布 + 右信息面板(原型 E1,面板宽 340) */
|
||||
.ed-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.ed-info {
|
||||
width: 340px;
|
||||
flex: none;
|
||||
overflow: auto;
|
||||
border-left: 1px solid #e8eaec;
|
||||
background: #fff;
|
||||
padding: 4px 14px 20px;
|
||||
}
|
||||
.ip-sec {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f1f3;
|
||||
}
|
||||
.ip-sec:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.ip-sec h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.ip-mini {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: #a5abb8;
|
||||
}
|
||||
.ip-fld {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ip-fld label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ip-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ip-guid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
gap: 8px;
|
||||
}
|
||||
.ip-mono {
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
z-index: 9;
|
||||
color: #515a6e;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ed-status.bad {
|
||||
background: #fff5f5;
|
||||
.ip-tip {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #a5abb8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.st-ok {
|
||||
.ip-ok {
|
||||
font-size: 12.5px;
|
||||
color: #19be6b;
|
||||
}
|
||||
.st-bad {
|
||||
.ip-issue {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
padding: 5px 6px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ip-issue.err {
|
||||
background: #fff5f5;
|
||||
color: #ed4014;
|
||||
}
|
||||
.st-right {
|
||||
margin-left: auto;
|
||||
color: #b0b6c1;
|
||||
white-space: nowrap;
|
||||
.ip-issue.warn {
|
||||
background: #fff9f0;
|
||||
color: #ff9900;
|
||||
}
|
||||
.ip-issue.loc {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ip-issue.loc:hover {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
.ip-issue.fix {
|
||||
cursor: default;
|
||||
}
|
||||
.ip-ic {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ip-tx {
|
||||
flex: 1;
|
||||
}
|
||||
.ip-sub {
|
||||
color: #a5abb8;
|
||||
}
|
||||
.ip-go {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ip-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.ip-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
background: #f7f9fc;
|
||||
border-radius: 6px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.ip-stat b {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
color: #17233d;
|
||||
}
|
||||
.ip-stat span {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
/* 校验清单定位高亮(原型 E2,用项目错误红不做原型紫);卡片在子组件内,需 :deep 命中 */
|
||||
:deep(.nd-locate) {
|
||||
box-shadow: 0 0 0 4px rgba(237, 64, 20, 0.18) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,26 @@ import type { InjectionKey } from "vue";
|
||||
|
||||
export type Countersign = "ALL" | "ANY";
|
||||
|
||||
/**
|
||||
* UserBase 新部门目录引用(C1,2026-10-29)。
|
||||
* 学校范围不落在前端:定义行的 schId 由后端从 CAS 会话派生,前端没有可越界的校域入口。
|
||||
*/
|
||||
export interface DeptRef {
|
||||
/** SYSTEM=组织架构组;CUSTOM=自定义部门。同值 id 在两类目录里不等价,必须带消歧位 */
|
||||
departmentType: "SYSTEM" | "CUSTOM";
|
||||
departmentId: number;
|
||||
/** 仅展示用名称快照(由目录解析回填,不参与关联与保存判据) */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 审批/抄送主体=部门(UserBase 权威目录;新配置唯一写入口径) */
|
||||
export interface AssigneeDept {
|
||||
type: "DEPT";
|
||||
deptRefs: DeptRef[];
|
||||
}
|
||||
|
||||
/** 历史本地科室引用(DEPT_KEY):只读兼容,编辑器不再产生新引用 */
|
||||
export interface AssigneeLegacyDept {
|
||||
type: "DEPT_KEY";
|
||||
deptKey: string;
|
||||
}
|
||||
@@ -17,7 +36,18 @@ export interface AssigneeUsers {
|
||||
type: "USERS";
|
||||
userIds: number[];
|
||||
}
|
||||
export type Assignee = AssigneeDept | AssigneeUsers;
|
||||
export type Assignee = AssigneeDept | AssigneeLegacyDept | AssigneeUsers;
|
||||
|
||||
/** 节点展示方式(C3):USER=显示人员(缺省)|DEPT=显示部门(独立名称,可手工改) */
|
||||
export type DisplayMode = "USER" | "DEPT";
|
||||
|
||||
/** 发起时指派:mode SINGLE=恰 1 人|MULTI=min~max;required 仅子应用 UI 必选提示 */
|
||||
export interface AssignPolicy {
|
||||
mode: "SINGLE" | "MULTI";
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalNode {
|
||||
uid: string;
|
||||
@@ -28,13 +58,19 @@ export interface ApprovalNode {
|
||||
countersign: Countersign;
|
||||
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
||||
deadlineHours: number | null;
|
||||
/** 发起时指派策略:存在即允许指派;生成 JSON 时关闭则不写字段(与 countersign 省略风格一致) */
|
||||
assignPolicy?: AssignPolicy | null;
|
||||
/** 多出口(≥2 才有多出口语义):出口按钮文案,办理人按下哪个按钮走哪条泳道;单出口不写字段 */
|
||||
outlets?: string[];
|
||||
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
|
||||
branches?: EditorItem[][];
|
||||
/** 节点展示方式(C3):缺省按人员;按部门时用 displayLabel 作独立展示名 */
|
||||
displayMode?: DisplayMode;
|
||||
/** 展示名称(按部门时生效;主体是部门时前端默认填入所选部门名,主体是人员时须管理员手工填写) */
|
||||
displayLabel?: string;
|
||||
}
|
||||
|
||||
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */
|
||||
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT/DEPT_KEY/USERS) */
|
||||
export interface NotifyNode {
|
||||
uid: string;
|
||||
kind: "notify";
|
||||
@@ -49,17 +85,25 @@ export interface ParallelNode {
|
||||
branches: EditorItem[][];
|
||||
}
|
||||
|
||||
/** CONDITION 判断方式(2026-10-10 四议题):SERVICE=外部判断服务(缺省)|EXPRESSION=内置表达式引擎 */
|
||||
export type JudgeMode = "SERVICE" | "EXPRESSION";
|
||||
|
||||
/**
|
||||
* 条件分支容器(2026-09-24 CONDITION):运行时同步调外部判断服务(flw_router,routerKey 寻址)
|
||||
* 按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,分支尾隐式汇合到容器后继。
|
||||
* outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),必须非空且唯一。
|
||||
* 条件分支容器(2026-09-24 CONDITION):按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,
|
||||
* 分支尾隐式汇合到容器后继。outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),
|
||||
* 必须非空且唯一。判断方式二选一(2026-10-10):SERVICE 调 flw_router 注册的外部判断服务;
|
||||
* EXPRESSION 用出边 condition 表达式(AviatorScript,按分支顺序取首个 true)。
|
||||
*/
|
||||
export interface ConditionNode {
|
||||
uid: string;
|
||||
kind: "condition";
|
||||
name: string;
|
||||
/** 判断服务注册键(flw_router.router_key,发布校验须已注册且启用) */
|
||||
/** 判断服务注册键(flw_router.router_key,SERVICE 模式发布校验须已注册且启用) */
|
||||
routerKey: string;
|
||||
/** 判断方式:缺省 SERVICE(旧定义无此字段) */
|
||||
judgeMode?: JudgeMode;
|
||||
/** EXPRESSION 模式各出口条件表达式(与 outlets 一一对应;SERVICE 模式无意义) */
|
||||
conditions?: string[];
|
||||
/** 各分支出口名(与 branches 一一对应) */
|
||||
outlets: string[];
|
||||
branches: EditorItem[][];
|
||||
@@ -79,11 +123,37 @@ export interface EditorSettings {
|
||||
urge: UrgeSetting;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量契约项(D1,与后端 engine/FlowDefModel.Variable + VariableContract 同口径)。
|
||||
* 变量由业务应用在发起时提供,FL 只用它做条件判断——不要求绑定 form-center、不读表单 schema。
|
||||
*/
|
||||
export interface FlowVariable {
|
||||
key: string;
|
||||
/** 后端 VariableContract.TYPES 的取值集 */
|
||||
type: "string" | "number" | "boolean" | "date" | "datetime" | "money" | "stringArray" | "fileArray" | "userArray";
|
||||
required?: boolean;
|
||||
/** 仅 string/stringArray 允许;空数组不写(后端按"无值域"处理) */
|
||||
enum?: string[];
|
||||
/**
|
||||
* 编辑态专用:值域的逗号分隔输入串。只活在浏览器内存里,
|
||||
* convert.toContentJson 不写这个键(值域以 enum 数组为准)。
|
||||
*/
|
||||
enumText?: string;
|
||||
}
|
||||
|
||||
/** 后端 VariableContract.TYPES 的前端镜像;新增类型必须先有服务端支持,不能只改下拉 */
|
||||
export const VARIABLE_TYPES: FlowVariable["type"][] = [
|
||||
"string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray",
|
||||
];
|
||||
|
||||
export interface EditorModel {
|
||||
code: string;
|
||||
name: string;
|
||||
code: string; name: string;
|
||||
list: EditorItem[];
|
||||
settings: EditorSettings;
|
||||
/**
|
||||
* 变量契约(undefined=本定义从未声明过,保存时按空集显式写出——后端新发布必须显式声明)。
|
||||
*/
|
||||
variables?: FlowVariable[];
|
||||
/**
|
||||
* START/END 节点的 name:编辑器里端点是隐式固定的(不展示、不可编辑),但载入既有定义时
|
||||
* 原样保留,生成 JSON 时回写——避免保存一次就静默丢掉平台里已存在的自定义端点名。
|
||||
@@ -104,15 +174,25 @@ export interface DefNodeJson {
|
||||
receivers?: Assignee;
|
||||
/** CONDITION 节点的判断服务注册键(flw_router.router_key) */
|
||||
routerKey?: string;
|
||||
/** CONDITION 判断方式:SERVICE(缺省)|EXPRESSION(出边 condition 表达式) */
|
||||
judgeMode?: string;
|
||||
countersign?: Countersign;
|
||||
waitFor?: string;
|
||||
deadlineHours?: number;
|
||||
/** 发起时指派策略(审批节点可选,2026-10-13;关闭态不写字段) */
|
||||
assignPolicy?: AssignPolicy;
|
||||
/** 节点展示方式(C3,可选):缺省=按人员;历史定义无此字段,读取行为不变 */
|
||||
displayMode?: DisplayMode;
|
||||
/** 展示名称(displayMode=DEPT 时生效,见后端 FlowDefModel.Node#displayName) */
|
||||
displayLabel?: string;
|
||||
}
|
||||
|
||||
export interface DefEdgeJson {
|
||||
from: string;
|
||||
to: string;
|
||||
name?: string;
|
||||
/** CONDITION EXPRESSION 模式的出口条件表达式(AviatorScript;SERVICE 模式不携带) */
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface DefJson {
|
||||
@@ -122,6 +202,8 @@ export interface DefJson {
|
||||
nodes: DefNodeJson[];
|
||||
edges: DefEdgeJson[];
|
||||
settings: EditorSettings;
|
||||
/** 变量契约(D1):新发布版本后端要求显式声明,无业务变量时为 [] */
|
||||
variables?: FlowVariable[];
|
||||
}
|
||||
|
||||
// ==================== 常量 ====================
|
||||
@@ -136,6 +218,60 @@ export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"];
|
||||
|
||||
export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 };
|
||||
|
||||
// ==================== 表达式变量契约(2026-10-15 抽共享,消除三处漂移) ====================
|
||||
/**
|
||||
* 表达式内置变量白名单。
|
||||
* 唯一权威来源是后端 `ConditionRouteService.builtinEnv`(运行时注入的同名集)。
|
||||
* 前端两处消费者(ConditionBox 的 chips、validate 的未知变量比对)必须共用此常量,
|
||||
* 改动时需同步后端 `DefValidator.EXPRESSION_BUILTINS` 与 `ConditionRouteService.builtinEnv`。
|
||||
*/
|
||||
export const EXPR_BUILTIN_VARS = [
|
||||
"schId",
|
||||
"instanceId",
|
||||
"instanceVersion",
|
||||
"bizType",
|
||||
"bizId",
|
||||
"nodeId",
|
||||
"nodeName",
|
||||
"submitterId",
|
||||
] as const;
|
||||
|
||||
/** 内置变量快速判定集合 */
|
||||
export const EXPR_BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
|
||||
|
||||
/**
|
||||
* AviatorScript 关键字/字面量:不参与"未知变量"提示。
|
||||
* 与后端表达式语法(AviatorScript)对齐,仅用于静态提取时的剔除。
|
||||
*/
|
||||
export const EXPR_KEYWORDS: ReadonlySet<string> = new Set([
|
||||
"true", "false", "nil", "null", "and", "or", "not", "if", "else", "elsif", "let", "while",
|
||||
"for", "return", "in", "like", "new", "end", "break", "continue", "seq", "lambda",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 从表达式里静态提取"疑似变量名"(剔除字符串字面量、函数名、属性访问、关键字)。
|
||||
* 与后端 `ExpressionEvaluator.variableNames()` 同口径的前端简化实现:
|
||||
* 后端解析 AST 更精确,前端只做词法级粗筛——因此结果**只用于 warn 提示,绝不用于阻断**。
|
||||
*/
|
||||
export function extractExprVars(expression: string): string[] {
|
||||
const cond = (expression || "").trim();
|
||||
if (!cond) return [];
|
||||
const stripped = cond
|
||||
.replace(/'[^']*'|"[^"]*"/g, " ") // 字符串字面量
|
||||
.replace(/[A-Za-z_][A-Za-z0-9_]*\s*\(/g, " ") // 函数名
|
||||
.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, " "); // 命名空间/属性访问
|
||||
const out = new Set<string>();
|
||||
(stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []).forEach((t) => {
|
||||
if (!EXPR_KEYWORDS.has(t)) out.add(t);
|
||||
});
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
/** 表达式中不在「已知变量集合」里的标识符(疑似拼错;仅提示不拦截) */
|
||||
export function unknownExprVars(expression: string, known: ReadonlySet<string>): string[] {
|
||||
return extractExprVars(expression).filter((v) => !known.has(v));
|
||||
}
|
||||
|
||||
// ==================== 工厂 ====================
|
||||
|
||||
let uidSeq = 0;
|
||||
@@ -165,13 +301,15 @@ export function createParallel(): ParallelNode {
|
||||
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
|
||||
}
|
||||
|
||||
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断服务 */
|
||||
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断方式(2026-10-12 收口:缺省表达式) */
|
||||
export function createCondition(): ConditionNode {
|
||||
return {
|
||||
uid: newUid(),
|
||||
kind: "condition",
|
||||
name: "条件判断",
|
||||
routerKey: "",
|
||||
judgeMode: "EXPRESSION",
|
||||
conditions: ["", ""],
|
||||
outlets: ["出口一", "出口二"],
|
||||
branches: [[createApproval()], [createApproval()]],
|
||||
};
|
||||
@@ -184,6 +322,7 @@ export function createEmptyModel(): EditorModel {
|
||||
name: "",
|
||||
list: [createApproval()],
|
||||
settings: { rejectPolicy: "END_REJECTED", urge: { ...DEFAULT_URGE } },
|
||||
variables: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,9 +432,10 @@ export interface DeptOption {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 审批人摘要文案(卡片第二行;deptOptions 来自 mapList,userNames 为 userId→姓名 快照) */
|
||||
/** 审批人摘要文案(卡片第二行;deptOptions 来自科室管理列表,userNames 为 userId→姓名 快照) */
|
||||
export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
|
||||
if (!a) return "未配置审批人";
|
||||
if (a.type === "DEPT") return subjectText("审批人", deptRefText(a.deptRefs));
|
||||
if (a.type === "DEPT_KEY") {
|
||||
if (!a.deptKey) return "审批人:未选择科室";
|
||||
const d = depts.find((x) => x.deptKey === a.deptKey);
|
||||
@@ -310,6 +450,7 @@ export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames:
|
||||
/** 卡片标签行(会签/或签 + 时限) */
|
||||
export function nodeTags(n: ApprovalNode): string[] {
|
||||
const tags = [n.countersign === "ANY" ? "或签" : "会签"];
|
||||
if (n.assignPolicy) tags.push("可指派");
|
||||
if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`);
|
||||
return tags;
|
||||
}
|
||||
@@ -317,6 +458,7 @@ export function nodeTags(n: ApprovalNode): string[] {
|
||||
/** 抄送对象摘要文案(抄送卡片第二行;口径同 assigneeText,前缀换"抄送") */
|
||||
export function receiversText(r: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
|
||||
if (!r) return "未配置抄送对象";
|
||||
if (r.type === "DEPT") return subjectText("抄送", deptRefText(r.deptRefs));
|
||||
if (r.type === "DEPT_KEY") {
|
||||
if (!r.deptKey) return "抄送:未选择科室";
|
||||
const d = depts.find((x) => x.deptKey === r.deptKey);
|
||||
@@ -328,14 +470,28 @@ export function receiversText(r: Assignee | null, depts: DeptOption[], userNames
|
||||
return `抄送:${r.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`;
|
||||
}
|
||||
|
||||
/** 引用键的口径化文本:只报"引用了哪几个部门",名称由部门目录解析,不在前端编造 */
|
||||
function deptRefText(refs: DeptRef[] | undefined): string {
|
||||
if (!refs || !refs.length) return "";
|
||||
return refs.map((r) => `${r.departmentType}:${r.departmentId}`).join("、");
|
||||
}
|
||||
|
||||
function subjectText(prefix: string, body: string): string {
|
||||
return body ? `${prefix}:部门(UserBase 目录)${body}` : `${prefix}:未选择部门`;
|
||||
}
|
||||
|
||||
// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ====================
|
||||
|
||||
export interface EditorCtx {
|
||||
model: EditorModel;
|
||||
depts: DeptOption[];
|
||||
userNames: Record<number, string>;
|
||||
/** 当前流程线 flowGuid(新建/尚未保存时为空):指派预览与已发布版本对账要用 */
|
||||
flowGuid: string;
|
||||
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
|
||||
routers: RouterOption[];
|
||||
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */
|
||||
availableVars: string[];
|
||||
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
|
||||
openNode: (uid: string) => void;
|
||||
removeNode: (uid: string) => void;
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
import {
|
||||
BRANCH_LABELS,
|
||||
DeptOption,
|
||||
EXPR_BUILTIN_SET,
|
||||
EditorItem,
|
||||
EditorModel,
|
||||
MAX_BRANCHES,
|
||||
MIN_BRANCHES,
|
||||
RouterOption,
|
||||
unknownExprVars,
|
||||
} from "./model";
|
||||
|
||||
export type IssueLevel = "error" | "warn";
|
||||
@@ -28,11 +30,23 @@ export interface Issue {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function validateModel(model: EditorModel, depts: DeptOption[], routers: RouterOption[] = []): Issue[] {
|
||||
/**
|
||||
* @param availableVars 该归属应用近期办件 formData 顶层键聚合(编辑器已拉取)。
|
||||
* 仅用于表达式变量的"疑似拼错"**警告**(2026-10-15 P0),不参与任何 error 级判定——
|
||||
* 为空数组时退化为仅比对内置变量,宁少报不误杀(动态变量场景)。
|
||||
*/
|
||||
export function validateModel(
|
||||
model: EditorModel,
|
||||
depts: DeptOption[],
|
||||
routers: RouterOption[] = [],
|
||||
availableVars: string[] = []
|
||||
): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
|
||||
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
|
||||
};
|
||||
/** 已知变量集:formData 聚合键 + 内置变量(与 ConditionBox chips 同口径) */
|
||||
const knownVars: ReadonlySet<string> = new Set<string>([...availableVars, ...EXPR_BUILTIN_SET]);
|
||||
|
||||
const rec = (items: EditorItem[], path: string): void => {
|
||||
items.forEach((item) => {
|
||||
@@ -52,6 +66,15 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`);
|
||||
}
|
||||
}
|
||||
} else if (a.type === "DEPT") {
|
||||
if (!a.deptRefs || !a.deptRefs.length) {
|
||||
push(item.uid, "error", path, "未选择部门(UserBase 目录)");
|
||||
} else if (a.deptRefs.some((r) => !r.departmentType || !r.departmentId)) {
|
||||
push(item.uid, "error", path, "部门引用缺少 departmentType/departmentId");
|
||||
}
|
||||
if (item.displayMode === "DEPT" && !(item.displayLabel || "").trim()) {
|
||||
push(item.uid, "error", path, "选择「显示部门」时必须填写展示名称,发布会被拦截");
|
||||
}
|
||||
} else if (!a.userIds.length) {
|
||||
push(item.uid, "error", path, "指定用户不能为空");
|
||||
}
|
||||
@@ -69,7 +92,8 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
(item.branches || []).forEach((branch, i) => {
|
||||
const p = `${path}·出口「${item.outlets![i] || i + 1}」`;
|
||||
if (!branch.length) {
|
||||
push(item.uid, "error", p, "出口泳道为空,请添加节点");
|
||||
// 空出口泳道(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
|
||||
push(item.uid, "warn", p, "出口泳道为空:该出口直达汇合点(直接通过)");
|
||||
return;
|
||||
}
|
||||
rec(branch, p);
|
||||
@@ -95,6 +119,12 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`);
|
||||
}
|
||||
}
|
||||
} else if (r.type === "DEPT") {
|
||||
if (!r.deptRefs || !r.deptRefs.length) {
|
||||
push(item.uid, "error", path, "未选择抄送部门(UserBase 目录)");
|
||||
} else if (r.deptRefs.some((x) => !x.departmentType || !x.departmentId)) {
|
||||
push(item.uid, "error", path, "抄送部门引用缺少 departmentType/departmentId");
|
||||
}
|
||||
} else if (!r.userIds.length) {
|
||||
push(item.uid, "error", path, "抄送用户不能为空");
|
||||
}
|
||||
@@ -103,9 +133,29 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
|
||||
if (item.kind === "condition") {
|
||||
// 条件分支(2026-09-24):判断服务必选(未注册仅 warn,发布期后端权威拦截);
|
||||
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)
|
||||
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)。
|
||||
// EXPRESSION(2026-10-10):每出口条件非空(语法后端拦,前端不重复实现)
|
||||
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
|
||||
if (!item.routerKey) {
|
||||
const expression = item.judgeMode === "EXPRESSION";
|
||||
if (expression) {
|
||||
item.outlets.forEach((o, i) => {
|
||||
const cond = (item.conditions || [])[i] || "";
|
||||
if (!cond.trim()) {
|
||||
push(item.uid, "error", path,
|
||||
`出口「${o || i + 1}」未配条件表达式(表达式模式下每个出口必须配置,发布会被拦截)`);
|
||||
return;
|
||||
}
|
||||
// 变量引用静态校验(2026-10-15 P0):疑似拼错的键给 warn,**不阻断**发布。
|
||||
// 理由:formData 键由子应用在运行期决定,动态变量场景误杀代价高于漏报;
|
||||
// 服务端 DefValidator.warnBuiltinMisspelling 是权威口径(含 AST 级解析)。
|
||||
const unknown = unknownExprVars(cond, knownVars);
|
||||
if (unknown.length) {
|
||||
push(item.uid, "warn", path,
|
||||
`出口「${o || i + 1}」表达式引用了未在已知变量中的 ${unknown
|
||||
.map((v) => `「${v}」`).join("、")},疑似拼错(若为动态变量可忽略)`);
|
||||
}
|
||||
});
|
||||
} else if (!item.routerKey) {
|
||||
push(item.uid, "error", path, "未选择判断服务(发布后运行到该节点会挂起)");
|
||||
} else if (routers.length && !routers.some((r) => r.routerKey === item.routerKey && r.status === 1)) {
|
||||
push(item.uid, "warn", path, `判断服务「${item.routerKey}」未注册或已停用,发布会被拦截`);
|
||||
@@ -126,7 +176,8 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
||||
item.branches.forEach((branch, i) => {
|
||||
const branchPath = `${path}·出口「${item.outlets[i] || BRANCH_LABELS[i] || i + 1}」`;
|
||||
if (!branch.length) {
|
||||
push(item.uid, "error", branchPath, "分支为空,请添加节点");
|
||||
// 空出口分支(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
|
||||
push(item.uid, "warn", branchPath, "分支为空:该出口直达汇合点(直接通过)");
|
||||
return;
|
||||
}
|
||||
rec(branch, branchPath);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
<script setup lang="ts">
|
||||
// 表达式调试弹窗(议题三,原型 S1-S5):三模式取变量 + 与运行时同一求值口径(/admin/router/eval)。
|
||||
// 抽成共享组件供条件路由页与(B5)编辑器出口试算共用:外部只传 modelValue / presetExpression。
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Message, Tag } from "view-ui-plus";
|
||||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, flowApi } from "@/api/module/flow";
|
||||
import { EXPR_BUILTIN_VARS } from "@/views/flow/admin/def-editor/model";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; presetExpression?: string }>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
|
||||
|
||||
type Mode = "instance" | "routelog" | "manual";
|
||||
const mode = ref<Mode>("instance");
|
||||
const expr = ref("");
|
||||
const varsJson = ref("");
|
||||
const varsParseError = ref(false);
|
||||
|
||||
const instances = ref<any[]>([]);
|
||||
const instLoading = ref(false);
|
||||
const pickedId = ref<any>(null);
|
||||
const bizTypeFilter = ref("");
|
||||
const traceRows = ref<any[]>([]);
|
||||
const traceLoading = ref(false);
|
||||
const traceInstanceId = ref<any>(null);
|
||||
const result = ref<{ result: boolean; value?: string; error?: string } | null>(null);
|
||||
const evaluating = ref(false);
|
||||
const exprBox = ref<any>(null);
|
||||
|
||||
/**
|
||||
* 内置变量(2026-10-15 抽共享:与 ConditionBox chips、后端 builtinEnv 同源)——
|
||||
* 线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填。
|
||||
* 展示顺序沿用原口径(schId 置末),故此处按名过滤而非直接复用数组顺序。
|
||||
*/
|
||||
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
|
||||
const BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
|
||||
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
|
||||
const builtinRows = computed(() => {
|
||||
const p = picked.value;
|
||||
if (!p) return [];
|
||||
return [
|
||||
{ k: "instanceId", v: p.id },
|
||||
{ k: "instanceVersion", v: p.version },
|
||||
{ k: "bizType", v: p.bizType },
|
||||
{ k: "bizId", v: p.bizId },
|
||||
{ k: "submitterId", v: p.submitterId },
|
||||
{ k: "nodeId", v: "", todo: true },
|
||||
{ k: "nodeName", v: "", todo: true },
|
||||
];
|
||||
});
|
||||
|
||||
/** 当前变量 JSON 的顶层键(解析失败为空,不阻塞手动编辑) */
|
||||
const formKeys = computed<string[]>(() => {
|
||||
try {
|
||||
const v = JSON.parse(varsJson.value || "{}");
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/** 手动模式的键来源:最近 50 条办件 formData 聚合出现过的键(去重后截 40) */
|
||||
const manualKeys = computed<string[]>(() => {
|
||||
const set = new Set<string>();
|
||||
instances.value.forEach((r) => {
|
||||
try {
|
||||
const v = JSON.parse(r.formDataJson || "{}");
|
||||
if (v && typeof v === "object") Object.keys(v).forEach((k) => set.add(k));
|
||||
} catch {
|
||||
/* 脏 formData 跳过,不影响其它办件的键聚合 */
|
||||
}
|
||||
});
|
||||
return Array.from(set).slice(0, 40);
|
||||
});
|
||||
/** chips:formData 键(手动模式取历史办件聚合键)+ 内置键;与键名重复时去重,内置键单独置灰标注 */
|
||||
const chips = computed(() => Array.from(new Set([
|
||||
...(mode.value === "manual" ? manualKeys.value : formKeys.value),
|
||||
...BUILTIN_KEYS,
|
||||
])));
|
||||
|
||||
/** bizType 筛选 chips(取当前页 distinct) */
|
||||
const bizTypes = computed(() => Array.from(new Set(instances.value.map((r) => r.bizType).filter(Boolean))));
|
||||
const shownInstances = computed(() =>
|
||||
bizTypeFilter.value ? instances.value.filter((r) => r.bizType === bizTypeFilter.value) : instances.value
|
||||
);
|
||||
|
||||
async function loadInstances(): Promise<void> {
|
||||
instLoading.value = true;
|
||||
try {
|
||||
const res: any = await flowApi.adminInstances({ page: 1, size: 50 }).send();
|
||||
instances.value = res?.content || [];
|
||||
} catch (e: any) {
|
||||
instances.value = [];
|
||||
Message.error(e?.message || "办件列表拉取失败");
|
||||
} finally {
|
||||
instLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中办件 → formData 解析后缩进填入;解析失败填原文并红字提示 */
|
||||
function fillFromInstance(id: any): void {
|
||||
const p = instances.value.find((r) => r.id === id);
|
||||
if (!p) return;
|
||||
result.value = null;
|
||||
varsParseError.value = false;
|
||||
try {
|
||||
varsJson.value = JSON.stringify(JSON.parse(p.formDataJson || "{}"), null, 2);
|
||||
} catch {
|
||||
varsJson.value = String(p.formDataJson || "");
|
||||
varsParseError.value = true;
|
||||
}
|
||||
if (mode.value === "routelog") void loadTrace(p.id);
|
||||
}
|
||||
|
||||
async function loadTrace(instanceId: any): Promise<void> {
|
||||
if (!instanceId) return;
|
||||
traceLoading.value = true;
|
||||
traceInstanceId.value = instanceId;
|
||||
try {
|
||||
const d: any = await flowApi.detail(instanceId).send();
|
||||
traceRows.value = (d?.trace || []).filter((r: any) => r.kind === "ROUTE");
|
||||
} catch (e: any) {
|
||||
traceRows.value = [];
|
||||
Message.error(e?.message || "办件详情拉取失败");
|
||||
} finally {
|
||||
traceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 载入该办件变量并复现(保留当前表达式) */
|
||||
async function replayFromLog(): Promise<void> {
|
||||
if (!traceInstanceId.value) {
|
||||
Message.warning("请先选择办件");
|
||||
return;
|
||||
}
|
||||
fillFromInstance(traceInstanceId.value);
|
||||
await doEval();
|
||||
}
|
||||
|
||||
function outletText(r: any): string {
|
||||
return r.outlet ? r.outlet : "未命中";
|
||||
}
|
||||
|
||||
/** chips 点击插入表达式光标处 */
|
||||
function insertChip(key: string): void {
|
||||
const input: HTMLInputElement | null = exprBox.value?.$el?.querySelector?.("input") || null;
|
||||
const cur = expr.value;
|
||||
if (!input) {
|
||||
expr.value = cur ? `${cur} ${key}` : key;
|
||||
return;
|
||||
}
|
||||
const at = input.selectionStart ?? cur.length;
|
||||
const end = input.selectionEnd ?? at;
|
||||
expr.value = cur.slice(0, at) + key + cur.slice(end);
|
||||
requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
input.setSelectionRange(at + key.length, at + key.length);
|
||||
});
|
||||
}
|
||||
|
||||
async function doEval(): Promise<void> {
|
||||
if (!expr.value.trim()) {
|
||||
Message.warning("请填写表达式");
|
||||
return;
|
||||
}
|
||||
evaluating.value = true;
|
||||
result.value = null;
|
||||
try {
|
||||
result.value = (await flowApi.routerEval({
|
||||
expression: expr.value.trim(),
|
||||
variablesJson: varsJson.value.trim(),
|
||||
}).send()) || null;
|
||||
} finally {
|
||||
evaluating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
mode.value = "instance";
|
||||
expr.value = props.presetExpression || "";
|
||||
varsJson.value = "";
|
||||
varsParseError.value = false;
|
||||
pickedId.value = null;
|
||||
bizTypeFilter.value = "";
|
||||
traceRows.value = [];
|
||||
traceInstanceId.value = null;
|
||||
result.value = null;
|
||||
void loadInstances();
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :model-value="modelValue" title="表达式调试" :mask-closable="false" :width="700"
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)">
|
||||
<p class="em-note">
|
||||
与 CONDITION 节点 EXPRESSION 模式同一套求值(AviatorScript 沙箱)。变量 = 样例 JSON 的键 +
|
||||
线上另注入 schId/bizType/bizId/submitterId/instanceId/instanceVersion/nodeId/nodeName
|
||||
(内置变量条仅展示,不参与求值,需在表达式里用到时请自行写入样例 JSON)。
|
||||
变量未提供会报错——线上同口径(宁挂起不误放行,不按 nil 求值)。
|
||||
</p>
|
||||
|
||||
<RadioGroup v-model="mode" type="button" button-style="solid" class="em-seg">
|
||||
<Radio label="instance">从办件导入</Radio>
|
||||
<Radio label="routelog">从路由日志导入</Radio>
|
||||
<Radio label="manual">手动编写</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<div class="em-fld">
|
||||
<label>表达式<span class="em-sub">AviatorScript 沙箱 · 与运行时同一求值口径</span></label>
|
||||
<Input ref="exprBox" v-model="expr" class="em-mono" placeholder="表达式,如 days >= 3" :maxlength="200" />
|
||||
<div v-if="chips.length" class="em-chips">
|
||||
<span v-for="k in chips" :key="k" class="em-chip" :class="{ bt: BUILTIN_SET.has(k) }"
|
||||
@click="insertChip(k)">{{ k }}</span>
|
||||
</div>
|
||||
<div v-if="chips.length" class="em-sub">↑ 可用变量(点击插入表达式光标处;灰色=内置变量,由运行时自动注入)</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式1/2 共用办件选择器;路由日志模式选中即拉该办件 trace -->
|
||||
<div v-if="mode !== 'manual'" class="em-fld">
|
||||
<label>选择办件
|
||||
<span class="em-sub">变量 = 该办件 formData 原样 + 内置变量;选完自动填充,可再手工改</span>
|
||||
</label>
|
||||
<Select v-model="pickedId" filterable clearable :loading="instLoading"
|
||||
placeholder="按标题/业务键搜索最近 50 条办件" @on-change="fillFromInstance">
|
||||
<Option v-for="r in shownInstances" :key="r.id" :value="r.id"
|
||||
:label="`#${r.id} ${r.bizTitle || r.bizId || ''}`">
|
||||
#{{ r.id }} · {{ r.bizType || "-" }}/{{ r.bizId || "-" }} · {{ r.bizTitle || "(无标题)" }}
|
||||
· {{ r.submitterName || r.submitterId || "-" }} ·
|
||||
{{ (r.submitTime || "").replace("T", " ").slice(0, 16) || "-" }}
|
||||
<Tag :color="INSTANCE_STATUS_COLOR[r.status] || 'gray'" style="margin-left: 6px">
|
||||
{{ INSTANCE_STATUS[r.status] || r.status }}
|
||||
</Tag>
|
||||
</Option>
|
||||
</Select>
|
||||
<div v-if="bizTypes.length" class="em-chips">
|
||||
<span class="em-chip flt" :class="{ on: !bizTypeFilter }" @click="bizTypeFilter = ''">全部类型</span>
|
||||
<span v-for="t in bizTypes" :key="t" class="em-chip flt" :class="{ on: bizTypeFilter === t }"
|
||||
@click="bizTypeFilter = t">{{ t }}</span>
|
||||
</div>
|
||||
<div v-if="picked" class="em-bi">
|
||||
<span class="em-bi-t">内置变量(线上由引擎注入,只读展示):</span>
|
||||
<span v-for="b in builtinRows" :key="b.k" class="em-bi-i">
|
||||
{{ b.k }}={{ b.todo ? "(按调试目标自填)" : b.v }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'routelog'" class="em-fld">
|
||||
<div v-if="traceLoading" class="em-sub">正在读取该办件的路由判断留痕…</div>
|
||||
<template v-else-if="traceRows && traceRows.length">
|
||||
<Table :columns="[
|
||||
{ title: '节点名', key: 'nodeName', width: 140 },
|
||||
{ title: '注册键', key: 'routerKey', width: 150 },
|
||||
{ title: '命中出口', width: 120, render: (h, p) => outletText(p.row) },
|
||||
{ title: 'attempt', key: 'attempt', width: 80 },
|
||||
{ title: '时间', width: 140, render: (h, p) => (p.row.time || '').replace('T', ' ').slice(0, 19) || '-' },
|
||||
]" :data="traceRows" size="small" :max-height="180" no-data-text="暂无数据" />
|
||||
<div v-for="(r, i) in traceRows" :key="`raw${i}`" class="em-raw">
|
||||
<span class="em-sub">{{ r.nodeName }} rawResponse({{ (r.rawResponse || "").length }} 字):</span>
|
||||
<pre :title="r.rawResponse || ''">{{ (r.rawResponse || "(无留痕)").slice(0, 200)
|
||||
}}{{ (r.rawResponse || "").length > 200 ? "…" : "" }}</pre>
|
||||
</div>
|
||||
<Button size="small" :loading="evaluating" @click="replayFromLog">载入该办件变量并复现</Button>
|
||||
<div class="em-sub">复现后可改表达式定位问题,再到办件台账对该办件重跑路由。</div>
|
||||
</template>
|
||||
<div v-else class="em-sub">
|
||||
{{ picked ? "该办件无条件判断记录" : "选择办件后展示其 flw_route_log 逐边留痕" }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="em-fld">
|
||||
<label>样例变量 JSON(formData)
|
||||
<span v-if="varsParseError" style="color: #ed4014">该办件 formData 不是合法 JSON,已填原文,请手工修正</span>
|
||||
<span v-else-if="mode === 'manual'" class="em-sub">粘贴应用实际传递的 formData JSON;不知道传什么?切「从办件导入」取真实变量</span>
|
||||
</label>
|
||||
<Input v-model="varsJson" type="textarea" :rows="8" :class="{ 'em-bad': varsParseError }"
|
||||
placeholder='如 {"days": 5, "bizType": "leave"}' />
|
||||
</div>
|
||||
|
||||
<hr class="em-divider">
|
||||
<div class="em-fld">
|
||||
<label>求值结果</label>
|
||||
<div v-if="!result" class="em-sub">点击「求值」查看结果(与 CONDITION 节点 EXPRESSION 模式同一求值口径)。</div>
|
||||
<template v-else-if="result.error">
|
||||
<div class="em-err">✗ 求值失败:{{ result.error }}</div>
|
||||
<div class="em-sub">宁挂起不误放行——变量缺失/类型不符都会在此暴露,线上同口径挂起而非按 nil 放行</div>
|
||||
</template>
|
||||
<template v-else-if="result.result">
|
||||
<div class="em-ok">✓ true(命中该出口)
|
||||
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
|
||||
</div>
|
||||
<div class="em-sub">线上行为:条件节点按出口顺序取首个为真者走该分支</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="em-warn">✗ false(不命中)
|
||||
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
|
||||
</div>
|
||||
<div class="em-sub">线上行为:该出口不走;全部出口不命中将挂起等管理端重跑路由</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="em-hint">变量缺失即报错——线上同口径(宁挂起不误放行,不按 nil 求值)</span>
|
||||
<Button @click="emit('update:modelValue', false)">关闭</Button>
|
||||
<Button type="primary" :loading="evaluating" @click="doEval">求值</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.em-note {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.em-seg {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.em-fld {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.em-fld > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.em-sub {
|
||||
color: #a5abb8;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.em-fld > label .em-sub {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.em-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.em-chip {
|
||||
border: 1px solid #e3e6eb;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: #0b7d72;
|
||||
background: #f2fbf9;
|
||||
cursor: pointer;
|
||||
}
|
||||
.em-chip.flt {
|
||||
color: #515a6e;
|
||||
background: #fff;
|
||||
}
|
||||
/* 内置变量 chip:与 formData 键区分(对齐原型 .chip.bt 口径) */
|
||||
.em-chip.bt {
|
||||
border-color: #dcdee2;
|
||||
color: #515a6e;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.em-chip.bt:hover {
|
||||
color: #fff;
|
||||
background: #515a6e;
|
||||
border-color: #515a6e;
|
||||
}
|
||||
.em-chip.flt.on {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
.em-bi {
|
||||
margin-top: 8px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.em-bi-t {
|
||||
color: #0b7d72;
|
||||
font-weight: 600;
|
||||
}
|
||||
.em-bi-i {
|
||||
margin-right: 10px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
color: #515a6e;
|
||||
}
|
||||
.em-mono :deep(input) {
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
}
|
||||
.em-bad :deep(.ivu-input) {
|
||||
border-color: #ed4014;
|
||||
}
|
||||
.em-raw {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.em-raw pre {
|
||||
margin: 2px 0 0;
|
||||
padding: 6px 8px;
|
||||
background: #fafbfd;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.em-divider {
|
||||
border: none;
|
||||
border-top: 1px solid #eef0f3;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.em-ok {
|
||||
color: #19be6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.em-warn {
|
||||
color: #ff9900;
|
||||
font-size: 13px;
|
||||
}
|
||||
.em-err {
|
||||
color: #ed4014;
|
||||
font-size: 13px;
|
||||
}
|
||||
.em-hint {
|
||||
float: left;
|
||||
color: #b0b6c1;
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,543 @@
|
||||
<script setup lang="ts">
|
||||
// 办件正文(共用组件,2026-10-11):嵌入页 /embed/instance 与管理面办件详情 /instance/:instanceId
|
||||
// 数据源同出 InstanceDetail(OpenEmbedController 复用管理面出参),展示层共用本组件,样式单点维护。
|
||||
// - embed(缺省,关键信息):基本信息 / 办理进度三视图 / 流程示意图折叠 / 业务信息(只认 summaryFields)
|
||||
// - full(管理面,全量):另有 流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号)、
|
||||
// 业务表单数据折叠、业务详情链接;业务信息只认子应用传递的 summaryFields,无则隐藏
|
||||
// 口径:人员/科室切换 emit('mode-change') 由管理面写 query 持久化(嵌入页 URL 归子应用管,忽略即可)。
|
||||
// 宽度:版式按嵌入场景调优(740 抽屉内容约 708px),full 下根类 max-width 封顶防宽屏拉散(见 .is-full)。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
|
||||
|
||||
const props = defineProps<{
|
||||
/** InstanceDetail 形状对象(两页同源) */
|
||||
detail: any;
|
||||
/** true=管理面全量(流程信息/业务表单数据/业务详情);缺省 embed 关键信息 */
|
||||
full?: boolean;
|
||||
/** 流程示意图地址(两页端点不同:token 图 / instanceId 图,由页面生成传入) */
|
||||
diagramSrc: string;
|
||||
/** 人员/科室口径初值(管理面从 query 恢复;缺省 user) */
|
||||
initialMode?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "mode-change", val: string): void }>();
|
||||
|
||||
/** 办理进度视图:node=按节点(缺省)/ timeline=时间线 / list=列表 */
|
||||
const viewMode = ref<string>("node");
|
||||
function switchView(val: string) {
|
||||
viewMode.value = val;
|
||||
}
|
||||
|
||||
/** 显示口径:dept=科室视图 / user=人员视图(缺省)。切换只 emit,落不落 URL 由页面决定 */
|
||||
const traceMode = ref<string>(props.initialMode === "dept" ? "dept" : "user");
|
||||
function switchMode(val: string) {
|
||||
traceMode.value = val;
|
||||
emit("mode-change", val);
|
||||
}
|
||||
|
||||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function onDiagramLoad() {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
// 折叠时容器 clientWidth=0,fitDiagram 取不到收缩上限;展开这一刻再适配一次
|
||||
function onDiagramToggle(e: Event) {
|
||||
if ((e.target as HTMLDetailsElement).open) fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 业务信息唯一内容 = 约定键 bizSnapshot.summaryFields;由发起流程的子应用决定展示字段与中文 label。
|
||||
* 形状错 / 无值 ⇒ [],不再 fallback 平铺 bizSnapshot 原始英文 key(避免详情页出现 itemTitle / deptName 等)。 */
|
||||
const summaryFields = computed(() => {
|
||||
const raw = props.detail?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sf = obj?.summaryFields;
|
||||
if (!Array.isArray(sf) || !sf.length) return [];
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
label: String(x.label),
|
||||
value: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
});
|
||||
|
||||
/** 业务信息最终渲染行:只认子应用传递的 summaryFields */
|
||||
const bizRows = computed(() => summaryFields.value);
|
||||
|
||||
/** full 专用:流程信息五项(专业字段只进管理面,嵌入页不外露) */
|
||||
const flowInfoRows = computed(() => {
|
||||
const d = props.detail || {};
|
||||
return [
|
||||
{ label: "流程编码", value: d.defCode || "-" },
|
||||
{ label: "流程版本", value: d.defVersion !== null && d.defVersion !== undefined ? `v${d.defVersion}` : "-" },
|
||||
{ label: "业务类型", value: d.bizType || "-" },
|
||||
{ label: "业务编号", value: d.bizId !== null && d.bizId !== undefined && d.bizId !== "" ? `#${d.bizId}` : "-" },
|
||||
{ label: "实例号", value: d.instanceId ? `#${d.instanceId}` : "-" },
|
||||
];
|
||||
});
|
||||
|
||||
/** full 专用:业务表单数据(解析失败降级一行原文;无 formData 时整块隐藏) */
|
||||
const formRows = computed(() => {
|
||||
if (!props.full) return [];
|
||||
const raw = props.detail?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ label: "表单原文", value: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({
|
||||
label: k,
|
||||
value: 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 [{ label: "表单原文", value: String(raw) }];
|
||||
});
|
||||
|
||||
/** 业务详情链接(仅管理面):bizUrl 是裸相对路径,必须用后端解析好的绝对地址(bizUrlAbs),
|
||||
* 否则会落到流程中心自己的 origin 上;解析不出就不给入口,不给打不开的链接。 */
|
||||
const bizUrlAbs = computed(() => {
|
||||
const abs = props.detail?.bizUrlAbs;
|
||||
if (abs) return abs;
|
||||
const raw = props.detail?.bizUrl;
|
||||
return /^https?:\/\//i.test(raw || "") ? raw : "";
|
||||
});
|
||||
|
||||
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 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",
|
||||
};
|
||||
|
||||
/** 留痕事件行:不参与节点聚合,每行独立成一步(口径同流程中心 toProgressVo) */
|
||||
const EVENT_KINDS = ["SUBMIT", "RESUBMIT", "WITHDRAW", "CC", "ROUTE"];
|
||||
|
||||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 TraceTimeline 的 multiRound) */
|
||||
const multiRound = computed(() =>
|
||||
(props.detail?.trace || []).some((t: any) => (t.round || 1) > 1));
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? String(v).replace("T", " ").replace(/\.\d+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 行发生时间:留痕行取 time,任务行取 handleTime,未办取 arriveTime */
|
||||
function rowTime(r: any): string {
|
||||
return r.time || r.handleTime || r.arriveTime || "";
|
||||
}
|
||||
|
||||
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人"、
|
||||
* 抄送显"抄送对象";发起/撤回/重提显"提交人") */
|
||||
function recordActor(t: any): string {
|
||||
if (t.kind === "ROUTE") return "流程引擎";
|
||||
if (traceMode.value !== "dept") {
|
||||
return t.assigneeName || t.assigneeId || "-";
|
||||
}
|
||||
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
|
||||
if (t.kind === "CC") {
|
||||
return ccActorText(t);
|
||||
}
|
||||
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 || "-");
|
||||
}
|
||||
|
||||
/** 展示层状态文案(设计图口径:已提交 / 已同意 / 待审核…)。
|
||||
* ⚠️ 刻意不复用全局 TASK_STATUS(0 待办 / 1 同意 / …)——它被 TraceTimeline 与其他页面共用,
|
||||
* 改它会把全站文案一起改掉;此处只做本组件的展示层映射,三视图共用一份。 */
|
||||
const TRACE_STATUS_TEXT: Record<number, string> = {
|
||||
0: "待审核", 1: "已同意", 2: "已拒绝", 3: "失效",
|
||||
4: "已退回", 5: "已移交", 6: "已提交", 7: "已撤回",
|
||||
};
|
||||
|
||||
/** 动作标签(口径同 TraceTimeline.actionText:CC/ROUTE 专用文案,任务行查展示层映射) */
|
||||
function recordAction(t: any): string {
|
||||
if (t.kind === "CC") return "已抄送";
|
||||
if (t.kind === "ROUTE") return "系统判断";
|
||||
return TRACE_STATUS_TEXT[t.status] || String(t.status ?? "");
|
||||
}
|
||||
|
||||
/** 节点步结论:任一拒绝 > 任一同意 > 全失效 > 待审核(口径=流程中心 OapiFlowService.stepLabel) */
|
||||
function stepLabel(rows: any[]): string {
|
||||
let denied = false, approved = false, invalid = true;
|
||||
rows.forEach((r) => {
|
||||
const s = r.status === null || r.status === undefined ? -1 : r.status;
|
||||
if (s === 2) denied = true;
|
||||
if (s === 1) approved = true;
|
||||
if (s === 0 || s === 1 || s === 2 || s === 4 || s === 5) invalid = false;
|
||||
});
|
||||
if (denied) return "已拒绝";
|
||||
if (approved) return "已同意";
|
||||
return invalid ? "失效" : "待审核";
|
||||
}
|
||||
|
||||
function labelTagClass(label: string): string {
|
||||
if (label === "已同意") return "green";
|
||||
if (label === "已拒绝") return "red";
|
||||
if (label === "待审核") return "blue";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** dept 档下抄送行折叠为一行(N 个接收人 → "抄送对象 N 人",不出现人名);user 档原样。
|
||||
* 节点视图/列表视图共用;时间线视图由 TraceTimeline 内部折叠(口径同源) */
|
||||
const displayTrace = computed(() => collapseCcRows(props.detail?.trace || [], traceMode.value));
|
||||
|
||||
/**
|
||||
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
||||
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
||||
*/
|
||||
const nodeSteps = computed(() => {
|
||||
const trace: any[] = displayTrace.value;
|
||||
const act: string[] = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
||||
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
||||
const steps: any[] = [];
|
||||
const byNode: Record<string, any> = {};
|
||||
rows.forEach((r) => {
|
||||
if (EVENT_KINDS.includes(r.kind)) {
|
||||
steps.push({ type: "event", nodeId: r.nodeId, nodeName: r.nodeName, rows: [r], t: r._t });
|
||||
return;
|
||||
}
|
||||
let s = byNode[r.nodeId];
|
||||
if (!s) {
|
||||
s = { type: "node", nodeId: r.nodeId, nodeName: r.nodeName, rows: [], t: r._t };
|
||||
byNode[r.nodeId] = s;
|
||||
steps.push(s);
|
||||
}
|
||||
s.rows.push(r);
|
||||
if ((r._t || "") > (s.t || "")) s.t = r._t;
|
||||
});
|
||||
steps.forEach((s) => {
|
||||
if (s.type === "node") {
|
||||
s.active = act.includes(s.nodeId);
|
||||
s.label = s.active ? "待审核" : stepLabel(s.rows);
|
||||
} else {
|
||||
s.label = recordAction(s.rows[0]);
|
||||
}
|
||||
});
|
||||
return steps.sort((a, b) => (a.t || "").localeCompare(b.t || ""));
|
||||
});
|
||||
|
||||
function stepClass(s: any): string {
|
||||
if (s.type !== "node") return "";
|
||||
if (s.active) return "is-active";
|
||||
if (s.label === "已拒绝") return "is-reject";
|
||||
if (s.label === "已同意") return "is-done";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stepTagColor(s: any): string {
|
||||
return s.type === "node" ? labelTagClass(s.label) : (RECORD_KIND_TAG[s.rows[0].kind] || "default");
|
||||
}
|
||||
|
||||
/** 办理人行色:1 绿 / 2 红 / 3 失效置灰删除线 / 0 待办 */
|
||||
function rowClass(r: any): string {
|
||||
if (r.status === 1) return "is-done";
|
||||
if (r.status === 2) return "is-reject";
|
||||
if (r.status === 3) return "is-invalid";
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowTimeText(r: any): string {
|
||||
if (r.handleTime) return `办理于 ${fmtTime(r.handleTime)}`;
|
||||
if (r.arriveTime) return `到达 ${fmtTime(r.arriveTime)}`;
|
||||
return fmtTime(rowTime(r));
|
||||
}
|
||||
|
||||
/** 基本信息「当前节点」:currentNodes 命中的节点名 + 办理中后缀 */
|
||||
const currentNodeText = computed(() => {
|
||||
const act = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
||||
const names = nodeSteps.value
|
||||
.filter((s) => s.type === "node" && act.includes(s.nodeId))
|
||||
.map((s) => s.nodeName || s.nodeId);
|
||||
return names.length ? `${names.join("、")}(办理中)` : "-";
|
||||
});
|
||||
|
||||
const metaItems = computed(() => {
|
||||
const d = props.detail || {};
|
||||
const items = [
|
||||
{ k: "提交人", v: d.submitterName || "-" },
|
||||
{ k: "提交时间", v: fmtTime(d.submitTime) || "-" },
|
||||
{ k: "办结时间", v: fmtTime(d.finishTime) || "-" },
|
||||
];
|
||||
if (d.currentNodes) items.push({ k: "当前节点", v: currentNodeText.value });
|
||||
return items;
|
||||
});
|
||||
|
||||
/** 表格列:Drawer iframe 宽约 708px(740 抽屉)按此设计,full 下容器有 max-width 封顶不会拉散;
|
||||
* 轮次列仅多轮时出现 */
|
||||
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;
|
||||
});
|
||||
|
||||
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="embed-body" :class="{ 'is-full': full }">
|
||||
<!-- 流程信息(仅管理面):专业字段不外露给子应用嵌入页 -->
|
||||
<div v-if="full" class="embed-sec">
|
||||
<div class="embed-sec-h"><span>流程信息</span></div>
|
||||
<div class="sf">
|
||||
<div v-for="r in flowInfoRows" :key="r.label" class="sf-row">
|
||||
<span class="sf-k">{{ r.label }}</span>
|
||||
<span class="sf-v">{{ r.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h"><span>基本信息</span></div>
|
||||
<div class="embed-meta">
|
||||
<div v-for="m in metaItems" :key="m.k" class="embed-meta-i">
|
||||
<div class="embed-meta-k">{{ m.k }}</div>
|
||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
||||
</div>
|
||||
<div v-if="full && bizUrlAbs" class="embed-meta-i">
|
||||
<div class="embed-meta-k">业务详情</div>
|
||||
<div class="embed-meta-v"><a :href="bizUrlAbs" target="_blank">查看业务详情</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h">
|
||||
<span>办理进度</span>
|
||||
<RadioGroup class="embed-view-seg" :model-value="viewMode" size="small" type="button" @on-change="switchView">
|
||||
<Radio label="node">按节点</Radio>
|
||||
<Radio label="timeline">时间线</Radio>
|
||||
<Radio label="list">列表</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 口径对三视图均生效(时间线走 :mode、列表走办理人列)⇒ 与视图切换器同排常驻 -->
|
||||
<RadioGroup class="embed-mode-seg" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">人员视图</Radio>
|
||||
<Radio label="dept">科室视图</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- ① 按节点:同节点多任务每人一行,步内保持后端任务序 -->
|
||||
<template v-if="viewMode === 'node'">
|
||||
<div class="embed-steps" v-if="nodeSteps.length">
|
||||
<div v-for="(s, i) in nodeSteps" :key="`${s.type}-${s.nodeId || 'x'}-${i}`"
|
||||
class="embed-step" :class="stepClass(s)">
|
||||
<span class="embed-step-dot"></span>
|
||||
<div class="embed-step-body">
|
||||
<div class="embed-step-head">
|
||||
<span class="embed-step-name">{{ s.nodeName || s.nodeId || "—" }}</span>
|
||||
<Tag size="small" :color="stepTagColor(s)">{{ s.label }}</Tag>
|
||||
<Tag v-if="s.active" size="small" color="blue">办理中</Tag>
|
||||
</div>
|
||||
<div v-for="(r, j) in s.rows" :key="r.rowKey || j" class="embed-assignee" :class="rowClass(r)">
|
||||
<span class="embed-an">{{ recordActor(r) }}</span>
|
||||
<span class="embed-am">{{ recordAction(r) }}</span>
|
||||
<span class="embed-am">{{ rowTimeText(r) }}</span>
|
||||
<span class="embed-ao" v-if="r.opinion">
|
||||
{{ r.kind === "REJECT" ? "拒绝意见" : "意见" }}:{{ r.opinion }}
|
||||
</span>
|
||||
<span class="embed-ao" v-if="r.edgeName">去向:{{ r.edgeName }}</span>
|
||||
<span class="embed-ao route-hung" v-if="r.kind === 'ROUTE' && !r.outlet">
|
||||
判断失败已挂起(第 {{ r.attempt || 1 }} 次),等待管理端重跑路由
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" v-else>暂无轨迹</div>
|
||||
</template>
|
||||
|
||||
<!-- ② 时间线:扁平行 + 多轮分隔(复用共用组件,口径不变) -->
|
||||
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
||||
:status-text="TRACE_STATUS_TEXT" />
|
||||
|
||||
<!-- ③ 列表:整个流程周期的完整事件流水(dept 档抄送行已折叠,见 displayTrace) -->
|
||||
<Table v-else size="small" :columns="recordColumns" :data="displayTrace" />
|
||||
</div>
|
||||
|
||||
<details class="embed-fold" @toggle="onDiagramToggle">
|
||||
<summary>流程示意图<slot name="diagram-link" /></summary>
|
||||
<div class="embed-fold-b">
|
||||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 业务信息:只认子应用传递的 summaryFields,无值整块隐藏 -->
|
||||
<details v-if="bizRows.length" class="embed-fold">
|
||||
<summary>业务信息</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in bizRows" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 业务表单数据(仅管理面):嵌入页不外露原始表单 -->
|
||||
<details v-if="formRows.length" class="embed-fold">
|
||||
<summary>业务表单数据</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in formRows" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 根类不用 .page/.block:UnoCSS 原子类同名会以更高优先级盖掉 scoped 样式(铁律) */
|
||||
/* 宽度适配:版式按嵌入场景调优(740 抽屉内容约 708px),管理面内容区更宽,full 下封顶防拉散;
|
||||
嵌入页不加限制(iframe 内 100%,行为不变),窄窗口两场景都自然收缩 */
|
||||
.embed-body.is-full { max-width: 900px; }
|
||||
.muted { color: #999; }
|
||||
|
||||
/* 区块标题:左侧蓝条 + 右侧控件 */
|
||||
.embed-sec { margin-bottom: 18px; }
|
||||
.embed-sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
|
||||
.embed-sec-h > span:first-child { position: relative; padding-left: 9px; }
|
||||
.embed-sec-h > span:first-child::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
|
||||
}
|
||||
/* 两处切换器(视图 / 口径)同排在标题行,同为 iView small button group、几何与配色一致
|
||||
(活动态 #0d9488、非活动 #515a6e/#dcdee2)。⚠️ 两者都落在 .embed-sec-h(600) 内 ⇒ 会继承粗体,
|
||||
必须显式回常规(管理面同款控件 .trace-mode 也是这个写法)。 */
|
||||
.embed-view-seg { margin-left: auto; font-weight: normal; }
|
||||
/* 口径紧随视图切换器:与 .embed-sec-h 的 10px gap 叠加为 24px,避免两组按钮被看成一排五选一 */
|
||||
.embed-mode-seg { margin-left: 14px; font-weight: normal; }
|
||||
|
||||
/* 基本信息:四列等宽(740 抽屉实测放得下时间戳,超出省略号 + title 兜底;full 下业务详情作第 5 格换行) */
|
||||
.embed-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #f2f4f7; border-radius: 8px; overflow: hidden; }
|
||||
.embed-meta-i { padding: 11px 14px; border-right: 1px solid #e8eaec; min-width: 0; }
|
||||
.embed-meta-i:last-child { border-right: none; }
|
||||
.embed-meta-k { font-size: 12px; color: #808695; margin-bottom: 4px; }
|
||||
.embed-meta-v { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 按节点视图 */
|
||||
.embed-steps { position: relative; }
|
||||
.embed-step { display: flex; gap: 11px; padding-bottom: 16px; position: relative; }
|
||||
.embed-step:last-child { padding-bottom: 2px; }
|
||||
.embed-step:not(:last-child)::before {
|
||||
content: ""; position: absolute; left: 5px; top: 16px; bottom: 0; width: 2px; background: #f2f4f7;
|
||||
}
|
||||
.embed-step-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; background: #c8ced8; margin-top: 5px; flex-shrink: 0;
|
||||
z-index: 1; border: 2px solid #fff; box-shadow: 0 0 0 1px #e8eaec;
|
||||
}
|
||||
.embed-step.is-done .embed-step-dot { background: #19be6b; box-shadow: 0 0 0 1px #b7ebc9; }
|
||||
.embed-step.is-reject .embed-step-dot { background: #ed4014; box-shadow: 0 0 0 1px #ffd2c9; }
|
||||
.embed-step.is-active .embed-step-dot { background: var(--brand); box-shadow: 0 0 0 3px rgba(45, 140, 240, .18); }
|
||||
.embed-step-body { flex: 1; min-width: 0; }
|
||||
.embed-step-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.embed-step-name { font-size: 14px; font-weight: 600; }
|
||||
.embed-assignee {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px;
|
||||
padding: 6px 10px; margin-bottom: 5px; border-radius: 6px; background: #fafbfc; border: 1px solid #f0f2f5;
|
||||
}
|
||||
.embed-assignee:last-child { margin-bottom: 0; }
|
||||
.embed-an { font-weight: 600; }
|
||||
.embed-assignee.is-done .embed-an { color: #19be6b; }
|
||||
.embed-assignee.is-reject .embed-an { color: #ed4014; }
|
||||
.embed-am { color: #808695; font-size: 12px; }
|
||||
.embed-ao { color: #515a6e; width: 100%; padding-top: 2px; }
|
||||
.embed-ao.route-hung { color: #ed4014; }
|
||||
.embed-assignee.is-invalid { opacity: .5; }
|
||||
.embed-assignee.is-invalid .embed-an { text-decoration: line-through; text-decoration-color: #b9bcc4; }
|
||||
|
||||
/* 折叠块:流程示意图 / 业务信息 / 业务表单数据,默认收缩 */
|
||||
.embed-fold { border: 1px solid #f2f4f7; border-radius: 8px; background: #fff; margin-bottom: 18px; }
|
||||
.embed-fold > summary {
|
||||
cursor: pointer; list-style: none; padding: 12px 16px; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.embed-fold > summary::-webkit-details-marker { display: none; }
|
||||
.embed-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; transition: .2s; font-size: 12px; }
|
||||
.embed-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.embed-fold-b { padding: 2px 16px 16px; }
|
||||
.diagram-img {
|
||||
max-width: 100%; height: auto; display: block; margin: 0 auto;
|
||||
border: 1px solid #f2f4f7; border-radius: 6px;
|
||||
}
|
||||
/* 行式 k/v(业务信息 / 流程信息 / 业务表单数据共用):折叠块内再收一层卡,与行成组(设计基线口径) */
|
||||
.sf { border: 1px solid #e6eefb; border-radius: 8px; overflow: hidden; background: #fcfdff; }
|
||||
.sf-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #eef3fb; font-size: 13px; }
|
||||
.sf-row:last-child { border-bottom: none; }
|
||||
.sf-k { width: 84px; flex-shrink: 0; color: #808695; }
|
||||
.sf-v { flex: 1; min-width: 0; word-break: break-all; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
// 办件详情抽屉(管理面列表页复用):非 iframe,内部直接复用 InstanceBody(full 口径)。
|
||||
// 宽度与 Notice 审批记录抽屉对齐 740px;关闭后清空状态,下次打开重新加载。
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Drawer, Tag } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { toAppHashUrl } from "@/utils/appUrl";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
instanceId?: string | number;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "update:modelValue", val: boolean): void }>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit("update:modelValue", val),
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
const mode = ref("user");
|
||||
|
||||
async function load() {
|
||||
const id = props.instanceId;
|
||||
if (!id || !visible.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await flowApi.detail(String(id)).send();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.instanceId, visible.value], load, { immediate: true });
|
||||
watch(visible, (val) => {
|
||||
if (!val) detail.value = null;
|
||||
});
|
||||
|
||||
function switchMode(val: string) {
|
||||
mode.value = val;
|
||||
}
|
||||
|
||||
const diagramSrc = computed(() => {
|
||||
if (!detail.value?.instanceId) return "";
|
||||
return `${config.serviceHost}/api/flow/diagram.png?instanceId=${detail.value.instanceId}&mode=${mode.value}`;
|
||||
});
|
||||
|
||||
function openDiagram() {
|
||||
if (!detail.value?.instanceId) return;
|
||||
// 原写法 `${origin}${BASE_URL}#/diagram?…` 里 BASE_URL 构建期是 "./",拼出
|
||||
// `https://host./#/…`(域名尾点);改为按当前真实地址丢 hash 再接目标 hash
|
||||
const href = router.resolve({
|
||||
path: "/diagram",
|
||||
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
|
||||
}).href;
|
||||
window.open(toAppHashUrl(href), "_blank");
|
||||
}
|
||||
|
||||
function statusName(s: number) {
|
||||
return INSTANCE_STATUS[s] || s;
|
||||
}
|
||||
function statusColor(s: number) {
|
||||
return INSTANCE_STATUS_COLOR[s] || "gray";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
v-model="visible"
|
||||
:title="detail?.bizTitle || '办件详情'"
|
||||
:width="740"
|
||||
:transfer="false"
|
||||
:styles="{ body: { padding: '0' } }"
|
||||
>
|
||||
<div v-if="loading" class="drawer-loading muted">加载中…</div>
|
||||
<template v-else-if="detail">
|
||||
<div class="drawer-head">
|
||||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<InstanceBody
|
||||
full
|
||||
:detail="detail"
|
||||
:diagram-src="diagramSrc"
|
||||
:initial-mode="mode"
|
||||
@mode-change="switchMode"
|
||||
>
|
||||
<template #diagram-link>
|
||||
<a class="fold-link" @click.stop.prevent="openDiagram">查看大图</a>
|
||||
</template>
|
||||
</InstanceBody>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-head { padding: 12px 16px 8px; }
|
||||
.drawer-body { padding: 0 16px 16px; }
|
||||
.drawer-loading { padding: 48px 16px; text-align: center; }
|
||||
.fold-link { font-size: 12px; font-weight: normal; }
|
||||
</style>
|
||||
@@ -2,10 +2,11 @@
|
||||
// 流程轨迹时间线(2026-09-18 新增,办件详情页与办理页共用一个实现):
|
||||
// ① 时间流:后端已按"该行实际发生的时间"升序返回(time 字段),此处不再排序,只按 round 分轮展示;
|
||||
// ② 两档显示:user=按人员(默认,显示办理人姓名);dept=按科室(DEPT_KEY 节点显科室名、USERS 节点显
|
||||
// "指定办理人"、发起/撤回/重提显"提交人"),口径与流程示意图的科室视图一致;
|
||||
// "指定办理人"、发起/撤回/重提显"提交人"、抄送显"抄送对象"),口径与流程示意图的科室视图一致;
|
||||
// ③ 撤回重走留痕:kind=WITHDRAW/RESUBMIT 的行由后端留痕事件行提供,重提处插入"第 N 轮"分隔。
|
||||
import { computed } from "vue";
|
||||
import { TASK_STATUS } from "@/api/module/flow";
|
||||
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
|
||||
|
||||
const props = defineProps<{
|
||||
/** 后端 detail.trace(已按时间升序,含 kind/time/round/科室富化字段) */
|
||||
@@ -46,7 +47,8 @@ const KIND_TAG: Record<string, string> = {
|
||||
ROUTE: "purple",
|
||||
};
|
||||
|
||||
const rows = computed(() => props.trace || []);
|
||||
/** dept 档下抄送行折叠为一行(N 个接收人 → "抄送对象 N 人");user 档原样 */
|
||||
const rows = computed(() => collapseCcRows(props.trace || [], props.mode));
|
||||
|
||||
/** 多轮才有轮次分隔(单轮不打扰) */
|
||||
const multiRound = computed(() => rows.value.some((t: any) => (t.round || 1) > 1));
|
||||
@@ -63,12 +65,16 @@ function roundTitle(round: number): string {
|
||||
return at ? `第 ${round} 轮审批(重新提交于 ${at})` : `第 ${round} 轮审批`;
|
||||
}
|
||||
|
||||
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
|
||||
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人/抄送对象(不暴露具体人名) */
|
||||
function actor(t: any): string {
|
||||
if (t.kind === "ROUTE") return "流程引擎";
|
||||
if (props.mode !== "dept") {
|
||||
return t.assigneeName || t.assigneeId || "-";
|
||||
}
|
||||
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
|
||||
if (t.kind === "CC") {
|
||||
return ccActorText(t);
|
||||
}
|
||||
if (t.assigneeType === "DEPT_KEY") {
|
||||
return t.deptName || t.deptKey || t.assigneeName || "-";
|
||||
}
|
||||
@@ -129,9 +135,9 @@ function fmtTime(v: string | null | undefined): string {
|
||||
padding: 2px 8px;
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: #2d8cf0;
|
||||
color: var(--brand);
|
||||
background: #f0f7ff;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { toAppHashUrl } from "../src/utils/appUrl";
|
||||
|
||||
/** 整页跳转地址单测:只锁「最终只有一个 hash」这条线上缺陷口径,不替代浏览器实测 */
|
||||
|
||||
test("办理页入口:resolve 出的 #/diagram 不再追加到当前 hash 之后", () => {
|
||||
const out = toAppHashUrl(
|
||||
"#/diagram?instanceId=107&mode=user",
|
||||
"https://base.yhxt.com.cn/flow-center/front/index.html#/task/339",
|
||||
);
|
||||
assert.equal(out, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=107&mode=user");
|
||||
assert.equal(out.split("#").length - 1, 1);
|
||||
});
|
||||
|
||||
test("参数原样保留:instanceId 与 mode 不因去 hash 而丢失", () => {
|
||||
const out = toAppHashUrl("#/diagram?instanceId=107&mode=dept", "https://h/flow-center/front/index.html#/task/339");
|
||||
assert.match(out, /instanceId=107/);
|
||||
assert.match(out, /mode=dept/);
|
||||
});
|
||||
|
||||
test("无 hash 的入口地址:直接接上目标 hash", () => {
|
||||
assert.equal(
|
||||
toAppHashUrl("#/diagram?instanceId=1", "https://h/flow-center/front/index.html"),
|
||||
"https://h/flow-center/front/index.html#/diagram?instanceId=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("真实 query 保留在 hash 之前(部署带 ?v= 缓存位时不被吞)", () => {
|
||||
assert.equal(
|
||||
toAppHashUrl("#/diagram?instanceId=1", "https://h/index.html?v=9#/task/339"),
|
||||
"https://h/index.html?v=9#/diagram?instanceId=1",
|
||||
);
|
||||
});
|
||||
|
||||
test("不带前导 # 的 resolve 结果同样归一,且不出现双 hash", () => {
|
||||
const out = toAppHashUrl("/diagram?instanceId=1", "https://h/index.html#/task/339");
|
||||
assert.equal(out, "https://h/index.html#/diagram?instanceId=1");
|
||||
});
|
||||
|
||||
test("查看大图:不再用 origin+BASE_URL 拼出 host 尾点地址", () => {
|
||||
// 旧写法 origin + "./" + "#/diagram?…" 会得到 https://base.yhxt.com.cn./#/diagram?…
|
||||
const legacy = `${"https://base.yhxt.com.cn"}${"./"}#/diagram?instanceId=1`;
|
||||
assert.match(legacy, /com\.cn\.\//);
|
||||
const fixed = toAppHashUrl("#/diagram?instanceId=1", "https://base.yhxt.com.cn/flow-center/front/index.html#/admin/instances");
|
||||
assert.ok(!/\.cn\.\//.test(fixed), fixed);
|
||||
assert.equal(fixed, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=1");
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveNodePreview } from "../src/views/flow/admin/def-editor/assignPreview";
|
||||
import type { PreviewApi, PreviewDept, PreviewNodeConfig } from "../src/views/flow/admin/def-editor/assignPreview";
|
||||
|
||||
/**
|
||||
* 编辑器指派预览解析单测:断言的是「用什么接口、给什么结论、失败怎么讲」。
|
||||
* 数据用桩,不代表真实科室数据与线上服务已验证。
|
||||
*/
|
||||
|
||||
const depts: PreviewDept[] = [
|
||||
{ deptKey: "41", deptName: "校长室", count: 2 },
|
||||
{ deptKey: "42", deptName: "空科室", count: 0 },
|
||||
];
|
||||
|
||||
function cfg(over: Partial<PreviewNodeConfig> = {}): PreviewNodeConfig {
|
||||
return {
|
||||
uid: "u3",
|
||||
name: "科室审核",
|
||||
type: "DEPT_KEY",
|
||||
deptKey: "41",
|
||||
userIds: [],
|
||||
countersign: "ANY",
|
||||
assignPolicy: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(over: Partial<PreviewApi> = {}): PreviewApi & { calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
deptMembers: async (deptId) => {
|
||||
calls.push(`deptMembers:${deptId}`);
|
||||
if (deptId === "41") {
|
||||
return [
|
||||
{ userId: 101, userName: "陈星", status: 0 },
|
||||
{ userId: 102, userName: "刘文娟", status: 0 },
|
||||
{ userId: 103, userName: "测试教师10", status: 1 },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
lookupUser: async (userId) => {
|
||||
calls.push(`lookupUser:${userId}`);
|
||||
if (userId === 101) return { id: 101, name: "陈星", status: 1 };
|
||||
if (userId === 999) return null;
|
||||
return { id: userId, name: `用户${userId}`, status: 0 };
|
||||
},
|
||||
applyPreview: async (flowGuid) => {
|
||||
calls.push(`applyPreview:${flowGuid}`);
|
||||
return { flowGuid, defVersion: 3, nodes: [] };
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test("科室映射节点:读既有科室成员接口,启用成员进候选、停用成员标注不计入", async () => {
|
||||
const api = stubApi();
|
||||
const out = await resolveNodePreview(cfg(), depts, api);
|
||||
assert.equal(out.ok, true);
|
||||
assert.ok(out.headline.includes("校长室"));
|
||||
assert.ok(out.headline.includes("2 名启用办理人"));
|
||||
assert.deepEqual(api.calls.filter((c) => c.startsWith("deptMembers")), ["deptMembers:41"]);
|
||||
assert.equal(out.rows.length, 3);
|
||||
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "刘文娟", "测试教师10"]);
|
||||
assert.equal(out.rows[2].warn, "该成员已停用,不计入候选");
|
||||
assert.ok(out.notes.some((n) => n.includes("预览只读")));
|
||||
});
|
||||
|
||||
test("未选科室=明确的不可预览原因,而不是空列表里什么都不说", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.equal(out.blocked, "尚未选择科室映射,请先在「科室映射」里选一个科室。");
|
||||
});
|
||||
|
||||
test("科室已被删除:点名到映射键并说明发布会被拦截", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "99" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("「99」"));
|
||||
assert.ok(out.blocked?.includes("发布会被拦截"));
|
||||
});
|
||||
|
||||
test("科室 0 名启用成员:给出补人路径,不误报成功", async () => {
|
||||
const out = await resolveNodePreview(cfg({ deptKey: "42" }), depts, stubApi());
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("0 名启用办理人"));
|
||||
assert.ok(out.blocked?.includes("办理人映射"));
|
||||
});
|
||||
|
||||
test("指定用户节点:逐个走 UserBase 检索,禁用账号标注服务端会拒绝", async () => {
|
||||
const api = stubApi();
|
||||
const out = await resolveNodePreview(
|
||||
cfg({ type: "USERS", deptKey: "", userIds: [101, 102, 999] }),
|
||||
depts,
|
||||
api,
|
||||
);
|
||||
assert.equal(out.ok, true);
|
||||
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "用户102", "#999"]);
|
||||
assert.ok(out.rows[1].warn?.includes("非启用"));
|
||||
assert.ok(out.rows[2].warn?.includes("UserBase 查不到"));
|
||||
});
|
||||
|
||||
test("指定用户检索失败(服务不可达):原因可读,堆栈类原文不外泄", async () => {
|
||||
const api = stubApi({
|
||||
lookupUser: async () => {
|
||||
throw new Error("java.lang.RuntimeException: connect timed out at com.yunhai.UserClient");
|
||||
},
|
||||
});
|
||||
const out = await resolveNodePreview(cfg({ type: "USERS", deptKey: "", userIds: [101] }), depts, api);
|
||||
assert.equal(out.ok, false);
|
||||
assert.ok(out.blocked?.includes("发布会被拦截"));
|
||||
assert.ok(!out.rows[0].warn?.includes("java."));
|
||||
assert.ok(out.rows[0].warn?.includes("服务未能处理本次请求"));
|
||||
});
|
||||
|
||||
test("新建未保存线:已发布版本对账说明「尚未保存」,不假装有一版可比", async () => {
|
||||
const out = await resolveNodePreview(cfg(), depts, stubApi(), "");
|
||||
assert.equal(out.published?.state, "unavailable");
|
||||
assert.ok(out.published?.text.includes("尚未保存"));
|
||||
});
|
||||
|
||||
test("草稿节点不在已发布预览中:区分「未开启指派」与「尚未发布」", async () => {
|
||||
const out = await resolveNodePreview(cfg(), depts, stubApi(), "g1");
|
||||
assert.equal(out.published?.state, "missing");
|
||||
assert.ok(out.published?.text.includes("v3"));
|
||||
assert.ok(out.published?.text.includes("未开启发起时指派"));
|
||||
});
|
||||
|
||||
test("已发布版本有本节点:候选人按服务端解析结果展示(含提单人剔除后的集)", async () => {
|
||||
const api = stubApi({
|
||||
applyPreview: async () => ({
|
||||
defVersion: 4,
|
||||
nodes: [{ nodeId: "u3", nodeName: "科室审核", candidates: [{ userId: 102, userName: "刘文娟" }] }],
|
||||
}),
|
||||
});
|
||||
const out = await resolveNodePreview(cfg(), depts, api, "g1");
|
||||
assert.equal(out.published?.state, "ok");
|
||||
assert.equal(out.published?.defVersion, 4);
|
||||
assert.deepEqual(out.published?.rows.map((r) => r.userName), ["刘文娟"]);
|
||||
});
|
||||
|
||||
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
|
||||
const api = stubApi({
|
||||
applyPreview: async () => {
|
||||
// 与 alova 层一致:业务信封 code≠0 的拒绝才带 bizCode,安全呈现层据此判定可信原文
|
||||
throw Object.assign(new Error("流程线已停发,不可发起新办件"), { bizCode: 2 });
|
||||
},
|
||||
});
|
||||
const out = await resolveNodePreview(cfg(), depts, api, "g1");
|
||||
assert.equal(out.published?.state, "unavailable");
|
||||
assert.ok(out.published?.text.includes("流程线已停发,不可发起新办件"));
|
||||
});
|
||||
|
||||
test("会签/发起时指派口径进说明文案(分层后信息不丢)", async () => {
|
||||
const out = await resolveNodePreview(
|
||||
cfg({ countersign: "ALL", assignPolicy: { mode: "SINGLE", required: true } }),
|
||||
depts,
|
||||
stubApi(),
|
||||
);
|
||||
assert.ok(out.notes.some((n) => n.includes("会签(全部同意才通过)")));
|
||||
assert.ok(out.notes.some((n) => n.includes("单选 1 人") && n.includes("必选")));
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
import { nextTick, reactive } from "vue";
|
||||
|
||||
const { flowApiMock } = vi.hoisted(() => ({
|
||||
flowApiMock: {
|
||||
adminTeacherTree: vi.fn(),
|
||||
deptMembers: vi.fn(),
|
||||
mapLookupUser: vi.fn(),
|
||||
applyPreview: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
|
||||
|
||||
import ApprovalDrawer from "@/views/flow/admin/def-editor/components/ApprovalDrawer.vue";
|
||||
import AssigneePreviewModal from "@/views/flow/admin/def-editor/components/AssigneePreviewModal.vue";
|
||||
import { EDITOR_CTX, createApproval, type ApprovalNode, type EditorItem } from "@/views/flow/admin/def-editor/model";
|
||||
|
||||
/**
|
||||
* 审批节点抽屉的组件测试:验收 P2-1 的两条硬指标——
|
||||
* ① 面板里有可发现的「预览候选人」入口,点开是真接口解析结果;
|
||||
* ② 核心/高级分层只挪展示,不改字段与数据语义。
|
||||
* 注:iView Drawer/Modal 内容 teleport 到 body,一律按真实 DOM 断言。
|
||||
*/
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
function ok<T>(value: T) {
|
||||
return { send: async () => value };
|
||||
}
|
||||
|
||||
function makeNode(over: Partial<ApprovalNode> = {}): ApprovalNode {
|
||||
return { ...createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" }), ...over } as ApprovalNode;
|
||||
}
|
||||
|
||||
function baseCtx(over: Record<string, unknown> = {}) {
|
||||
const model = reactive({
|
||||
code: "c",
|
||||
name: "n",
|
||||
list: [] as EditorItem[],
|
||||
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
|
||||
});
|
||||
return {
|
||||
model,
|
||||
depts: [
|
||||
{ deptKey: "41", deptName: "校长室", count: 2 },
|
||||
{ deptKey: "42", deptName: "空科室", count: 0 },
|
||||
],
|
||||
userNames: { 101: "陈星", 102: "刘文娟" },
|
||||
routers: [],
|
||||
availableVars: [],
|
||||
issueOf: () => [],
|
||||
openNode: () => undefined,
|
||||
removeNode: () => undefined,
|
||||
insertItem: () => undefined,
|
||||
addBranch: () => undefined,
|
||||
removeBranch: () => undefined,
|
||||
exitOutlets: () => undefined,
|
||||
flowGuid: "g-line-1",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mountDrawer(node: ApprovalNode, ctxOver: Record<string, unknown> = {}): VueWrapper {
|
||||
return mount(ApprovalDrawer, {
|
||||
props: { modelValue: true, node },
|
||||
global: {
|
||||
plugins: [ViewUIPlus],
|
||||
provide: { [EDITOR_CTX as unknown as symbol]: baseCtx(ctxOver) },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
const bodyText = () => document.body.textContent || "";
|
||||
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
|
||||
const hiddenByStyle = (el: HTMLElement | null) => (el?.getAttribute("style") || "").includes("display: none");
|
||||
|
||||
async function clickEl(el: HTMLElement | null) {
|
||||
expect(el, "目标元素不存在").toBeTruthy();
|
||||
el!.click();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function buttonNamed(text: string) {
|
||||
return (Array.from(document.querySelectorAll("button")) as HTMLButtonElement[])
|
||||
.find((b) => (b.textContent || "").includes(text)) || null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
flowApiMock.adminTeacherTree.mockImplementation(() => ok([]));
|
||||
flowApiMock.deptMembers.mockImplementation((deptId: string) =>
|
||||
ok(deptId === "41"
|
||||
? [
|
||||
{ userId: 101, userName: "陈星", status: 0 },
|
||||
{ userId: 102, userName: "刘文娟", status: 0 },
|
||||
{ userId: 103, userName: "测试教师10", status: 1 },
|
||||
]
|
||||
: []),
|
||||
);
|
||||
flowApiMock.mapLookupUser.mockImplementation((id: number) => ok({ id, name: `用户${id}`, status: 1 }));
|
||||
flowApiMock.applyPreview.mockImplementation(() => ok({ defVersion: 3, nodes: [] }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("审批节点抽屉 · 核心/高级分层", () => {
|
||||
it("核心区直接可见:节点名称、审批人、预览入口;高级项默认收起", async () => {
|
||||
mountDrawer(makeNode());
|
||||
await flushPromises();
|
||||
const text = bodyText();
|
||||
expect(text).toContain("节点名称");
|
||||
expect(text).toContain("审批人类型");
|
||||
expect(text).toContain("科室映射");
|
||||
expect(text).toContain("预览候选人");
|
||||
expect(text).toContain("高级设置");
|
||||
// 收起只是隐藏,不删除能力
|
||||
expect(hiddenByStyle(q(".adv-body"))).toBe(true);
|
||||
});
|
||||
|
||||
it("高级能力一个都没少:展开后会签/发起时指派/时限/多出口都在原位置", async () => {
|
||||
mountDrawer(makeNode());
|
||||
await flushPromises();
|
||||
await clickEl(q(".adv-head"));
|
||||
const adv = q(".adv-body");
|
||||
expect(hiddenByStyle(adv)).toBe(false);
|
||||
const text = adv?.textContent || "";
|
||||
expect(text).toContain("多人审批方式");
|
||||
expect(text).toContain("发起时指派");
|
||||
expect(text).toContain("办理时限(小时)");
|
||||
expect(text).toContain("出口路由");
|
||||
});
|
||||
|
||||
it("已配置高级项的节点:打开抽屉自动展开,收起态也标出配了哪几项", async () => {
|
||||
mountDrawer(makeNode({ countersign: "ALL", deadlineHours: 24 }));
|
||||
await flushPromises();
|
||||
expect(hiddenByStyle(q(".adv-body"))).toBe(false);
|
||||
|
||||
await clickEl(q(".adv-head"));
|
||||
expect(q(".adv-badge")?.textContent).toContain("会签");
|
||||
expect(q(".adv-badge")?.textContent).toContain("办理时限");
|
||||
});
|
||||
|
||||
it("分层不改数据:折叠/展开来回,节点字段值不变", async () => {
|
||||
const node = makeNode({ countersign: "ALL", deadlineHours: 8, outlets: ["同意", "退回"] });
|
||||
const snapshot = JSON.stringify(node);
|
||||
mountDrawer(node);
|
||||
await flushPromises();
|
||||
await clickEl(q(".adv-head"));
|
||||
await clickEl(q(".adv-head"));
|
||||
expect(JSON.stringify(node)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe("审批节点抽屉 · 预览候选人入口", () => {
|
||||
async function openPreview() {
|
||||
await clickEl(buttonNamed("预览候选人"));
|
||||
}
|
||||
|
||||
it("点入口即按当前节点配置走真实接口(科室成员 + 发起预览),不是静态示例", async () => {
|
||||
mountDrawer(makeNode());
|
||||
await flushPromises();
|
||||
await openPreview();
|
||||
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("41");
|
||||
expect(flowApiMock.applyPreview).toHaveBeenCalledWith("g-line-1");
|
||||
const text = bodyText();
|
||||
expect(text).toContain("校长室");
|
||||
expect(text).toContain("陈星");
|
||||
expect(text).toContain("刘文娟");
|
||||
expect(text).toContain("该成员已停用,不计入候选");
|
||||
expect(text).toContain("预览只读");
|
||||
});
|
||||
|
||||
it("预览读的是抽屉里的未保存改动(换科室后立刻按新科室解析)", async () => {
|
||||
mountDrawer(makeNode({ assignee: { type: "DEPT_KEY", deptKey: "42" } }));
|
||||
await flushPromises();
|
||||
await openPreview();
|
||||
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("42");
|
||||
const text = bodyText();
|
||||
expect(text).toContain("0 名启用办理人");
|
||||
expect(text).toContain("发布会被拦截");
|
||||
});
|
||||
|
||||
it("指定用户节点按 UserBase 检索解析,非启用账号如实标注", async () => {
|
||||
flowApiMock.mapLookupUser.mockImplementation((id: number) =>
|
||||
ok(id === 101 ? { id: 101, name: "陈星", status: 1 } : { id, name: `用户${id}`, status: 0 }),
|
||||
);
|
||||
mountDrawer(makeNode({ assignee: { type: "USERS", userIds: [101, 102] } }));
|
||||
await flushPromises();
|
||||
await openPreview();
|
||||
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(101);
|
||||
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(102);
|
||||
const text = bodyText();
|
||||
expect(text).toContain("陈星");
|
||||
expect(text).toContain("非启用");
|
||||
});
|
||||
|
||||
it("流程线尚未保存时说明没有可比对的已发布版本,不假装有一版", async () => {
|
||||
mountDrawer(makeNode(), { flowGuid: "" });
|
||||
await flushPromises();
|
||||
await openPreview();
|
||||
expect(flowApiMock.applyPreview).not.toHaveBeenCalled();
|
||||
expect(bodyText()).toContain("尚未保存");
|
||||
});
|
||||
|
||||
it("预览只读:打开再关闭不写回节点、不产生任何接口写入", async () => {
|
||||
const node = makeNode();
|
||||
const before = JSON.stringify(node);
|
||||
const wrapper = mountDrawer(node);
|
||||
await flushPromises();
|
||||
await openPreview();
|
||||
await clickEl(buttonNamed("关闭"));
|
||||
expect(JSON.stringify(node)).toEqual(before);
|
||||
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
|
||||
// 预览的数据源只有读接口:科室成员 / 用户检索 / 发起预览
|
||||
expect(flowApiMock.deptMembers).toHaveBeenCalled();
|
||||
expect(flowApiMock.applyPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("预览弹窗组件确实挂在抽屉内(入口不是死链)", async () => {
|
||||
const wrapper = mountDrawer(makeNode());
|
||||
await flushPromises();
|
||||
expect(wrapper.findComponent(AssigneePreviewModal).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
|
||||
import ViewUIPlus from "view-ui-plus";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
|
||||
|
||||
/**
|
||||
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
|
||||
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
|
||||
*/
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
const PROTO = "flow";
|
||||
const VERSION = 1;
|
||||
const APP_ORIGIN = window.location.origin;
|
||||
|
||||
function baseData(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
proto: PROTO,
|
||||
v: VERSION,
|
||||
type: "assign-confirmed",
|
||||
contextId: "",
|
||||
flowGuid: "g-embed",
|
||||
defVersion: 3,
|
||||
assigns: [{ nodeId: "u2", userIds: [101] }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
|
||||
function frameEl(): HTMLIFrameElement | null {
|
||||
return document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
}
|
||||
|
||||
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
|
||||
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
const ev = new MessageEvent("message", {
|
||||
origin: opts.origin ?? APP_ORIGIN,
|
||||
source: (opts.source ?? frame.contentWindow) as Window | null,
|
||||
data,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
return nextTick();
|
||||
}
|
||||
|
||||
function mountHost(props: Record<string, unknown> = {}) {
|
||||
return mount(FlowAssignEmbed, {
|
||||
props: { flowGuid: "g-embed", ...props },
|
||||
global: { plugins: [ViewUIPlus] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
|
||||
async function openHost(wrapper: VueWrapper) {
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 地址与会话", () => {
|
||||
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const src = frameEl()!.src;
|
||||
expect(src.split("#").length - 1).toBe(1);
|
||||
expect(src).toContain("#/assign-preview");
|
||||
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
|
||||
expect((wrapper.vm as any).contextId).toBeTruthy();
|
||||
expect(src).toContain("flowGuid=g-embed");
|
||||
expect(src).toContain(`contextId=${(wrapper.vm as any).contextId}`);
|
||||
});
|
||||
|
||||
it("缺少 flowGuid 时不开框、不监听", async () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const wrapper = mountHost({ flowGuid: "" });
|
||||
(wrapper.vm as any).open();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(frameEl()).toBeNull();
|
||||
expect(addSpy.mock.calls.filter((c) => c[0] === "message")).toHaveLength(0);
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 消息校验", () => {
|
||||
it("换线立即结束旧会话,旧 iframe 不能借新 props 回传选择", async () => {
|
||||
const wrapper = mountHost(); await openHost(wrapper);
|
||||
const contextId = (wrapper.vm as any).contextId;
|
||||
await wrapper.setProps({ flowGuid: 'different-flow' });
|
||||
await post(wrapper, baseData({ contextId, flowGuid: 'different-flow' }));
|
||||
expect(wrapper.emitted('confirmed')).toBeUndefined();
|
||||
expect(wrapper.emitted('cancelled')).toHaveLength(1);
|
||||
});
|
||||
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
const payload = events![0][0] as any;
|
||||
expect(payload.flowGuid).toBe("g-embed");
|
||||
expect(payload.defVersion).toBe(3);
|
||||
expect(payload.assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example" });
|
||||
await post(wrapper, baseData({ contextId: cid }), { source: { __other: true } });
|
||||
await post(wrapper, baseData({ contextId: "stale-ctx" }));
|
||||
await post(wrapper, { proto: "fc", v: 1, type: "assign-confirmed", contextId: cid });
|
||||
await post(wrapper, baseData({ contextId: cid, v: 2 }));
|
||||
await post(wrapper, baseData({ contextId: cid, type: "assign-mystery" }));
|
||||
await post(wrapper, { nope: true });
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
|
||||
await post(wrapper, baseData({ contextId: cid, defVersion: undefined }));
|
||||
await post(wrapper, baseData({ contextId: cid, flowGuid: undefined }));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 生命周期与选择语义", () => {
|
||||
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [999] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
});
|
||||
|
||||
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
(wrapper.vm as any).close();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid1 = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid1 }));
|
||||
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||
|
||||
await openHost(wrapper);
|
||||
const cid2 = (wrapper.vm as any).contextId;
|
||||
expect(cid2).not.toEqual(cid1);
|
||||
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
|
||||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||||
|
||||
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
|
||||
const events = wrapper.emitted("confirmed");
|
||||
expect(events).toHaveLength(2);
|
||||
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
|
||||
});
|
||||
|
||||
it("卸载后监听器摘掉,后续消息不再回传", async () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
const frame = frameEl() as HTMLIFrameElement;
|
||||
wrapper.unmount();
|
||||
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
|
||||
window.dispatchEvent(new MessageEvent("message", {
|
||||
origin: APP_ORIGIN,
|
||||
source: frame.contentWindow as Window,
|
||||
data: baseData({ contextId: cid }),
|
||||
}));
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlowAssignEmbed 载荷逐字段校验与真实关闭(F03/F07)", () => {
|
||||
/** F03:宿主必须按打开时锁定的流程与字段类型核载荷,畸形载荷不得覆盖节点配置 */
|
||||
it.each([
|
||||
["异流程回传", () => ({ flowGuid: "g-other" })],
|
||||
["字符串 defVersion", () => ({ defVersion: "3" })],
|
||||
["零 defVersion", () => ({ defVersion: 0 })],
|
||||
["小数 defVersion", () => ({ defVersion: 1.5 })],
|
||||
["空 nodeId", () => ({ assigns: [{ nodeId: "", userIds: [101] }] })],
|
||||
["字符串型 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: ["101"] }] })],
|
||||
["空 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: [] }] })],
|
||||
["assigns 不是数组", () => ({ assigns: "u2=101" })],
|
||||
])("畸形/异源载荷被丢弃:%s", async (_label, mutate) => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid, ...(mutate() as object) }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("缺少可核验的窗口引用时不处理消息(不再是「缺条件就跳过校验」)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }), { source: {} as Window });
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
});
|
||||
|
||||
/** F07:点 X 是真实用户路径,必须走同一 settle/close 并通知宿主取消 */
|
||||
it("点 Modal 的 X:宿主收到一次 cancelled,原有选择未被覆盖,监听器已摘", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||||
expect(closeBtn).not.toBeNull();
|
||||
(closeBtn as HTMLElement).click();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
const cancelled = wrapper.emitted("cancelled");
|
||||
expect(cancelled).toHaveLength(1);
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("程序化 close 与 X 走同一路径:重复关闭只 emit 一次 cancelled", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
(wrapper.vm as any).close();
|
||||
(wrapper.vm as any).close();
|
||||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||||
(closeBtn as HTMLElement)?.click();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("确认后不得再补发 cancelled(iframe 已答复,Modal 收起只是收尾)", async () => {
|
||||
const wrapper = mountHost();
|
||||
await openHost(wrapper);
|
||||
const cid = (wrapper.vm as any).contextId;
|
||||
await post(wrapper, baseData({ contextId: cid }));
|
||||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||||
const closeBtn = document.querySelector(".ivu-modal-close");
|
||||
(closeBtn as HTMLElement)?.click();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
expect(wrapper.emitted("cancelled")).toBeUndefined();
|
||||
expect(wrapper.emitted("confirmed")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
|
||||
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
|
||||
import ViewUIPlus, { Message } from "view-ui-plus";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
const { flowApiMock } = vi.hoisted(() => ({
|
||||
flowApiMock: {
|
||||
defList: vi.fn(),
|
||||
defInflight: vi.fn(),
|
||||
appList: vi.fn(),
|
||||
defOfflineLine: vi.fn(),
|
||||
defEnableLine: vi.fn(),
|
||||
defPublish: vi.fn(),
|
||||
defOffline: vi.fn(),
|
||||
defEnable: vi.fn(),
|
||||
defUpdateMeta: vi.fn(),
|
||||
defVersions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
|
||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
|
||||
vi.mock("@/views/flow/components/SourceDrawer.vue", () => ({ default: { template: "<div />" } }));
|
||||
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import Defs from "@/views/flow/admin/Defs.vue";
|
||||
|
||||
/** 组件级测试:真实挂载 Defs.vue(view-ui-plus Table/操作列),只 mock HTTP 客户端、路由与确认框。 */
|
||||
|
||||
const confirmMock = MyConfirm as unknown as Mock;
|
||||
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
function ok<T>(value: T) {
|
||||
return { send: async () => value };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function baseRows() {
|
||||
return [
|
||||
{
|
||||
id: 1, flowGuid: "g-normal", name: "正常线", code: "c1", version: 1,
|
||||
status: 1, lineOffline: 0, hasPublished: true, publishTime: "2026-10-20T10:00:00",
|
||||
},
|
||||
{
|
||||
id: 2, flowGuid: "g-off", name: "停发线", code: "c2", version: 1,
|
||||
status: 1, lineOffline: 1, hasPublished: true, publishTime: "2026-10-20T10:00:00",
|
||||
},
|
||||
{
|
||||
id: 3, flowGuid: "", name: "历史脏行", code: "c3", version: 1,
|
||||
status: 0, lineOffline: 0, hasPublished: false, publishTime: null,
|
||||
},
|
||||
{
|
||||
id: 4, flowGuid: "g-version-off", name: "版本停用线", code: "c4", version: 2,
|
||||
status: 2, lineOffline: 0, hasPublished: false, publishTime: "2026-10-19T10:00:00",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function offlineRows() {
|
||||
return baseRows().map((r) => (r.flowGuid === "g-normal" ? { ...r, lineOffline: 1 } : r));
|
||||
}
|
||||
|
||||
async function mountDefs() {
|
||||
flowApiMock.defList.mockImplementation(() => ok(baseRows()));
|
||||
flowApiMock.defInflight.mockImplementation(() => ok({ "g-normal": 2 }));
|
||||
flowApiMock.appList.mockImplementation(() => ok([]));
|
||||
const wrapper = mount(Defs, {
|
||||
global: { plugins: [ViewUIPlus] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function rowsNamed(wrapper: VueWrapper, name: string) {
|
||||
return wrapper.findAll("tr.ivu-table-row").filter((r) => r.text().includes(name));
|
||||
}
|
||||
|
||||
function lineLink(wrapper: VueWrapper, name: string, label: string) {
|
||||
for (const row of rowsNamed(wrapper, name)) {
|
||||
const link = row.findAll(".operating-link").find((l) => l.text() === label);
|
||||
if (link) return link;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lastConfirmOptions(): any {
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
return confirmMock.mock.calls[confirmMock.mock.calls.length - 1][0];
|
||||
}
|
||||
|
||||
let successSpy: Mock;
|
||||
let warningSpy: Mock;
|
||||
let errorSpy: Mock;
|
||||
let infoSpy: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
successSpy = vi.spyOn(Message, "success").mockImplementation((() => {}) as any);
|
||||
warningSpy = vi.spyOn(Message, "warning").mockImplementation((() => {}) as any);
|
||||
errorSpy = vi.spyOn(Message, "error").mockImplementation((() => {}) as any);
|
||||
infoSpy = vi.spyOn(Message, "info").mockImplementation((() => {}) as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Defs 流程线状态展示(分组视图)", () => {
|
||||
it("线状态与版本状态独立展示,入口随线状态切换", async () => {
|
||||
const wrapper = await mountDefs();
|
||||
|
||||
expect(wrapper.text()).toContain("线状态");
|
||||
|
||||
const normal = rowsNamed(wrapper, "正常线")[0]?.text() || "";
|
||||
expect(normal).toContain("正常");
|
||||
expect(normal).toContain("已发布");
|
||||
expect(normal).toContain("停发流程线");
|
||||
expect(normal).not.toContain("恢复流程线");
|
||||
|
||||
const offline = rowsNamed(wrapper, "停发线")[0]?.text() || "";
|
||||
expect(offline).toContain("已停发");
|
||||
expect(offline).toContain("已发布");
|
||||
expect(offline).toContain("恢复流程线");
|
||||
expect(offline).not.toContain("停发流程线");
|
||||
|
||||
// 版本已停用但线正常:线状态不得被版本 status 带偏
|
||||
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
|
||||
expect(versionOff).toContain("正常");
|
||||
expect(versionOff).toContain("停用");
|
||||
expect(versionOff).toContain("停发流程线");
|
||||
});
|
||||
|
||||
it("空 flowGuid 历史行显示 —,不出现任何线级操作入口", async () => {
|
||||
const wrapper = await mountDefs();
|
||||
const rows = rowsNamed(wrapper, "历史脏行");
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const row of rows) {
|
||||
expect(row.text()).toContain("—");
|
||||
expect(row.text()).not.toContain("停发流程线");
|
||||
expect(row.text()).not.toContain("恢复流程线");
|
||||
}
|
||||
expect(lineLink(wrapper, "历史脏行", "停发流程线")).toBeUndefined();
|
||||
expect(lineLink(wrapper, "历史脏行", "恢复流程线")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Defs 流程线状态展示(平铺视图)", () => {
|
||||
it("每个版本行展示共享线状态,线级入口与版本级停用并存", async () => {
|
||||
const wrapper = await mountDefs();
|
||||
const radios = wrapper.findAll("input[type=radio]");
|
||||
expect(radios.length).toBeGreaterThan(1);
|
||||
await radios[1].setValue(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(".fix").text()).toContain("平铺所有版本行");
|
||||
|
||||
expect(rowsNamed(wrapper, "正常线")[0]?.text()).toContain("正常");
|
||||
expect(rowsNamed(wrapper, "停发线")[0]?.text()).toContain("已停发");
|
||||
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
|
||||
expect(versionOff).toContain("正常");
|
||||
expect(versionOff).toContain("停用");
|
||||
|
||||
// 平铺视图长操作集进入「更多」,菜单内容仍由同一 lineActions 过滤逻辑产生
|
||||
const menuItems = Array.from(document.querySelectorAll(".ivu-dropdown-item"))
|
||||
.map((e) => e.textContent || "");
|
||||
expect(menuItems).toContain("停发流程线");
|
||||
expect(menuItems).toContain("恢复流程线");
|
||||
expect(menuItems).toContain("停用");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Defs 流程线停发/恢复交互", () => {
|
||||
it("停发:确认文案含在途数,请求期间显示处理中并禁用入口,成功后刷新列表与在途", async () => {
|
||||
flowApiMock.defList
|
||||
.mockImplementationOnce(() => ok(baseRows()))
|
||||
.mockImplementationOnce(() => ok(offlineRows()));
|
||||
const wrapper = await mountDefs();
|
||||
|
||||
const gate = deferred<number>();
|
||||
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
|
||||
|
||||
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
|
||||
const opts = lastConfirmOptions();
|
||||
expect(opts.title).toBe("停发流程线");
|
||||
expect(opts.msg).toContain("正常线");
|
||||
expect(opts.info).toContain("在途办件不受影响");
|
||||
expect(opts.info).toContain("当前在途 2 件");
|
||||
|
||||
const pending = opts.onOk();
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("处理中…");
|
||||
expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled");
|
||||
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal", { silentBizToast: true });
|
||||
|
||||
gate.resolve(2);
|
||||
await pending;
|
||||
await flushPromises();
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(2 个版本"));
|
||||
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
|
||||
expect(flowApiMock.defInflight).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.text()).toContain("已停发");
|
||||
expect(lineLink(wrapper, "正常线", "恢复流程线")).toBeTruthy();
|
||||
expect(wrapper.text()).not.toContain("处理中…");
|
||||
});
|
||||
|
||||
it("在途期间重复确认/重复点击不会产生第二次请求", async () => {
|
||||
const wrapper = await mountDefs();
|
||||
const gate = deferred<number>();
|
||||
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
|
||||
|
||||
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
|
||||
const opts = lastConfirmOptions();
|
||||
const first = opts.onOk();
|
||||
await nextTick();
|
||||
|
||||
// 入口已置灰(点击回调不再触发);再次确认也走 busy 守卫
|
||||
const disabledLink = lineLink(wrapper, "正常线", "停发流程线")!;
|
||||
expect(disabledLink.classes()).toContain("disabled");
|
||||
await disabledLink.trigger("click");
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await opts.onOk();
|
||||
expect(flowApiMock.defOfflineLine).toHaveBeenCalledTimes(1);
|
||||
expect(infoSpy).toHaveBeenCalledWith("该流程线操作正在处理中,请稍候");
|
||||
|
||||
gate.resolve(1);
|
||||
await first;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("恢复:已停发线只调 enableLine,成功后刷新并切换回停发入口", async () => {
|
||||
flowApiMock.defList
|
||||
.mockImplementationOnce(() => ok(baseRows()))
|
||||
.mockImplementationOnce(() => ok(baseRows()));
|
||||
const wrapper = await mountDefs();
|
||||
const gate = deferred<number>();
|
||||
flowApiMock.defEnableLine.mockReturnValue({ send: () => gate.promise });
|
||||
|
||||
await lineLink(wrapper, "停发线", "恢复流程线")!.trigger("click");
|
||||
const opts = lastConfirmOptions();
|
||||
expect(opts.title).toBe("恢复流程线");
|
||||
expect(opts.info).toContain("仅恢复流程线可用性");
|
||||
|
||||
const pending = opts.onOk();
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain("处理中…");
|
||||
expect(lineLink(wrapper, "停发线", "恢复流程线")!.classes()).toContain("disabled");
|
||||
gate.resolve(1);
|
||||
await pending;
|
||||
await flushPromises();
|
||||
|
||||
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off", { silentBizToast: true });
|
||||
expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled();
|
||||
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本"));
|
||||
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("接口失败/无权限/网络异常:明确报错,不刷新,不误报成功", async () => {
|
||||
const cases = [
|
||||
{ error: "无流程管理权限", expected: /无流程管理权限/ },
|
||||
{ error: "网络错误", expected: /网络异常/ },
|
||||
];
|
||||
for (const c of cases) {
|
||||
vi.clearAllMocks();
|
||||
const wrapper = await mountDefs();
|
||||
flowApiMock.defOfflineLine.mockReturnValue({
|
||||
send: () => Promise.reject(new Error(c.error)),
|
||||
});
|
||||
|
||||
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
|
||||
await lastConfirmOptions().onOk();
|
||||
await flushPromises();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(c.expected));
|
||||
expect(successSpy).not.toHaveBeenCalled();
|
||||
expect(flowApiMock.defList).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.text()).toContain("正常");
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("接口返回 0 行:提示状态未更新并要求刷新确认", async () => {
|
||||
const wrapper = await mountDefs();
|
||||
flowApiMock.defOfflineLine.mockReturnValue(ok(0));
|
||||
|
||||
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
|
||||
await lastConfirmOptions().onOk();
|
||||
await flushPromises();
|
||||
|
||||
expect(warningSpy).toHaveBeenCalledWith("未更新任何版本,流程线状态可能已变化,请刷新后确认");
|
||||
expect(successSpy).not.toHaveBeenCalled();
|
||||
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("操作成功但列表刷新失败:保留成功结果并明确提示刷新失败", async () => {
|
||||
flowApiMock.defList
|
||||
.mockImplementationOnce(() => ok(baseRows()))
|
||||
.mockImplementationOnce(() => ({ send: async () => { throw new Error("网络异常"); } }));
|
||||
const wrapper = await mountDefs();
|
||||
flowApiMock.defOfflineLine.mockReturnValue(ok(1));
|
||||
|
||||
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
|
||||
await lastConfirmOptions().onOk();
|
||||
await flushPromises();
|
||||
|
||||
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(1 个版本"));
|
||||
expect(warningSpy).toHaveBeenCalledWith("操作已提交,但列表刷新失败,请手动刷新后确认最新状态");
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
|
||||
import { reactive, nextTick } from 'vue';
|
||||
import ViewUIPlus from 'view-ui-plus';
|
||||
import ApprovalDrawer from '@/views/flow/admin/def-editor/components/ApprovalDrawer.vue';
|
||||
import NotifyDrawer from '@/views/flow/admin/def-editor/components/NotifyDrawer.vue';
|
||||
import { EDITOR_CTX, createApproval, createNotify } from '@/views/flow/admin/def-editor/model';
|
||||
|
||||
const { api } = vi.hoisted(() => ({ api: { deptGroups: vi.fn(), deptResolve: vi.fn(), deptOptions: vi.fn() } }));
|
||||
vi.mock('@/api/module/flow', () => ({ flowApi: api }));
|
||||
enableAutoUnmount(afterEach);
|
||||
const ok = (data: unknown) => ({ send: async () => data });
|
||||
|
||||
describe('CUSTOM 引用真实抽屉编辑往返', () => {
|
||||
for (const mode of ['approval', 'notify'] as const) {
|
||||
it(`${mode}:无 categoryId 的历史解析仍可反查分类,保存重开再保存不丢引用`, async () => {
|
||||
document.body.innerHTML = '';
|
||||
const ref = { departmentType: 'CUSTOM', departmentId: 9 };
|
||||
api.deptGroups.mockImplementation(() => ok([
|
||||
{ departmentType: 'SYSTEM', name: '系统组织' },
|
||||
{ departmentType: 'CUSTOM', categoryId: 4, name: '教研组' },
|
||||
]));
|
||||
api.deptResolve.mockImplementation(() => ok([{ ...ref, name: '科学组', memberCount: 2, selectable: true }]));
|
||||
api.deptOptions.mockImplementation(() => ok([{ ...ref, categoryId: 4, name: '科学组', memberCount: 2, selectable: true }]));
|
||||
const node: any = mode === 'approval' ? createApproval('审批') : createNotify('抄送');
|
||||
const key = mode === 'approval' ? 'assignee' : 'receivers';
|
||||
node[key] = { type: 'DEPT', deptRefs: [ref] };
|
||||
const ctx = { model: reactive({ list: [node] }), depts: [], userNames: {}, routers: [], availableVars: [], issueOf: () => [] };
|
||||
const wrapper = mount(mode === 'approval' ? ApprovalDrawer : NotifyDrawer, {
|
||||
props: { node, modelValue: true }, attachTo: document.body,
|
||||
global: { plugins: [ViewUIPlus], provide: { [EDITOR_CTX as unknown as symbol]: ctx } },
|
||||
});
|
||||
await flushPromises(); await nextTick();
|
||||
expect(document.body.textContent).toContain('科学组');
|
||||
expect(api.deptOptions).toHaveBeenCalledWith({ type: 'CUSTOM' });
|
||||
for (let round = 0; round < 2; round++) {
|
||||
if (round) { await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises(); }
|
||||
const input = document.querySelector('input.ivu-input') as HTMLInputElement;
|
||||
expect(input).toBeTruthy(); input.value = `修改名称${round}`; input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await nextTick();
|
||||
const confirm = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '确定');
|
||||
expect(confirm).toBeTruthy(); confirm!.click(); await nextTick();
|
||||
expect(node[key]).toEqual({ type: 'DEPT', deptRefs: [ref] });
|
||||
expect(node.name).toBe(`修改名称${round}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// jsdom 缺少浏览器布局/观察器能力:只补组件运行所需的最小桩,不替业务逻辑兜底。
|
||||
if (!("ResizeObserver" in globalThis)) {
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as any).ResizeObserver = ResizeObserverStub;
|
||||
}
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() {
|
||||
return false;
|
||||
},
|
||||
})) as any;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard) {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: async () => undefined },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { it, expect, vi, afterEach } from 'vitest';
|
||||
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
|
||||
import ViewUIPlus from 'view-ui-plus';
|
||||
import TaskHandle from '@/views/flow/TaskHandle.vue';
|
||||
const { api } = vi.hoisted(() => ({ api: { taskDetail: vi.fn(), approve: vi.fn() } }));
|
||||
vi.mock('@/api/module/flow', () => ({ flowApi: api, INSTANCE_STATUS: { 10: '审批中' } }));
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { taskId: '1' }, path: '/task/1', query: { embed: '1' } }), useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock('@/utils/taskEmbed', () => ({ isBareTaskRoute: () => true, postToParent: vi.fn() }));
|
||||
enableAutoUnmount(afterEach);
|
||||
const detail = { instanceId: 9, status: 10, myPendingTaskIds: [1], trace: [{ taskId: 1, status: 0 }], outlets: [] };
|
||||
async function click(text: string) {
|
||||
const b = Array.from(document.querySelectorAll('button')).find(x => x.textContent?.trim() === text);
|
||||
expect(b).toBeTruthy(); b!.click(); await flushPromises();
|
||||
}
|
||||
for (const lost of [true, false]) it(lost ? 'POST 丢响应不声称未生效' : 'POST 成功但刷新失败保留成功并关闭办理入口', async () => {
|
||||
document.body.innerHTML = '';
|
||||
let reads = 0;
|
||||
api.taskDetail.mockImplementation(() => ({ send: async () => { if (++reads > 1) throw new Error('refresh failed'); return detail; } }));
|
||||
api.approve.mockImplementation(() => ({ send: async () => { if (lost) throw new TypeError('Failed to fetch'); return {}; } }));
|
||||
mount(TaskHandle, { global: { plugins: [ViewUIPlus], stubs: { TraceTimeline: true } }, attachTo: document.body });
|
||||
await flushPromises(); await click('同意'); await click('确定');
|
||||
const text = document.body.textContent || '';
|
||||
expect(text).not.toContain('本次办理未生效');
|
||||
expect(text).not.toContain('任务仍是你的待办');
|
||||
if (lost) expect(text).toContain('结果待确认');
|
||||
else { expect(text).toContain('已提交成功'); expect(document.querySelector('.actions')).toBeNull(); }
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { toContentJson, fromContentJson } from "../src/views/flow/admin/def-editor/convert";
|
||||
import { createApproval, createCondition, createNotify, createParallel } from "../src/views/flow/admin/def-editor/model";
|
||||
import type { EditorItem, EditorModel } from "../src/views/flow/admin/def-editor/model";
|
||||
|
||||
/**
|
||||
* 流程定义序列化回归闸门(任务书 §6.8「检查流程定义序列化」)。
|
||||
* 本轮只动编辑器面板的**展示分层**,这里钉住:结构语义不因这些改动漂移。
|
||||
* 对比口径按语义集合比(节点字段集 + 边三元组集),不比数组顺序——空泳道会产生纯顺序差。
|
||||
*/
|
||||
|
||||
type Json = ReturnType<typeof toContentJson>;
|
||||
|
||||
function model(items: EditorItem[]): EditorModel {
|
||||
return {
|
||||
code: "rt-demo",
|
||||
name: "往返回归",
|
||||
list: items,
|
||||
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
|
||||
};
|
||||
}
|
||||
|
||||
const clone = (o: unknown) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function fullItems(): EditorItem[] {
|
||||
const approval = createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" });
|
||||
approval.countersign = "ALL";
|
||||
approval.deadlineHours = 8;
|
||||
approval.assignPolicy = { mode: "SINGLE", required: true };
|
||||
|
||||
const outletNode = createApproval("多出口", { type: "USERS", userIds: [101, 102] });
|
||||
outletNode.outlets = ["同意", "退回", "加签"];
|
||||
outletNode.branches = [[createApproval("分支一")], [], [createNotify("分支三抄送")]];
|
||||
|
||||
const cond = createCondition();
|
||||
cond.judgeMode = "EXPRESSION";
|
||||
cond.outlets = ["大额", "小额"];
|
||||
cond.conditions = ["amount > 1000", ""];
|
||||
cond.branches = [[createApproval("大额复核")], [createApproval("小额直过")]];
|
||||
|
||||
const par = createParallel();
|
||||
par.branches = [[createNotify("并行抄送A")], [createApproval("并行复核B", { type: "DEPT_KEY", deptKey: "42" })]];
|
||||
|
||||
return [approval, cond, par, outletNode];
|
||||
}
|
||||
|
||||
/** 已知缺陷用:非首位并行容器重解析后 split/join 会换成新 id(见最后一条用例) */
|
||||
function parallelDerivedIds(json: Json): string[] {
|
||||
return json.nodes.filter((n) => n.type === "PARALLEL_SPLIT" || n.type === "PARALLEL_JOIN").map((n) => n.id);
|
||||
}
|
||||
|
||||
function roundTrip(times: number, json: Json): Json {
|
||||
let cur = json;
|
||||
for (let i = 0; i < times; i++) {
|
||||
cur = toContentJson({ ...model([]), ...fromContentJson(clone(cur)) } as EditorModel);
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
test("结构往返两轮:每个节点的配置语义逐项一致(并行派生 id 另用例专门记录)", () => {
|
||||
const first = toContentJson(model(fullItems()));
|
||||
const second = roundTrip(1, first);
|
||||
const third = roundTrip(2, first);
|
||||
|
||||
const byId = (json: Json) => {
|
||||
const m = new Map<string, string>();
|
||||
json.nodes.forEach((n) => m.set(n.id, JSON.stringify(n)));
|
||||
return m;
|
||||
};
|
||||
const a = byId(first);
|
||||
const b = byId(second);
|
||||
const c = byId(third);
|
||||
|
||||
// 配置类节点(审批/抄送/条件)的 id 与内容必须逐字不变
|
||||
["u1", "u2", "u3", "u4", "u5c", "u8", "u9", "start", "end"].forEach((id) => {
|
||||
assert.equal(b.get(id), a.get(id), `节点 ${id} 内容在第二轮发生变化`);
|
||||
assert.equal(c.get(id), a.get(id), `节点 ${id} 内容在第三轮发生变化`);
|
||||
});
|
||||
assert.deepEqual(second.settings, first.settings);
|
||||
assert.deepEqual(third.settings, first.settings);
|
||||
|
||||
// 并行分支内的两名办理人同样不变
|
||||
const branchIds = ["u13", "u14"];
|
||||
branchIds.forEach((id) => assert.equal(b.get(id), a.get(id), `并行分支节点 ${id} 变化`));
|
||||
|
||||
// 出口边(含条件表达式)在两轮都存在
|
||||
const edgeSet = (json: Json) => new Set(json.edges.map((e) => `${e.from}->${e.to}${e.name ? `(${e.name})` : ""}${e.condition ? `[${e.condition}]` : ""}`));
|
||||
const ea = edgeSet(first);
|
||||
const eb = edgeSet(second);
|
||||
["u2->u3(同意)", "u2->u4(加签)", "u2->end(退回)", "u5c->u8(大额)[amount > 1000]", "u5c->u9(小额)"].forEach((e) => {
|
||||
assert.ok(ea.has(e), `第一轮缺少边 ${e}`);
|
||||
assert.ok(eb.has(e), `第二轮缺少边 ${e}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("关键语义字段全部活过往返,不被展示层分层影响", () => {
|
||||
const first = toContentJson(model(fullItems()));
|
||||
const parsed = fromContentJson(clone(first));
|
||||
const second = toContentJson({ ...model([]), ...parsed } as EditorModel);
|
||||
|
||||
// 会签 ALL 是后端缺省值,生成 JSON 时按设计省略(model.ts 注释);口径以解析回编辑态为准
|
||||
const approvalItem = parsed.list.find((it) => it.kind === "approval" && it.assignee?.type === "DEPT_KEY");
|
||||
assert.ok(approvalItem && approvalItem.kind === "approval");
|
||||
assert.equal(approvalItem.countersign, "ALL", "会签语义在解析端丢失");
|
||||
assert.equal(approvalItem.deadlineHours, 8, "办理时限丢失");
|
||||
assert.deepEqual(approvalItem.assignPolicy, { mode: "SINGLE", required: true }, "发起时指派策略丢失");
|
||||
|
||||
assert.ok(second.nodes.some((n) => n.type === "NOTIFY" && n.receivers?.type === "USERS"), "抄送对象丢失");
|
||||
assert.ok(second.nodes.some((n) => n.judgeMode === "EXPRESSION"), "条件判断方式丢失");
|
||||
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_SPLIT"), "并行拆分丢失");
|
||||
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_JOIN" && n.waitFor === "ALL"), "并行汇聚语义丢失");
|
||||
assert.ok(second.nodes.some((n) => n.id === "start") && second.nodes.some((n) => n.id === "end"), "端点丢失");
|
||||
assert.ok(second.edges.filter((e) => e.name).length >= 3, "多出口/条件分支的出口名丢失");
|
||||
});
|
||||
|
||||
test("设置与端点名不被丢:rejectPolicy/urge/start-end name 原样回写", () => {
|
||||
const m = model([createApproval("单节点")]);
|
||||
m.startName = "提交申请";
|
||||
m.endName = "办结";
|
||||
const json = toContentJson(m);
|
||||
assert.equal(json.settings.rejectPolicy, "END_REJECTED");
|
||||
assert.equal(json.settings.urge.intervalHours, 24);
|
||||
assert.equal((json.nodes.find((n) => n.type === "START") || {}).name, "提交申请");
|
||||
assert.equal((json.nodes.find((n) => n.type === "END") || {}).name, "办结");
|
||||
});
|
||||
|
||||
test("已知缺陷(本轮不修,修它等于改序列化契约):并行容器重解析后 split/join 换成新 id", () => {
|
||||
const first = toContentJson(model([createApproval("a"), createParallel()]));
|
||||
const before = parallelDerivedIds(first);
|
||||
const rt = roundTrip(1, first);
|
||||
const after = parallelDerivedIds(rt);
|
||||
|
||||
assert.equal(after.length, before.length, "并行拆/合节点数量发生变化");
|
||||
assert.notDeepEqual(after, before, "并行 id 改名行为已变化——若已被修好,请把本用例改成断言 id 稳定并同步终审");
|
||||
|
||||
// 改名只涉及 id 字面,汇聚语义不变
|
||||
const join = rt.nodes.find((n) => n.type === "PARALLEL_JOIN");
|
||||
assert.equal(rt.nodes.filter((n) => n.type === "PARALLEL_SPLIT").length, 1);
|
||||
assert.equal(join?.waitFor, "ALL", "并行汇聚 waitFor 语义丢失");
|
||||
// 拆分/汇聚与分支节点的连接关系仍然闭合(每个分支节点都连到某条 split/join)
|
||||
const anchors = new Set(after);
|
||||
const linked = rt.edges.filter((e) => anchors.has(e.from) || anchors.has(e.to));
|
||||
assert.ok(linked.length >= 4, `并行连线丢失,只剩 ${linked.length} 条`);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
classifyFlowError,
|
||||
describeFlowError,
|
||||
flowFailTip,
|
||||
flowReloadFailTip,
|
||||
flowSubmitFailTip,
|
||||
isDefiniteBusinessRejection,
|
||||
FLOW_GENERIC_FALLBACK,
|
||||
FLOW_NETWORK_FALLBACK,
|
||||
FLOW_UNKNOWN_FALLBACK,
|
||||
} from "../src/utils/flowError";
|
||||
|
||||
/**
|
||||
* 错误呈现与结果可信度单测(P0-1 附带项 + Codex 终审 F04/F05,2026-10-29)。
|
||||
*
|
||||
* F05 的口径不是"命中黑名单才挡",而是"拿不到可信凭据就不放行":
|
||||
* 只有带 bizCode 的业务信封原文才可能进 DOM;HTTP 状态文本、异常/SQL/Token 形态、
|
||||
* 以及无法判定来源的裸文本一律换成通用安全提示。
|
||||
*
|
||||
* F04 的口径是"失败 ≠ 未生效":服务端明确拒绝与"请求已发出但结果未知"必须分开说。
|
||||
*/
|
||||
|
||||
function bizError(message: string, bizCode = 2): Error {
|
||||
return Object.assign(new Error(message), { bizCode });
|
||||
}
|
||||
|
||||
function httpError(message: string, httpStatus = 500): Error {
|
||||
return Object.assign(new Error(message), { httpStatus });
|
||||
}
|
||||
|
||||
test("业务信封明确拒绝:具体业务话原样透出,不改写不误导", () => {
|
||||
assert.equal(describeFlowError(bizError("流程线不存在:8d07f049")), "流程线不存在:8d07f049");
|
||||
assert.equal(describeFlowError(bizError("该任务已被他人办理")), "该任务已被他人办理");
|
||||
assert.equal(
|
||||
describeFlowError(bizError("该待办缺少流程推进记录,需管理员回填后办理")),
|
||||
"该待办缺少流程推进记录,需管理员回填后办理",
|
||||
);
|
||||
assert.equal(isDefiniteBusinessRejection(bizError("流程线不存在:g1")), true);
|
||||
});
|
||||
|
||||
test("F05:HTTP 500 的响应原文不回显(旧实现把任意非黑名单文本都当业务话)", () => {
|
||||
assert.equal(describeFlowError(Object.assign(httpError('private backend text'), { bizCode: 2 })), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(isDefiniteBusinessRejection(Object.assign(httpError('private backend text'), { bizCode: 2 })), false);
|
||||
assert.equal(describeFlowError(bizError('private backend text', 999)), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError(bizError('private backend text', 6)), FLOW_GENERIC_FALLBACK);
|
||||
for (const raw of ["Internal Server Error", "Bad Gateway", ""]) {
|
||||
const out = describeFlowError(httpError(raw));
|
||||
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
|
||||
assert.equal(isDefiniteBusinessRejection(httpError(raw)), false);
|
||||
}
|
||||
// 网关超时属网络口径:说"刷新重试"而不是把 "Gateway Timeout" 原文贴进页面
|
||||
assert.equal(describeFlowError(httpError("Gateway Timeout", 504)), FLOW_NETWORK_FALLBACK);
|
||||
});
|
||||
|
||||
test("F05:无法判定来源的裸文本走通用提示,不做黑名单后原样放行", () => {
|
||||
// 没有 bizCode 的字符串/Error 不可能是"可信业务文案"
|
||||
assert.equal(describeFlowError("Something went wrong at node 7"), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError(new Error("流程线不存在:g1")), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError(undefined), FLOW_GENERIC_FALLBACK);
|
||||
assert.equal(describeFlowError(null), FLOW_GENERIC_FALLBACK);
|
||||
});
|
||||
|
||||
test("F05:即使来自业务信封,异常/SQL/令牌形态也不回显", () => {
|
||||
const leaks = [
|
||||
"java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required",
|
||||
"at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)",
|
||||
"Caused by: SQLException error code [1064]",
|
||||
"refresh token=Bearer abcdef123456",
|
||||
"<html><body>500 Internal Server Error</body></html>",
|
||||
"内部服务错误",
|
||||
];
|
||||
for (const raw of leaks) {
|
||||
const out = describeFlowError(bizError(raw, 500));
|
||||
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
|
||||
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out), raw);
|
||||
}
|
||||
});
|
||||
|
||||
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
|
||||
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
|
||||
assert.equal(describeFlowError(new Error("Failed to fetch")), FLOW_NETWORK_FALLBACK);
|
||||
assert.equal(classifyFlowError(httpError("timeout of 20000ms exceeded", 0)).kind, "network");
|
||||
});
|
||||
|
||||
test("F04:明确拒绝说拒绝;超时/丢响应只说结果待确认,不断言未生效", () => {
|
||||
const rejected = flowSubmitFailTip("同意", bizError("仅任务办理人本人可办理"));
|
||||
assert.ok(rejected.startsWith("同意被拒绝:"), rejected);
|
||||
assert.ok(rejected.includes("仅任务办理人本人可办理"), rejected);
|
||||
|
||||
const lost = flowSubmitFailTip("同意", new TypeError("network failure"));
|
||||
assert.ok(lost.startsWith("同意结果待确认:"), lost);
|
||||
assert.ok(lost.includes(FLOW_UNKNOWN_FALLBACK) || lost.includes(FLOW_NETWORK_FALLBACK), lost);
|
||||
assert.ok(!lost.includes("未生效"), lost);
|
||||
assert.ok(!lost.includes("仍是你的待办"), lost);
|
||||
});
|
||||
|
||||
test("F04:办理成功但后续刷新失败 → 说明已成功、只是没刷新,不改口成失败", () => {
|
||||
const tip = flowReloadFailTip("同意");
|
||||
assert.ok(tip.includes("已提交成功"), tip);
|
||||
assert.ok(tip.includes("刷新"), tip);
|
||||
});
|
||||
|
||||
test("只读动作失败的内联提示带动作名,不谎报成功", () => {
|
||||
const tip = flowFailTip("催办", bizError("已达催办次数上限(3 次)"));
|
||||
assert.ok(tip.startsWith("催办未完成:"), tip);
|
||||
assert.ok(tip.includes("已达催办次数上限(3 次)"), tip);
|
||||
assert.ok(!tip.includes("成功"), tip);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fromContentJson, toContentJson } from "../src/views/flow/admin/def-editor/convert";
|
||||
import { createEmptyModel } from "../src/views/flow/admin/def-editor/model";
|
||||
import { isValidAssignMessage, buildMessage } from "../src/utils/assignEmbed";
|
||||
|
||||
/**
|
||||
* FL 2.0 缺口补足的定义契约往返 + 选人载荷校验单测(2026-10-29)。
|
||||
*
|
||||
* 覆盖三件"换皮不能破"的事:
|
||||
* 1. C1/C3:UserBase 类型化部门引用与节点展示方式在 保存→重新加载→再次保存 中逐字段存活;
|
||||
* 2. D1:变量契约持久化/回显,且编辑态专用字段(enumText)不进 contentJson;
|
||||
* 3. F03/F07:选人 iframe 宿主对畸形/异源/异流程载荷一律拒绝。
|
||||
*
|
||||
* 注意:并行容器派生节点 id 重编号(Codex F08)是基线既有行为,本文件刻意不把它写成"已修复"。
|
||||
*/
|
||||
|
||||
const ORIGINAL = JSON.stringify({
|
||||
code: "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
name: "缺口补足线",
|
||||
start: "start",
|
||||
nodes: [
|
||||
{ id: "start", type: "START" },
|
||||
{
|
||||
id: "n1",
|
||||
type: "APPROVAL",
|
||||
name: "一级审批",
|
||||
assignee: { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] },
|
||||
countersign: "ANY",
|
||||
displayMode: "DEPT",
|
||||
displayLabel: "校长室",
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
type: "APPROVAL",
|
||||
name: "二级审批",
|
||||
assignee: { type: "USERS", userIds: [101, 102] },
|
||||
countersign: "ALL",
|
||||
assignPolicy: { mode: "SINGLE", required: true },
|
||||
},
|
||||
{
|
||||
id: "n3",
|
||||
type: "NOTIFY",
|
||||
name: "抄送",
|
||||
receivers: {
|
||||
type: "DEPT",
|
||||
deptRefs: [
|
||||
{ departmentType: "CUSTOM", departmentId: 7 },
|
||||
{ departmentType: "SYSTEM", departmentId: 42 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "end", type: "END" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "n1" },
|
||||
{ from: "n1", to: "n2" },
|
||||
{ from: "n2", to: "n3" },
|
||||
{ from: "n3", to: "end" },
|
||||
],
|
||||
variables: [
|
||||
{ key: "days", type: "number", required: true },
|
||||
{ key: "grade", type: "string", enum: ["g1", "g2"] },
|
||||
],
|
||||
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
|
||||
});
|
||||
|
||||
test("部门引用与展示方式:载入→保存→再载入逐字段存活,且不写入名称快照", () => {
|
||||
const parsed = fromContentJson(JSON.parse(ORIGINAL));
|
||||
const json = toContentJson({
|
||||
code: parsed.code,
|
||||
name: parsed.name,
|
||||
list: parsed.list,
|
||||
settings: parsed.settings,
|
||||
variables: parsed.variables,
|
||||
});
|
||||
const n1: any = json.nodes.find((n) => n.id === "n1");
|
||||
assert.deepEqual(n1.assignee, { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] });
|
||||
assert.equal(n1.displayMode, "DEPT");
|
||||
assert.equal(n1.displayLabel, "校长室");
|
||||
assert.equal(n1.countersign, "ANY");
|
||||
|
||||
const n3: any = json.nodes.find((n) => n.id === "n3");
|
||||
assert.deepEqual(n3.receivers.deptRefs, [
|
||||
{ departmentType: "CUSTOM", departmentId: 7 },
|
||||
{ departmentType: "SYSTEM", departmentId: 42 },
|
||||
]);
|
||||
|
||||
// 第二次往返不变形(保存-重载-再保存)
|
||||
const reparsed = fromContentJson(JSON.parse(JSON.stringify(json)));
|
||||
const again = toContentJson({
|
||||
code: reparsed.code,
|
||||
name: reparsed.name,
|
||||
list: reparsed.list,
|
||||
settings: reparsed.settings,
|
||||
variables: reparsed.variables,
|
||||
});
|
||||
assert.deepEqual(again.nodes, json.nodes);
|
||||
});
|
||||
|
||||
test("未配置展示方式的历史节点不写出 displayMode(旧定义字节兼容)", () => {
|
||||
const legacy = JSON.parse(ORIGINAL);
|
||||
delete legacy.nodes[1].displayMode;
|
||||
delete legacy.nodes[1].displayLabel;
|
||||
const parsed = fromContentJson(legacy);
|
||||
const json = toContentJson({
|
||||
code: parsed.code, name: parsed.name, list: parsed.list,
|
||||
settings: parsed.settings, variables: parsed.variables,
|
||||
});
|
||||
const n1: any = json.nodes.find((n) => n.id === "n1");
|
||||
assert.equal("displayMode" in n1, false);
|
||||
assert.equal("displayLabel" in n1, false);
|
||||
});
|
||||
|
||||
test("D1 变量契约:required/enum 持久化,编辑态 enumText 不进 contentJson", () => {
|
||||
const parsed = fromContentJson(JSON.parse(ORIGINAL));
|
||||
assert.deepEqual(parsed.variables?.map((v) => v.key), ["days", "grade"]);
|
||||
assert.equal(parsed.variables?.[0].required, true);
|
||||
assert.deepEqual(parsed.variables?.[1].enum, ["g1", "g2"]);
|
||||
|
||||
const json = toContentJson({
|
||||
code: "x", name: "x", list: parsed.list, settings: parsed.settings, variables: parsed.variables,
|
||||
});
|
||||
assert.deepEqual((json as any).variables, [
|
||||
{ key: "days", type: "number", required: true },
|
||||
{ key: "grade", type: "string", enum: ["g1", "g2"] },
|
||||
]);
|
||||
assert.equal("enumText" in ((json as any).variables[1]), false);
|
||||
});
|
||||
|
||||
test("D1 新建流程显式声明空变量集(后端要求新发布版本必须声明)", () => {
|
||||
const model = createEmptyModel();
|
||||
const json = toContentJson(model);
|
||||
assert.deepEqual((json as any).variables, []);
|
||||
});
|
||||
|
||||
test("D1 逗号输入的值域归一为字符串数组,中文逗号同样切开", () => {
|
||||
const model = createEmptyModel();
|
||||
model.variables = [{ key: "grade", type: "string", required: false, enumText: "优,良, 中 ," }];
|
||||
const json = toContentJson(model);
|
||||
assert.deepEqual((json as any).variables, [{ key: "grade", type: "string", enum: ["优", "良", "中"] }]);
|
||||
});
|
||||
|
||||
// ==================== F03 选人 iframe 载荷校验 ====================
|
||||
|
||||
const ORIGIN = "https://notice.example.edu.cn";
|
||||
const WINDOW = {} as Window;
|
||||
const CONTEXT = "ctx-1";
|
||||
const FLOW = "fg-locked";
|
||||
|
||||
function frameEvent(data: unknown, origin = ORIGIN, source: Window | null = WINDOW) {
|
||||
return { origin, source, data } as unknown as MessageEvent;
|
||||
}
|
||||
|
||||
function confirmed(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
proto: "flow", v: 1, type: "assign-confirmed", contextId: CONTEXT,
|
||||
flowGuid: FLOW, defVersion: 3,
|
||||
assigns: [{ nodeId: "n1", userIds: [101] }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function valid(event: MessageEvent) {
|
||||
return isValidAssignMessage(event, ORIGIN, WINDOW, CONTEXT, FLOW);
|
||||
}
|
||||
|
||||
test("F03:正向消息通过,且逐字段带出", () => {
|
||||
const msg = valid(frameEvent(confirmed()));
|
||||
assert.ok(msg);
|
||||
assert.equal(msg?.flowGuid, FLOW);
|
||||
assert.equal(msg?.defVersion, 3);
|
||||
});
|
||||
|
||||
test("F03:origin/source/context 任一缺失或不符即丢弃(不再是「缺条件就跳过校验」)", () => {
|
||||
assert.equal(valid(frameEvent(confirmed(), "https://evil.example")), null);
|
||||
assert.equal(isValidAssignMessage(frameEvent(confirmed()), ORIGIN, null, CONTEXT, FLOW), null);
|
||||
assert.equal(isValidAssignMessage(frameEvent(confirmed()), "", WINDOW, CONTEXT, FLOW), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ contextId: "stale-ctx" }))), null);
|
||||
});
|
||||
|
||||
test("F03:异流程回传丢弃(消息 flowGuid 必须等于打开时锁定的流程)", () => {
|
||||
assert.equal(valid(frameEvent(confirmed({ flowGuid: "fg-other" }))), null);
|
||||
});
|
||||
|
||||
test("F03:畸形载荷整条拒绝——字符串版本、空节点、非数字 ID、缺 assigns", () => {
|
||||
assert.equal(valid(frameEvent(confirmed({ defVersion: "3" }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ defVersion: 0 }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ defVersion: 1.5 }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "", userIds: [101] }] }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: ["101"] }] }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: [] }] }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ assigns: "n1=101" }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ assigns: undefined }))), null);
|
||||
});
|
||||
|
||||
test("F03:协议版本与类型闭集不被放宽", () => {
|
||||
assert.equal(valid(frameEvent(confirmed({ v: 2 }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ proto: "fc" }))), null);
|
||||
assert.equal(valid(frameEvent(confirmed({ type: "assign-mystery" }))), null);
|
||||
assert.equal(valid(frameEvent({ nope: true })), null);
|
||||
});
|
||||
|
||||
test("取消消息只需 context,不携带选择也不被要求带 flowGuid", () => {
|
||||
const cancelled = { proto: "flow", v: 1, type: "assign-cancelled", contextId: CONTEXT };
|
||||
const msg = valid(frameEvent(cancelled));
|
||||
assert.equal(msg?.type, "assign-cancelled");
|
||||
assert.equal(buildMessage(cancelled as any)?.type, "assign-cancelled");
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { lineActionErrorDetail, runLineAction } from "../src/utils/lineOffline";
|
||||
|
||||
/** 线级启停纯逻辑单测:不发真实请求,边界=请求/刷新编排,不代表后端或真实 UI 已验证 */
|
||||
|
||||
test("停发成功:按 flowGuid 调一次接口,按返回行数刷新", async () => {
|
||||
const calls: string[] = [];
|
||||
let reloads = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: " g1 ",
|
||||
lineOffline: 0,
|
||||
action: "offline",
|
||||
request: async (guid) => { calls.push(guid); return 3; },
|
||||
reload: async () => { reloads++; },
|
||||
});
|
||||
assert.deepEqual(calls, ["g1"]);
|
||||
assert.equal(out.kind, "updated");
|
||||
assert.equal(out.affected, 3);
|
||||
assert.equal(out.reloadFailed, false);
|
||||
assert.equal(reloads, 1);
|
||||
});
|
||||
|
||||
test("恢复成功:已停发线才发恢复请求", async () => {
|
||||
let requested = "";
|
||||
let reloads = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: "g2",
|
||||
lineOffline: 1,
|
||||
action: "enable",
|
||||
request: async (guid) => { requested = guid; return 2; },
|
||||
reload: async () => { reloads++; },
|
||||
});
|
||||
assert.equal(requested, "g2");
|
||||
assert.equal(out.kind, "updated");
|
||||
assert.equal(out.affected, 2);
|
||||
assert.equal(reloads, 1);
|
||||
});
|
||||
|
||||
test("重复停发被前端状态守卫拦下:不发请求、不刷新", async () => {
|
||||
let requests = 0;
|
||||
let reloads = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: "g3",
|
||||
lineOffline: 1,
|
||||
action: "offline",
|
||||
request: async () => { requests++; return 1; },
|
||||
reload: async () => { reloads++; },
|
||||
});
|
||||
assert.equal(out.kind, "skipped");
|
||||
assert.equal(out.reason, "already-offline");
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(reloads, 0);
|
||||
});
|
||||
|
||||
test("恢复一条未停发的线被拦下:不发请求、不刷新", async () => {
|
||||
let requests = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: "g4",
|
||||
lineOffline: 0,
|
||||
action: "enable",
|
||||
request: async () => { requests++; return 1; },
|
||||
reload: async () => {},
|
||||
});
|
||||
assert.equal(out.kind, "skipped");
|
||||
assert.equal(out.reason, "not-offline");
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("空 flowGuid 不发请求(历史脏行防误操作整线)", async () => {
|
||||
let requests = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: "",
|
||||
lineOffline: 0,
|
||||
action: "offline",
|
||||
request: async () => { requests++; return 1; },
|
||||
reload: async () => {},
|
||||
});
|
||||
assert.equal(out.kind, "skipped");
|
||||
assert.equal(out.reason, "no-guid");
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test("接口返回 0 行:报 empty 但仍刷新列表以同步真实状态", async () => {
|
||||
let reloads = 0;
|
||||
const out = await runLineAction({
|
||||
flowGuid: "g5",
|
||||
lineOffline: 0,
|
||||
action: "offline",
|
||||
request: async () => 0,
|
||||
reload: async () => { reloads++; },
|
||||
});
|
||||
assert.equal(out.kind, "empty");
|
||||
assert.equal(out.affected, 0);
|
||||
assert.equal(reloads, 1);
|
||||
});
|
||||
|
||||
test("接口失败向上抛出,且不触发刷新", async () => {
|
||||
let reloads = 0;
|
||||
await assert.rejects(() => runLineAction({
|
||||
flowGuid: "g6",
|
||||
lineOffline: 0,
|
||||
action: "offline",
|
||||
request: async () => { throw new Error("无流程管理权限"); },
|
||||
reload: async () => { reloads++; },
|
||||
}), /无流程管理权限/);
|
||||
assert.equal(reloads, 0);
|
||||
});
|
||||
|
||||
test("操作成功但刷新失败:保留成功结果并标记 reloadFailed", async () => {
|
||||
const out = await runLineAction({
|
||||
flowGuid: "g7",
|
||||
lineOffline: 1,
|
||||
action: "enable",
|
||||
request: async () => 1,
|
||||
reload: async () => { throw new Error("网络异常"); },
|
||||
});
|
||||
assert.equal(out.kind, "updated");
|
||||
assert.equal(out.affected, 1);
|
||||
assert.equal(out.reloadFailed, true);
|
||||
});
|
||||
|
||||
test("错误文案:权限/网络异常与后端业务错误分开提示", () => {
|
||||
assert.match(lineActionErrorDetail("网络错误"), /权限/);
|
||||
assert.match(lineActionErrorDetail("Failed to fetch"), /网络异常/);
|
||||
assert.match(lineActionErrorDetail(""), /网络异常/);
|
||||
assert.equal(lineActionErrorDetail("流程线不存在:g1"), "流程线不存在:g1");
|
||||
});
|
||||
|
||||
test("静态接线:Defs.vue 使用线级接口,不混入 listAll 或重复查询", () => {
|
||||
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
|
||||
assert.match(defs, /defOfflineLine/);
|
||||
assert.match(defs, /defEnableLine/);
|
||||
assert.match(defs, /lineOffline/);
|
||||
assert.match(defs, /停发流程线/);
|
||||
assert.match(defs, /恢复流程线/);
|
||||
assert.match(defs, /versionActions/);
|
||||
assert.doesNotMatch(defs, /listAll/);
|
||||
});
|
||||
|
||||
test("静态接线:API 客户端使用既有 admin 端点与 flowGuid 请求体", () => {
|
||||
const api = readFileSync(resolve(process.cwd(), "src/api/module/flow.ts"), "utf8");
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}, config/);
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}, config/);
|
||||
});
|
||||
|
||||
test("静态接线:线级启停失败只有一条错误面(调用方带 silentBizToast,全局 toast 认这个旗标)", () => {
|
||||
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
|
||||
const core = readFileSync(resolve(process.cwd(), "src/utils/alova/alova-core.ts"), "utf8");
|
||||
assert.match(defs, /defOfflineLine\(guid, \{ silentBizToast: true \}\)/);
|
||||
assert.match(defs, /defEnableLine\(guid, \{ silentBizToast: true \}\)/);
|
||||
assert.match(core, /silentBizToast/);
|
||||
assert.match(core, /if \(!isSilentBizToast\(method\)\) onBizError/);
|
||||
assert.match(core, /if \(!isSilentBizToast\(method\)\) onHttpError/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// 组件测试专用配置:只加载 Vue SFC 编译与 @ 别名,不引入 html/mock/svg 构建插件。
|
||||
// 真实构建仍走 vite.config.ts;两者互不替代。
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { "@": resolve(process.cwd(), "src") },
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/**/*.component.test.ts"],
|
||||
setupFiles: ["tests/components/setup.ts"],
|
||||
css: false,
|
||||
testTimeout: 15000,
|
||||
hookTimeout: 15000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user