Compare commits
24
Commits
e01ef87380
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93313cdb7 | ||
|
|
f2841e2740 | ||
|
|
cdea9c559b | ||
|
|
5534cd107a | ||
|
|
1e852c78be | ||
|
|
8392635571 | ||
|
|
4849b53541 | ||
|
|
8853c0faa3 | ||
|
|
ba448fc7a0 | ||
|
|
04fd54e945 | ||
|
|
c7e7a7aa85 | ||
|
|
33500e23d6 | ||
|
|
650665224c | ||
|
|
98ea069691 | ||
|
|
1369b103ee | ||
|
|
0542279636 | ||
|
|
fbd428b913 | ||
|
|
859210bf07 | ||
|
|
6998152c88 | ||
|
|
26d93148dc | ||
|
|
070f2f5ee8 | ||
|
|
8962dac7d2 | ||
|
|
0a060ab22a | ||
|
|
ec810afd14 |
@@ -19,7 +19,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- 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 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -38,21 +41,29 @@ jobs:
|
|||||||
- name: 构建前端(node 容器)
|
- name: 构建前端(node 容器)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
docker exec frontbuild sh -c '
|
# 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
|
set -e
|
||||||
cd /build
|
cd /build
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
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 ci
|
||||||
npm run build
|
npm run build
|
||||||
'
|
'
|
||||||
echo "=== dist 产物 ==="
|
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 到宿主工作目录
|
# 从容器 cp dist 到宿主工作目录
|
||||||
docker cp frontbuild:/build/dist ./dist
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
docker rm -f frontbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
echo "BUILD CHECK OK"
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||||
|
|||||||
Generated
+1575
-154
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -12,7 +12,12 @@
|
|||||||
"build:ld": "vite build --mode ld",
|
"build:ld": "vite build --mode ld",
|
||||||
"build:prod": "vite build --mode prod",
|
"build:prod": "vite build --mode prod",
|
||||||
"preview": "vite preview",
|
"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 --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": {
|
"dependencies": {
|
||||||
"@open-file-viewer/core": "^0.1.43",
|
"@open-file-viewer/core": "^0.1.43",
|
||||||
@@ -40,13 +45,16 @@
|
|||||||
"@unocss/preset-rem-to-px": "^66.6.0",
|
"@unocss/preset-rem-to-px": "^66.6.0",
|
||||||
"@unocss/preset-uno": "^66.6.0",
|
"@unocss/preset-uno": "^66.6.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"@vue/test-utils": "2.4.6",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
|
"jsdom": "25.0.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"unocss": "^66.6.0",
|
"unocss": "^66.6.0",
|
||||||
"unplugin-auto-import": "0.18.3",
|
"unplugin-auto-import": "0.18.3",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
"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 config from "@/assets/config/host.config";
|
||||||
import { baseApi } from "@/api/module/base";
|
import { baseApi } from "@/api/module/base";
|
||||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||||
|
import { isBareTaskRoute } from "@/utils/taskEmbed";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isLogin = ref(false);
|
const isLogin = ref(false);
|
||||||
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
||||||
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
|
// 也不该出现菜单壳。判定用路由前缀而非 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):
|
// 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
|
||||||
// 上面 isEmbed 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
// 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||||
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
|
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
|
||||||
function syncEmbedLayout(embed: boolean) {
|
function syncEmbedLayout(embed: boolean) {
|
||||||
if (embed) document.body.style.minWidth = "0";
|
if (embed) document.body.style.minWidth = "0";
|
||||||
else document.body.style.removeProperty("min-width");
|
else document.body.style.removeProperty("min-width");
|
||||||
}
|
}
|
||||||
watch(isEmbed, syncEmbedLayout, { immediate: true });
|
watch(isShellless, syncEmbedLayout, { immediate: true });
|
||||||
onBeforeUnmount(() => syncEmbedLayout(false));
|
onBeforeUnmount(() => syncEmbedLayout(false));
|
||||||
|
|
||||||
const layoutData = reactive({
|
const layoutData = reactive({
|
||||||
@@ -37,8 +44,9 @@ onMounted(async () => {
|
|||||||
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||||
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
|
||||||
if (isEmbed.value) {
|
// 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
|
||||||
|
if (isTokenEmbed.value) {
|
||||||
isLogin.value = true;
|
isLogin.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,11 +105,14 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) -->
|
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
|
||||||
<router-view v-if="isEmbed" />
|
token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
|
||||||
<template v-else>
|
否则未登录时 iframe 里先闪一个能点的办理面板 -->
|
||||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
<router-view v-if="isTokenEmbed" />
|
||||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
<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">
|
<div class="main-wrapper">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-6
@@ -13,8 +13,9 @@ function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
|||||||
|
|
||||||
// alova 的请求体是 Method 构造的第 5 个参数(instance.data),不是 config 上的键:
|
// alova 的请求体是 Method 构造的第 5 个参数(instance.data),不是 config 上的键:
|
||||||
// 写进 config 的 body/data 都会被适配器忽略,POST 将发出空 body。
|
// 写进 config 的 body/data 都会被适配器忽略,POST 将发出空 body。
|
||||||
function post<T = any>(url: string, body?: any): Method<T> {
|
// 第 4 个参数 config 可带 silentBizToast:调用方自己承担错误提示时,不再叠加全局 toast。
|
||||||
return new Method<T>("POST", alovaInstance, url, {}, body);
|
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 口径) */
|
||||||
@@ -144,7 +145,29 @@ class FlowApi {
|
|||||||
defGet(id: number | string) {
|
defGet(id: number | string) {
|
||||||
return get(`${ROOT}/admin/def/get`, { id });
|
return get(`${ROOT}/admin/def/get`, { id });
|
||||||
}
|
}
|
||||||
defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string; appKey?: string; changeLog?: 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;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
contentJson: string;
|
||||||
|
baseGuid?: string;
|
||||||
|
appKey?: string;
|
||||||
|
changeLog?: string;
|
||||||
|
formUuid?: string;
|
||||||
|
formVersion?: number;
|
||||||
|
}) {
|
||||||
return post(`${ROOT}/admin/def/save`, body);
|
return post(`${ROOT}/admin/def/save`, body);
|
||||||
}
|
}
|
||||||
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
|
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
|
||||||
@@ -161,6 +184,17 @@ class FlowApi {
|
|||||||
defEnable(id: number | string) {
|
defEnable(id: number | string) {
|
||||||
return post(`${ROOT}/admin/def/enable/${id}`);
|
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) {
|
defVersions(flowGuid: string) {
|
||||||
return get(`${ROOT}/admin/def/versions`, { flowGuid });
|
return get(`${ROOT}/admin/def/versions`, { flowGuid });
|
||||||
}
|
}
|
||||||
@@ -252,6 +286,10 @@ class FlowApi {
|
|||||||
callbackRepush(ids: number[]) {
|
callbackRepush(ids: number[]) {
|
||||||
return post(`${ROOT}/admin/callback/repush`, ids);
|
return post(`${ROOT}/admin/callback/repush`, ids);
|
||||||
}
|
}
|
||||||
|
/** 对接健康度体检(2026-10-15 P2):每应用一行六项检查 + 综合评分,零新增表 */
|
||||||
|
callbackHealth() {
|
||||||
|
return get(`${ROOT}/admin/callback/health`);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 管理面:条件路由注册 / 重跑 ====================
|
// ==================== 管理面:条件路由注册 / 重跑 ====================
|
||||||
|
|
||||||
@@ -273,9 +311,13 @@ class FlowApi {
|
|||||||
routerStatus(id: number | string, enabled: boolean) {
|
routerStatus(id: number | string, enabled: boolean) {
|
||||||
return post(`${ROOT}/admin/router/status/${id}?enabled=${enabled}`);
|
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:与运行时同一求值核心;不落库不走流程) */
|
/** 表达式调试求值(2026-10-10:与运行时同一求值核心;不落库不走流程) */
|
||||||
routerEval(body: { expression: string; variablesJson?: string }) {
|
routerEval(body: { expression: string; variablesJson?: string }) {
|
||||||
@@ -286,6 +328,14 @@ class FlowApi {
|
|||||||
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
|
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
|
||||||
return get(`${ROOT}/admin/instance/page`, params);
|
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();
|
export const flowApi = new FlowApi();
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -98,17 +98,25 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||||
.table-spin-load {
|
.table-spin-load {
|
||||||
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
margin: 0 auto;
|
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 {
|
.ivu-table-cell-tree {
|
||||||
@@ -134,6 +142,37 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 固定列层不吞下层事件 =================
|
||||||
|
根因(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,样式必须全局) */
|
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
|
||||||
.op-more {
|
.op-more {
|
||||||
.op-more-label {
|
.op-more-label {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<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 listening = false;
|
||||||
|
/** 本次会话是否已经由 iframe 明确答复过(确认/取消),用于区分"宿主自己关框" */
|
||||||
|
let settled = 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 {
|
||||||
|
const msg: AssignEmbedMessage | null = isValidAssignMessage(
|
||||||
|
ev,
|
||||||
|
expectedOrigin,
|
||||||
|
frameEl.value?.contentWindow ?? null,
|
||||||
|
contextId.value,
|
||||||
|
);
|
||||||
|
if (!msg) return;
|
||||||
|
settled = true;
|
||||||
|
closeListener();
|
||||||
|
visible.value = false;
|
||||||
|
if (msg.type === "assign-cancelled") {
|
||||||
|
emit("cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("confirmed", {
|
||||||
|
flowGuid: msg.flowGuid || props.flowGuid,
|
||||||
|
defVersion: msg.defVersion,
|
||||||
|
assigns: msg.assigns ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(): void {
|
||||||
|
if (!props.flowGuid) return;
|
||||||
|
// 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效
|
||||||
|
closeListener();
|
||||||
|
settled = false;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宿主主动收起(等价于取消):同样摘监听,避免后续消息继续影响本页 */
|
||||||
|
function close(): void {
|
||||||
|
closeListener();
|
||||||
|
if (visible.value && !settled) emit("cancelled");
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点遮罩/X 关闭走这里;已 settled 的关闭由 iframe 自己回过话,不再补发 cancelled
|
||||||
|
watch(visible, (val) => {
|
||||||
|
if (!val) closeListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(closeListener);
|
||||||
|
|
||||||
|
defineExpose({ open, close, contextId });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model="visible" :title="title || '选择办理人'" :width="760" :footer-hide="true" class="assign-embed-modal">
|
||||||
|
<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="暂无数据"
|
no-data-text="暂无数据"
|
||||||
>
|
>
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
<slot :name="slotName" v-bind="slotProps" />
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
|
|||||||
@@ -76,10 +76,15 @@ const routeChange = (to: any) => {
|
|||||||
const navArr: any[] = [];
|
const navArr: any[] = [];
|
||||||
|
|
||||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||||
// 匹配不到再退回 modId 对照
|
// 匹配不到再退回 modId 对照。
|
||||||
|
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
|
||||||
|
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
|
||||||
|
// 不相干的「流程编辑器」(验收报告 P3)。
|
||||||
const find: any =
|
const find: any =
|
||||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
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) {
|
if (find) {
|
||||||
currPath.value = find.page || "";
|
currPath.value = find.page || "";
|
||||||
if (find.pid) {
|
if (find.pid) {
|
||||||
@@ -109,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) {
|
if (r) {
|
||||||
currPath.value = r.path;
|
currPath.value = r.path;
|
||||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||||
@@ -117,7 +125,10 @@ const routeChange = (to: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currPath.value = "";
|
currPath.value = "";
|
||||||
navPath.value = [];
|
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
|
||||||
|
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
|
||||||
|
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
|
||||||
|
navPath.value = title ? [{ name: title }] : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const linkMenu = (item: any) => {
|
const linkMenu = (item: any) => {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/flow/InstanceDetail.vue"),
|
component: () => import("@/views/flow/InstanceDetail.vue"),
|
||||||
meta: { title: "办件详情", parentId: 102 },
|
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 整页跳转
|
// 流程示意图深链页(不挂菜单,同 /task/:taskId 模式);跨应用入口 window.open 整页跳转
|
||||||
path: "/diagram",
|
path: "/diagram",
|
||||||
@@ -81,6 +88,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/flow/admin/AppSetting.vue"),
|
component: () => import("@/views/flow/admin/AppSetting.vue"),
|
||||||
meta: { title: "应用设置", modId: 107 },
|
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 },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
|
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
|
||||||
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
|
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const defHooks: Required<RequestHooks> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
/* ============= 错误处理(原 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 {
|
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||||
// http 层错误
|
// http 层错误
|
||||||
if (status !== 200) {
|
if (status !== 200) {
|
||||||
@@ -48,7 +55,7 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
|||||||
default:
|
default:
|
||||||
msg = res?.msg || "内部服务错误!";
|
msg = res?.msg || "内部服务错误!";
|
||||||
}
|
}
|
||||||
throw new Error(msg);
|
fail(msg, { httpStatus: status });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务层错误
|
// 业务层错误
|
||||||
@@ -64,9 +71,9 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
|||||||
window.location.href = res.urlToRedirectTo!;
|
window.location.href = res.urlToRedirectTo!;
|
||||||
return new Promise(() => {}) as T;
|
return new Promise(() => {}) as T;
|
||||||
case 4:
|
case 4:
|
||||||
throw new Error(res.msg || "参数错误!");
|
throw Object.assign(new Error(res.msg || "参数错误!"), { bizCode: 4 });
|
||||||
default:
|
default:
|
||||||
throw new Error(res.msg || "业务错误");
|
throw Object.assign(new Error(res.msg || "业务错误"), { bizCode: res.code });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +115,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--;
|
reqCount--;
|
||||||
if (reqCount === 0) {
|
if (reqCount === 0) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
@@ -120,18 +132,18 @@ export function createBschAlova(
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|
||||||
onBizError(e.message);
|
if (!isSilentBizToast(method)) onBizError(e.message);
|
||||||
return Promise.reject(e);
|
return Promise.reject(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onError(err: any) {
|
async function onError(err: any, method?: Method<any>) {
|
||||||
reqCount = 0;
|
reqCount = 0;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
onLoadingEnd();
|
onLoadingEnd();
|
||||||
|
|
||||||
const msg = err?.message || "网络请求出错";
|
const msg = err?.message || "网络请求出错";
|
||||||
onHttpError(msg);
|
if (!isSilentBizToast(method)) onHttpError(msg);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createBschAlova } from "./alova-core";
|
import { createBschAlova } from "./alova-core";
|
||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import conf from "@/assets/config/host.config";
|
import conf from "@/assets/config/host.config";
|
||||||
|
import { describeFlowError } from "@/utils/flowError";
|
||||||
|
|
||||||
/* 全局 loading 遮罩实例 */
|
/* 全局 loading 遮罩实例 */
|
||||||
let spinInstance: any = null;
|
let spinInstance: any = null;
|
||||||
@@ -30,11 +31,13 @@ export const alovaInstance = createBschAlova(baseUrl, {
|
|||||||
spinInstance = Message.loading("加载中...");
|
spinInstance = Message.loading("加载中...");
|
||||||
},
|
},
|
||||||
onLoadingEnd: closeLoading,
|
onLoadingEnd: closeLoading,
|
||||||
|
/* 全局兜底提示:具体业务 msg 原样透出,通用/异常形态换成不误导的用户话术(不泄露堆栈/SQL/令牌) */
|
||||||
onBizError(msg: string) {
|
onBizError(msg: string) {
|
||||||
if (!!msg) Message.error(msg);
|
const text = describeFlowError(msg);
|
||||||
|
if (!!text) Message.error(text);
|
||||||
},
|
},
|
||||||
|
|
||||||
onHttpError(msg: string) {
|
onHttpError(msg: string) {
|
||||||
Message.error({ title: "网络错误", content: msg });
|
Message.error({ title: "请求未完成", content: describeFlowError(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,85 @@
|
|||||||
|
/**
|
||||||
|
* 托管选人 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) {
|
||||||
|
return new URL(document.referrer).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 (!msg.contextId || !msg.type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (msg.type === 'assign-confirmed') {
|
||||||
|
if (!msg.flowGuid || !msg.defVersion || !Array.isArray(msg.assigns)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接收侧(父页)校验:origin/source/形状/context 逐项核,任一不过=丢弃(不告警不打扰) */
|
||||||
|
export function isValidAssignMessage(
|
||||||
|
ev: MessageEvent,
|
||||||
|
expectedOrigin: string,
|
||||||
|
expectedSource: Window | null,
|
||||||
|
expectedContextId: string,
|
||||||
|
): AssignEmbedMessage | null {
|
||||||
|
if (expectedOrigin && ev.origin !== expectedOrigin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expectedSource && 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 || data.contextId !== expectedContextId) {
|
||||||
|
// 旧 frame / 换线后的迟到消息:按 context 失效
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildMessage(data as AssignEmbedMessage);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 服务端错误文案的可读化(FL上线独立验收报告 P0-1 附带项:审批失败只甩「内部服务错误」)。
|
||||||
|
*
|
||||||
|
* 边界:前端只做**文案呈现**,不猜原因、不绕校验、不把失败说成成功。
|
||||||
|
* 后端把某类失败收敛成具体业务码 + 人话 msg 后(如 D09「无激活凭据」),这些 msg 原样透出即可;
|
||||||
|
* 仍然只给通用占位文案(code=6 内部服务错误)或异常/SQL/令牌形态的原文时,一律换成不误导的
|
||||||
|
* 通用提示,原文不进 DOM——堆栈、SQL、Token 不外泄。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 后端/网关的通用占位文案:出现即说明没有可用诊断信息,需要换成不误导的用户话术 */
|
||||||
|
const GENERIC_HINTS = [
|
||||||
|
"内部服务错误",
|
||||||
|
"服务器错误",
|
||||||
|
"系统错误",
|
||||||
|
"系统异常",
|
||||||
|
"业务错误",
|
||||||
|
"未知错误",
|
||||||
|
"服务异常",
|
||||||
|
"请求失败",
|
||||||
|
"null",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 明显是异常原文而不是用户文案的形态:关键字命中即按通用提示处理,不回显原文 */
|
||||||
|
const INTERNAL_SHAPES = [
|
||||||
|
/exception/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 封装把非 200 与 fetch 失败都收敛成这几句 */
|
||||||
|
const NETWORK_SHAPES = [/^网络错误/, /网络请求出错/, /failed to fetch/i, /fetch failed/i, /network/i, /timeout/i];
|
||||||
|
|
||||||
|
export const FLOW_GENERIC_FALLBACK =
|
||||||
|
"服务未能处理本次请求,操作没有生效。可稍后重试;多次失败请联系流程管理员,并提供操作时间。";
|
||||||
|
|
||||||
|
export const FLOW_NETWORK_FALLBACK = "网络连接异常,或登录状态已失效,请刷新页面后重试。";
|
||||||
|
|
||||||
|
function looksGeneric(raw: string): boolean {
|
||||||
|
const t = raw.trim();
|
||||||
|
if (!t) return true;
|
||||||
|
if (GENERIC_HINTS.some((g) => t === g || t === `${g}!` || t === `${g}!`)) return true;
|
||||||
|
return INTERNAL_SHAPES.some((re) => re.test(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksNetwork(raw: string): boolean {
|
||||||
|
return NETWORK_SHAPES.some((re) => re.test(raw.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务失败 → 用户可读文案。
|
||||||
|
* 已在契约里的具体业务 msg(如「流程线不存在:xxx」「该任务已被办理」)原样返回,不做改写;
|
||||||
|
* 通用/异常形态返回兜底话术。
|
||||||
|
*/
|
||||||
|
export function describeFlowError(raw: unknown): string {
|
||||||
|
const text = typeof raw === "string"
|
||||||
|
? raw
|
||||||
|
: raw && typeof raw === "object" && "message" in raw
|
||||||
|
? String((raw as { message?: unknown }).message ?? "")
|
||||||
|
: "";
|
||||||
|
if (looksNetwork(text)) return FLOW_NETWORK_FALLBACK;
|
||||||
|
if (looksGeneric(text)) return FLOW_GENERIC_FALLBACK;
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面内联提示用:动作名 + 可读原因 + 下一步(不重复弹全局 toast) */
|
||||||
|
export function flowFailTip(action: string, raw: unknown): string {
|
||||||
|
return `${action}未完成:${describeFlowError(raw)}`;
|
||||||
|
}
|
||||||
@@ -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,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,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>
|
||||||
@@ -60,9 +60,14 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="embed-page" v-if="detail">
|
<div class="embed-page" v-if="detail">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
|
<div class="head-row">
|
||||||
<span class="title">{{ detail.bizTitle }}</span>
|
<span class="title">{{ detail.bizTitle }}</span>
|
||||||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 流程简略信息(detail.nodeSummary=APPROVAL 节点名串联,后端早已下发):
|
||||||
|
不展开"流程示意图"也能一眼看清走向,口径同 Notice 详情页的"流程:"摘要行 -->
|
||||||
|
<div class="head-flow" v-if="detail.nodeSummary">流程:{{ detail.nodeSummary }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<InstanceBody :detail="detail" :diagram-src="diagramSrc" />
|
<InstanceBody :detail="detail" :diagram-src="diagramSrc" />
|
||||||
</div>
|
</div>
|
||||||
@@ -78,9 +83,10 @@ onMounted(async () => {
|
|||||||
/* 标题区吸顶(贴合设计基线):左右负外边距让白色背景铺满滚动容器,滚动时内容不从两侧留白露出 */
|
/* 标题区吸顶(贴合设计基线):左右负外边距让白色背景铺满滚动容器,滚动时内容不从两侧留白露出 */
|
||||||
.head {
|
.head {
|
||||||
position: sticky; top: 0; z-index: 5; background: #fff;
|
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;
|
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; }
|
.title { font-size: 16px; font-weight: 600; flex: 1; min-width: 0; }
|
||||||
.muted { color: #999; }
|
.muted { color: #999; }
|
||||||
.expired { padding: 48px 16px; text-align: center; }
|
.expired { padding: 48px 16px; text-align: center; }
|
||||||
|
|||||||
+260
-53
@@ -1,17 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
|
// 办理页(概览 §9.4):biz_snapshot 业务信息区块(只认 summaryFields)+ "查看业务详情"跳 bizUrl
|
||||||
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
|
// + 历史意见 + 同意/拒绝/退回/移交。业务表单数据(formDataJson)不在办理页渲染——托管模式下它是
|
||||||
|
// 条件路由入参,只进管理面办件详情。
|
||||||
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
|
// 一切审批入口的收敛点都是后端同一套 /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 { useRoute, useRouter } from "vue-router";
|
||||||
import { Message, Modal } from "view-ui-plus";
|
import { Message, Modal } from "view-ui-plus";
|
||||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||||
|
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
|
||||||
|
import { toAppHashUrl } from "@/utils/appUrl";
|
||||||
|
import { flowFailTip } from "@/utils/flowError";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const taskId = computed(() => String(route.params.taskId || ""));
|
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=按人员(不传=人员),与办件详情页、图示页同参数 */
|
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
|
||||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||||
|
|
||||||
@@ -37,6 +52,18 @@ const showTransfer = ref(false);
|
|||||||
const transferUser = ref("");
|
const transferUser = ref("");
|
||||||
const transferName = 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 snapshotRows = computed(() => {
|
||||||
const raw = detail.value?.bizSnapshot;
|
const raw = detail.value?.bizSnapshot;
|
||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
@@ -46,28 +73,25 @@ const snapshotRows = computed(() => {
|
|||||||
} catch {
|
} catch {
|
||||||
return [{ k: "摘要", v: String(raw) }];
|
return [{ k: "摘要", v: String(raw) }];
|
||||||
}
|
}
|
||||||
if (obj && typeof obj === "object") {
|
if (!obj || typeof obj !== "object") return [];
|
||||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
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 [];
|
return SUMMARY_FALLBACK_LABELS
|
||||||
});
|
.filter(([key]) => obj[key] !== null && obj[key] !== undefined && obj[key] !== "")
|
||||||
|
.map(([key, label]) => ({ k: label, v: formValueText(obj[key]) }));
|
||||||
/** 业务表单数据(§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) }];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 值 → 展示文本(业务信息回退行共用):数组按文件名/项连接,对象降级 JSON */
|
||||||
function formValueText(v: any): string {
|
function formValueText(v: any): string {
|
||||||
if (v === null || v === undefined) return "-";
|
if (v === null || v === undefined) return "-";
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
@@ -90,10 +114,53 @@ 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() {
|
function openBizUrl() {
|
||||||
if (detail.value?.bizUrl) {
|
if (isBare.value) {
|
||||||
window.open(detail.value.bizUrl, "_blank");
|
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) {
|
||||||
|
if (!isBare.value) {
|
||||||
|
router.push("/workbench");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postToParent({ type: "handled", taskId: taskId.value, action });
|
||||||
|
doneTip.value = tip;
|
||||||
|
opinion.value = "";
|
||||||
|
chosenEdge.value = "";
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(approved: boolean) {
|
async function submit(approved: boolean) {
|
||||||
@@ -102,6 +169,8 @@ async function submit(approved: boolean) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
failTip.value = "";
|
||||||
|
const label = approved ? "同意" : "拒绝";
|
||||||
try {
|
try {
|
||||||
if (approved) {
|
if (approved) {
|
||||||
// 多出口节点必须带所选出口
|
// 多出口节点必须带所选出口
|
||||||
@@ -113,27 +182,47 @@ async function submit(approved: boolean) {
|
|||||||
}
|
}
|
||||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
||||||
Message.success("已同意");
|
Message.success("已同意");
|
||||||
|
await afterHandled("approve", "已同意,本任务办理完成");
|
||||||
} else {
|
} else {
|
||||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||||
Message.success("已拒绝");
|
Message.success("已拒绝");
|
||||||
|
await afterHandled("reject", "已拒绝,本任务办理完成");
|
||||||
}
|
}
|
||||||
router.push("/workbench");
|
} catch (e: any) {
|
||||||
|
// 失败就是失败:不跳走、不清意见、不写成功文案;服务端事务已回滚,任务仍是待办
|
||||||
|
failTip.value = flowFailTip(label, e);
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二次确认:独立打开用 Modal.confirm(原生体验);bare 态一律行内确认。
|
||||||
|
* iframe 内弹 Modal 会在父页面弹窗之上再叠一层遮罩——父页遮罩盖不到 iframe,视觉上是"弹窗套弹窗",
|
||||||
|
* 且弹出位置只在本页顶部(长表单下离按钮很远)。行内确认条没有这个问题。
|
||||||
|
*/
|
||||||
|
const confirming = ref(false);
|
||||||
|
|
||||||
function confirmApprove() {
|
function confirmApprove() {
|
||||||
if (outlets.value.length > 1 && !chosenEdge.value) {
|
if (outlets.value.length > 1 && !chosenEdge.value) {
|
||||||
Message.warning("该节点有多个办理去向,请先选择");
|
Message.warning("该节点有多个办理去向,请先选择");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isBare.value) {
|
||||||
|
confirming.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: "同意该任务?",
|
title: "同意该任务?",
|
||||||
onOk: () => submit(true),
|
onOk: () => submit(true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function doApprove() {
|
||||||
|
confirming.value = false;
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 退回 =====
|
// ===== 退回 =====
|
||||||
function openReturn() {
|
function openReturn() {
|
||||||
const targets = detail.value?.returnTargets || [];
|
const targets = detail.value?.returnTargets || [];
|
||||||
@@ -143,16 +232,18 @@ function openReturn() {
|
|||||||
|
|
||||||
function doReturn() {
|
function doReturn() {
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
failTip.value = "";
|
||||||
const targets = detail.value?.returnTargets || [];
|
const targets = detail.value?.returnTargets || [];
|
||||||
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
|
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
|
||||||
targets[targets.length - 1] || null;
|
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)
|
||||||
.send()
|
.send()
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
Message.success("已退回");
|
Message.success("已退回");
|
||||||
showReturn.value = false;
|
showReturn.value = false;
|
||||||
router.push("/workbench");
|
await afterHandled("return", "已退回,目标节点重新生成待办");
|
||||||
})
|
})
|
||||||
|
.catch((e: any) => { failTip.value = flowFailTip("退回", e); })
|
||||||
.finally(() => { submitting.value = false; });
|
.finally(() => { submitting.value = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,22 +255,63 @@ async function doTransfer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
|
failTip.value = "";
|
||||||
try {
|
try {
|
||||||
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
|
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
|
||||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
||||||
Message.success("已移交");
|
Message.success("已移交");
|
||||||
showTransfer.value = false;
|
showTransfer.value = false;
|
||||||
router.push("/workbench");
|
await afterHandled("transfer", "已移交,本任务由接手人办理");
|
||||||
|
} catch (e: any) {
|
||||||
|
failTip.value = flowFailTip("移交", e);
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="head">
|
||||||
<span class="title">{{ detail.bizTitle }}</span>
|
<span class="title">{{ detail.bizTitle }}</span>
|
||||||
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
||||||
@@ -189,31 +321,24 @@ onMounted(load);
|
|||||||
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
|
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 业务摘要区块(M1 办理页核心;富文本正文留在业务方,经 bizUrl 查看) -->
|
<!-- 业务信息区块(M1 办理页核心):只渲染子应用给的 summaryFields 中文标签行;
|
||||||
|
富文本正文留在业务方,经 bizUrl 查看 -->
|
||||||
<Card class="block" :bordered="false" dis-hover>
|
<Card class="block" :bordered="false" dis-hover>
|
||||||
<template #title>业务摘要</template>
|
<template #title>业务信息</template>
|
||||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供摘要)</div>
|
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供业务信息)</div>
|
||||||
<div v-else class="snapshot">
|
<div v-else class="snapshot">
|
||||||
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
||||||
<span class="k">{{ row.k }}</span>
|
<span class="k">{{ row.k }}</span>
|
||||||
<span class="v">{{ row.v }}</span>
|
<span class="v">{{ row.v }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 业务表单数据(§3.4 只读渲染,非表单引擎;无 formData 时整块隐藏) -->
|
<!-- 业务表单数据不在此渲染(§3.4):formDataJson 是托管模式的条件路由入参(deptId/deptName 之类),
|
||||||
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
|
对办理人是内部噪音;原始表单只在管理面办件详情(InstanceBody full)看 -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 办理区 -->
|
<!-- 办理区 -->
|
||||||
<Card class="block" :bordered="false" dis-hover>
|
<Card class="block" :bordered="false" dis-hover>
|
||||||
@@ -228,17 +353,54 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
||||||
placeholder="同意可选填意见;拒绝必须填写意见" />
|
placeholder="同意可选填意见;拒绝必须填写意见" />
|
||||||
|
<Alert v-if="failTip" type="error" show-icon class="fail-tip">
|
||||||
|
{{ failTip }}
|
||||||
|
<div class="muted">本次办理未生效,任务仍是你的待办;可稍后重试,或把页面提示与操作时间反馈给流程管理员。</div>
|
||||||
|
</Alert>
|
||||||
<div class="actions" v-if="myTaskId">
|
<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 type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||||
<Button :loading="submitting" @click="openReturn">退回</Button>
|
<Button :loading="submitting" @click="openReturn">退回</Button>
|
||||||
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
||||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</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" @click="showReturn = false">取消</Button>
|
||||||
|
</div>
|
||||||
|
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</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>
|
||||||
|
|
||||||
<!-- 流程轨迹 -->
|
<!-- 流程轨迹(独立打开):中心侧唯一上下文,常显 -->
|
||||||
<Card class="block" :bordered="false" dis-hover>
|
<Card v-if="!isBare" class="block" :bordered="false" dis-hover>
|
||||||
<template #title>
|
<template #title>
|
||||||
流程轨迹
|
流程轨迹
|
||||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||||
@@ -247,12 +409,30 @@ onMounted(load);
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</template>
|
</template>
|
||||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||||
<Button size="small" ghost type="primary"
|
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||||
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 退回弹窗 -->
|
<!-- 流程轨迹(bare 态):父页面已有"审批进度"卡片,同一份 trace 不再整块铺开——
|
||||||
<Modal v-model="showReturn" title="退回" :width="480">
|
默认收起,需要核对明细(路由判断/抄送/退回留痕)时再展开 -->
|
||||||
|
<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">
|
<div class="modal-row">
|
||||||
<span class="modal-label">退回至:</span>
|
<span class="modal-label">退回至:</span>
|
||||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||||
@@ -269,8 +449,8 @@ onMounted(load);
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- 移交弹窗 -->
|
<!-- 移交弹窗(独立打开;bare 态走行内面板) -->
|
||||||
<Modal v-model="showTransfer" title="移交" :width="480">
|
<Modal v-if="!isBare" v-model="showTransfer" title="移交" :width="480">
|
||||||
<div class="modal-row">
|
<div class="modal-row">
|
||||||
<span class="modal-label">移交对象工号:</span>
|
<span class="modal-label">移交对象工号:</span>
|
||||||
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
||||||
@@ -287,6 +467,11 @@ onMounted(load);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { padding: 12px 16px; }
|
.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; }
|
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
.title { font-size: 16px; font-weight: 600; }
|
.title { font-size: 16px; font-weight: 600; }
|
||||||
.block { margin-bottom: 12px; }
|
.block { margin-bottom: 12px; }
|
||||||
@@ -294,7 +479,15 @@ onMounted(load);
|
|||||||
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
|
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
|
||||||
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
|
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
|
||||||
.biz-link { margin-top: 8px; }
|
.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; }
|
.muted { color: #999; }
|
||||||
.outlet { margin-bottom: 8px; display: flex; align-items: center; }
|
.outlet { margin-bottom: 8px; display: flex; align-items: center; }
|
||||||
.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; }
|
.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; }
|
||||||
@@ -305,4 +498,18 @@ onMounted(load);
|
|||||||
.opinion { color: #666; }
|
.opinion { color: #666; }
|
||||||
.time { color: #aaa; font-size: 12px; }
|
.time { color: #aaa; font-size: 12px; }
|
||||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单 + 学校池同步 + 开关);
|
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单 + 学校池同步 + 开关);
|
||||||
// tab2 科室管理(flw_dept 组+成员模型,取代旧「办理人映射」页——deptKey=本地自增 id,
|
// tab2 科室管理(flw_dept 组+成员模型,取代旧「办理人映射」页——deptKey=本地自增 id,
|
||||||
// 切源后旧流程定义引用的 UserBase 组 id 需重建科室后重新配置)。
|
// 切源后旧流程定义引用的 UserBase 组 id 需重建科室后重新配置)。
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { Message, Tag } from "view-ui-plus";
|
import { useRouter } from "vue-router";
|
||||||
|
import { Message, Tag, Button } from "view-ui-plus";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
@@ -11,6 +12,7 @@ import { actionColumn } from "@/utils/tableActions";
|
|||||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||||
|
|
||||||
const tab = ref("apps");
|
const tab = ref("apps");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// ==================== tab1 应用管理 ====================
|
// ==================== tab1 应用管理 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -343,6 +345,102 @@ onMounted(() => {
|
|||||||
load();
|
load();
|
||||||
loadDepts();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -351,6 +449,7 @@ onMounted(() => {
|
|||||||
<Tabs class="tabs-panel" v-model="tab">
|
<Tabs class="tabs-panel" v-model="tab">
|
||||||
<TabPane label="应用管理" name="apps"></TabPane>
|
<TabPane label="应用管理" name="apps"></TabPane>
|
||||||
<TabPane label="科室管理" name="depts"></TabPane>
|
<TabPane label="科室管理" name="depts"></TabPane>
|
||||||
|
<TabPane label="对接健康度" name="health"></TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- 应用管理 -->
|
<!-- 应用管理 -->
|
||||||
@@ -385,6 +484,21 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Modal v-model="createVisible" title="新建科室" :mask-closable="false">
|
||||||
<Input v-model="createName" placeholder="科室名称,如 人事处" :maxlength="30" />
|
<Input v-model="createName" placeholder="科室名称,如 人事处" :maxlength="30" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -432,3 +546,27 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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,6 +2,7 @@
|
|||||||
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
|
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
|
||||||
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
|
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
import { Message, Tag } from "view-ui-plus";
|
import { Message, Tag } from "view-ui-plus";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||||
@@ -15,6 +16,7 @@ const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled
|
|||||||
|
|
||||||
const tab = ref("regs");
|
const tab = ref("regs");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
// 无分页 tab 的表格高度:实测各自 .table-region(flex:1),替代 useTableHeight 常量减法
|
// 无分页 tab 的表格高度:实测各自 .table-region(flex:1),替代 useTableHeight 常量减法
|
||||||
const regsRegionRef = ref<HTMLElement | null>(null);
|
const regsRegionRef = ref<HTMLElement | null>(null);
|
||||||
@@ -24,6 +26,13 @@ const hungH = useElementHeight(hungRegionRef);
|
|||||||
|
|
||||||
// ---- 注册列表 ----
|
// ---- 注册列表 ----
|
||||||
const regs = ref<any[]>([]);
|
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 = [
|
const regColumns = [
|
||||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
{ title: "应用 appKey", key: "appKey", width: 110 },
|
{ title: "应用 appKey", key: "appKey", width: 110 },
|
||||||
@@ -208,7 +217,15 @@ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -222,8 +239,15 @@ onMounted(loadRegs);
|
|||||||
|
|
||||||
<!-- 回调注册:无分页 -->
|
<!-- 回调注册:无分页 -->
|
||||||
<div class="tab-body" v-show="tab === 'regs'">
|
<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">
|
<div class="table-region" ref="regsRegionRef">
|
||||||
<Table :columns="regColumns" :data="regs" :loading="loading" class="link-table table-bottom-box"
|
<Table :columns="regColumns" :data="filteredRegs()" :loading="loading" class="link-table table-bottom-box"
|
||||||
:height="regsH || undefined" no-data-text="暂无数据" />
|
:height="regsH || undefined" no-data-text="暂无数据" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+189
-32
@@ -4,11 +4,12 @@
|
|||||||
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
|
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { Message, Table, Tag } from "view-ui-plus";
|
import { Message, Table, Tag, Tooltip } from "view-ui-plus";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
import { actionColumn } from "@/utils/tableActions";
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
import { lineActionErrorDetail, runLineAction } from "@/utils/lineOffline";
|
||||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,6 +17,8 @@ const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const rows = ref<any[]>([]);
|
const rows = ref<any[]>([]);
|
||||||
|
// 线级启停在途标记(按 flowGuid):确认后的请求期间禁用入口并展示「处理中」
|
||||||
|
const lineBusyGuid = ref("");
|
||||||
const versionVisible = ref(false);
|
const versionVisible = ref(false);
|
||||||
const versions = ref<any[]>([]);
|
const versions = ref<any[]>([]);
|
||||||
// 源码抽屉(列表行已带回 contentJson,无需再请求)
|
// 源码抽屉(列表行已带回 contentJson,无需再请求)
|
||||||
@@ -61,8 +64,47 @@ const renderStatus = (h: any, p: any) => {
|
|||||||
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
|
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 行操作集(平铺态、分组展开行同一套;禁用规则按 status) */
|
/** 流程线级停发状态(T03):与版本 status 正交,必须独立展示,不能混读成“发布/停用” */
|
||||||
const rowActions = [
|
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)),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 固定宽文本列 + 省略号 + 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: "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: "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 },
|
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
|
||||||
@@ -78,15 +120,25 @@ const rowActions = [
|
|||||||
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(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) },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 平铺态(每行=一个版本)同时提供线级入口;展开的版本子表仍只用版本级操作,避免误操作整线 */
|
||||||
|
const flatActions = [...versionActions, ...lineActions];
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
{ title: "流程", key: "name", minWidth: 180 },
|
{ title: "流程", key: "name", minWidth: 180 },
|
||||||
{ title: "编码", key: "code", width: 160 },
|
{ title: "编码", key: "code", width: 200 },
|
||||||
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
||||||
{ title: "版本", key: "version", width: 70, align: "center" },
|
{ title: "版本", key: "version", width: 70, align: "center" },
|
||||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||||
{ title: "GUID", key: "flowGuid", width: 240, render: (h: any, p: any) => {
|
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||||
// 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁
|
{ title: "GUID", key: "flowGuid", width: 320, render: (h: any, p: any) => {
|
||||||
|
// 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁;列宽同分组态,保证 32 位 guid 单行全显
|
||||||
const n = inflightMap.value[p.row.flowGuid];
|
const n = inflightMap.value[p.row.flowGuid];
|
||||||
return h("span", {}, [
|
return h("span", {}, [
|
||||||
p.row.flowGuid,
|
p.row.flowGuid,
|
||||||
@@ -94,7 +146,7 @@ const columns = [
|
|||||||
]);
|
]);
|
||||||
} },
|
} },
|
||||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
||||||
actionColumn(rowActions, { mode: "text", width: 180, fixed: "right" }),
|
actionColumn(flatActions, { mode: "text", width: 180, fixed: "right" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -146,6 +198,8 @@ const groups = computed(() => {
|
|||||||
return {
|
return {
|
||||||
_key: g, flowGuid: g, id: cur.id, name: cur.name, code: cur.code, appKey: cur.appKey,
|
_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,
|
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,
|
total: vs.length, hasPublished: published.length > 0,
|
||||||
versions: vs, _expanded: !!openMap.value[g],
|
versions: vs, _expanded: !!openMap.value[g],
|
||||||
};
|
};
|
||||||
@@ -156,60 +210,66 @@ const groups = computed(() => {
|
|||||||
out.push({
|
out.push({
|
||||||
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
|
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
|
||||||
version: r.version, status: r.status, publishTime: r.publishTime,
|
version: r.version, status: r.status, publishTime: r.publishTime,
|
||||||
|
lineOffline: 0,
|
||||||
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
|
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作复用 rowActions)。
|
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作只用版本级 versionActions)。
|
||||||
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
|
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
|
||||||
const versionColumns = [
|
const versionColumns = [
|
||||||
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
|
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
|
||||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||||
{ title: "创建人", width: 100, render: (h: any, p: any) => h("span", p.row.createBy || "-") },
|
{ title: "创建人", width: 110, render: (h: any, p: any) => h("span", p.row.createBy || "-") },
|
||||||
{ title: "创建时间", width: 140, render: (h: any, p: any) => h("span", fmtTime(p.row.createTime)) },
|
// 创建时间「2026-10-13 23:44」实测行盒 2(折行)→ 取 160,与主表「发布时间」同宽(同文案已证单行)
|
||||||
{ title: "改动说明", minWidth: 230, render: (h: any, p: any) => (p.row.changeLog
|
{ title: "创建时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.createTime)) },
|
||||||
? h("span", p.row.changeLog)
|
// 改动说明:固定窄列 + 省略号 + 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" }, "(未填写)")) },
|
: h("span", { style: "color:#999" }, "(未填写)")) },
|
||||||
actionColumn(rowActions, { mode: "text", width: 210 }),
|
actionColumn(versionActions, { mode: "text", width: 210 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 子表各列声明宽之和:作为内层 Table 的 width 传下去,避免 iView 把展开行剩余空间摊到各列
|
||||||
|
* (否则「改动说明」会被撑回数百像素,等于没压短),同时让子表右端不再压到固定条带下方。 */
|
||||||
|
const VERSIONS_TABLE_WIDTH = 70 + 90 + 110 + 160 + 260 + 210;
|
||||||
|
|
||||||
const groupColumns = [
|
const groupColumns = [
|
||||||
{ type: "expand", width: 40, render: (h: any, p: any) => h(Table, {
|
{ type: "expand", width: 40, render: (h: any, p: any) => h(Table, {
|
||||||
columns: versionColumns, data: p.row.versions, size: "small", noDataText: "暂无数据",
|
columns: versionColumns, data: p.row.versions, size: "small", noDataText: "暂无数据",
|
||||||
|
width: VERSIONS_TABLE_WIDTH,
|
||||||
}) },
|
}) },
|
||||||
{ title: "序号", type: "index", width: 60, align: "center" },
|
{ title: "序号", type: "index", width: 60, align: "center" },
|
||||||
{ title: "流程", minWidth: 260, render: (h: any, p: any) => {
|
// 标题列固定宽 + 超长省略 + Tooltip;生效/在途 徽标已拆到独立列(2026-10-14)
|
||||||
const n = inflightMap.value[p.row.flowGuid];
|
{ title: "流程", width: 260, ellipsis: true, render: ellipsisCell("name", "b") },
|
||||||
return h("span", {}, [
|
{ title: "生效/在途", width: 240, align: "center", render: renderRunState },
|
||||||
h("b", p.row.name),
|
// 编码实测单行 155px + 内边距 24 → 160 会折行,故 190
|
||||||
h(Tag, { color: p.row.hasPublished ? "green" : "default", style: "margin-left:6px" },
|
{ title: "编码", key: "code", width: 200 },
|
||||||
() => (p.row.hasPublished ? `当前生效 v${p.row.version}` : "无生效版")),
|
|
||||||
h(Tag, { color: n ? "blue" : "default", style: "margin-left:6px" }, () => inflightText(p.row.flowGuid)),
|
|
||||||
]);
|
|
||||||
} },
|
|
||||||
{ title: "编码", key: "code", width: 160 },
|
|
||||||
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
||||||
{ title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) },
|
{ 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: 90, align: "center", render: renderStatus },
|
||||||
{ title: "GUID", width: 130, render: (h: any, p: any) => (p.row.flowGuid
|
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||||
? h("span", { title: p.row.flowGuid }, `${String(p.row.flowGuid).slice(0, 8)}…`)
|
// 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" }, "—")) },
|
: h("span", { style: "color:#999" }, "—")) },
|
||||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
||||||
actionColumn(
|
actionColumn(
|
||||||
[
|
[
|
||||||
|
...lineActions,
|
||||||
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
|
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
|
||||||
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
|
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
|
||||||
],
|
],
|
||||||
{ mode: "text", width: 140, fixed: "right" },
|
{ mode: "text", width: 260, fixed: "right" },
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 分组态点行展开/收起(展开箭头同一路径:箭头内部态会被下一帧的 _expanded 重新校准) */
|
/** 分组态展开/收起:只由左侧箭头触发(Table 的 on-expand),行内任意位置点击不再切换 */
|
||||||
function onGroupRowClick(row: any) {
|
function onGroupExpand(row?: any, status?: boolean) {
|
||||||
if (row && row._key) {
|
if (row && row._key) {
|
||||||
openMap.value[row._key] = !openMap.value[row._key];
|
openMap.value[row._key] = !!status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +348,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 返回全量实体),直接开抽屉,无需再拉接口 */
|
/** 源码:列表行已带回 contentJson(defList 返回全量实体),直接开抽屉,无需再拉接口 */
|
||||||
function onSource(row: any) {
|
function onSource(row: any) {
|
||||||
sourceText.value = prettyJson(row.contentJson);
|
sourceText.value = prettyJson(row.contentJson);
|
||||||
@@ -387,15 +544,15 @@ onMounted(() => {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
|
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
|
||||||
{{ viewMode === "group"
|
{{ viewMode === "group"
|
||||||
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明"
|
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立"
|
||||||
: "平铺所有版本行(现状口径)" }}
|
: "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-region" ref="defsRegionRef">
|
<div class="table-region" ref="defsRegionRef">
|
||||||
<Table v-if="viewMode === 'group'" :columns="groupColumns" :data="groups" :loading="loading"
|
<Table v-if="viewMode === 'group'" :columns="groupColumns" :data="groups" :loading="loading"
|
||||||
class="link-table table-bottom-box" :height="defsH || undefined" no-data-text="暂无数据"
|
class="link-table table-bottom-box" :height="defsH || undefined" no-data-text="暂无数据"
|
||||||
@on-row-click="onGroupRowClick" />
|
@on-expand="onGroupExpand" />
|
||||||
<Table v-else :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
<Table v-else :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||||
:height="defsH || undefined" no-data-text="暂无数据" />
|
:height="defsH || undefined" no-data-text="暂无数据" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// 办件记录(管理端):全校办件查询;CONDITION 判断失败挂起的办件在此"重跑路由"恢复
|
// 办件记录(管理端):全校办件查询;CONDITION 判断失败挂起的办件在此"重跑路由"恢复
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { Message, Tag } from "view-ui-plus";
|
import { Message, Tag } from "view-ui-plus";
|
||||||
import MyConfirm from "@/components/MyConfirm";
|
|
||||||
import { OperatorEnum } from "@/types";
|
import { OperatorEnum } from "@/types";
|
||||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||||
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
|
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
|
||||||
@@ -10,7 +9,8 @@ import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/fl
|
|||||||
import { actionColumn } from "@/utils/tableActions";
|
import { actionColumn } from "@/utils/tableActions";
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
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 drawerVisible = ref(false);
|
||||||
const drawerInstanceId = ref<string | number>("");
|
const drawerInstanceId = ref<string | number>("");
|
||||||
|
|
||||||
@@ -18,8 +18,11 @@ const columns = [
|
|||||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||||
{ title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) },
|
{ 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) =>
|
{ 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) },
|
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: "提交人", key: "submitterName", width: 100 },
|
||||||
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||||
actionColumn(
|
actionColumn(
|
||||||
@@ -37,24 +40,36 @@ function openDetail(instanceId: string | number) {
|
|||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重跑路由(2026-09-24 §4.4):重新调判断服务,成功即按出口续走;仍失败回业务错(办件继续挂起) */
|
/** 重跑路由(2026-09-24 §4.4 + F5-4 逃生门):确认框带「按当前已发布定义重跑」复选——
|
||||||
|
* 僵尸办件(挂起时的表达式已修好并发布为新版)勾选后按新版续走,否则按落库快照重跑 */
|
||||||
|
const rerouteVisible = ref(false);
|
||||||
|
const rerouteUseLatest = ref(false);
|
||||||
|
const rerouteRow = ref<any>(null);
|
||||||
|
|
||||||
function onReroute(row: any) {
|
function onReroute(row: any) {
|
||||||
MyConfirm({
|
rerouteRow.value = row;
|
||||||
title: "重跑路由",
|
rerouteUseLatest.value = false;
|
||||||
msg: `确定对办件「${row.bizTitle || row.id}」重跑条件路由判断吗?`,
|
rerouteVisible.value = true;
|
||||||
info: "仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)",
|
}
|
||||||
async onOk() {
|
|
||||||
await flowApi.instanceReroute(row.id).send();
|
async function confirmReroute() {
|
||||||
Message.success("路由已重跑,流程按新判断结果继续");
|
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);
|
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||||
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
||||||
const params: any = { page: p.pageIndex, size: p.pageSize };
|
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();
|
if (dataParams.value.keyword) params.keyword = dataParams.value.keyword.trim();
|
||||||
return flowApi.adminInstances(params);
|
return flowApi.adminInstances(params);
|
||||||
};
|
};
|
||||||
@@ -73,6 +88,7 @@ function onSearch() {
|
|||||||
<Option :value="20" label="已完成" />
|
<Option :value="20" label="已完成" />
|
||||||
<Option :value="30" label="已拒绝" />
|
<Option :value="30" label="已拒绝" />
|
||||||
<Option :value="40" label="已撤回" />
|
<Option :value="40" label="已撤回" />
|
||||||
|
<Option value="hung" label="路由挂起" />
|
||||||
</Select>
|
</Select>
|
||||||
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
|
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
|
||||||
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
|
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
|
||||||
@@ -80,5 +96,10 @@ function onSearch() {
|
|||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
<InstanceDrawer v-model="drawerVisible" :instance-id="drawerInstanceId" />
|
<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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 审批节点「预览候选人」(FL上线独立验收报告 P2-1:/assign-preview 页存在但编辑器内无入口)。
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - 只读。解析当前抽屉里的**草稿配置**,不写回节点、不改流程定义、不建实例。
|
||||||
|
* - 数据全部来自既有接口(科室成员 / UserBase 用户检索 / 发起预览),前端不重实现人员解析:
|
||||||
|
* DEPT_KEY 读的是后端 AssigneeResolver 同一张 flw_dept_member,USERS 读的是 UserBase byId。
|
||||||
|
* - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。
|
||||||
|
*/
|
||||||
|
import { describeFlowError } from "@/utils/flowError";
|
||||||
|
|
||||||
|
export type PreviewAssigneeType = "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;
|
||||||
|
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[]>;
|
||||||
|
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 : "该成员已停用,不计入候选",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已发布版本对账:只有本线已保存(有 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_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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { computed, inject, reactive, ref, watch } from "vue";
|
|||||||
import { Message } from "view-ui-plus";
|
import { Message } from "view-ui-plus";
|
||||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
|
import AssigneePreviewModal from "./AssigneePreviewModal.vue";
|
||||||
|
import type { PreviewNodeConfig } from "../assignPreview";
|
||||||
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -29,6 +31,11 @@ const form = reactive({
|
|||||||
countersign: "ANY" as "ALL" | "ANY",
|
countersign: "ANY" as "ALL" | "ANY",
|
||||||
deadlineHours: null as number | null,
|
deadlineHours: null as number | null,
|
||||||
multiOutlet: false,
|
multiOutlet: false,
|
||||||
|
assignEnabled: false,
|
||||||
|
assignMode: "MULTI" as "SINGLE" | "MULTI",
|
||||||
|
assignMin: 1,
|
||||||
|
assignMax: null as number | null,
|
||||||
|
assignRequired: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userCheckModal = ref<any>();
|
const userCheckModal = ref<any>();
|
||||||
@@ -49,6 +56,11 @@ function syncFromNode(): void {
|
|||||||
form.countersign = n.countersign;
|
form.countersign = n.countersign;
|
||||||
form.deadlineHours = n.deadlineHours;
|
form.deadlineHours = n.deadlineHours;
|
||||||
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -104,6 +116,49 @@ function removeUser(id: number): void {
|
|||||||
if (i >= 0) form.userIds.splice(i, 1);
|
if (i >= 0) form.userIds.splice(i, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 高级设置分层(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,
|
||||||
|
type: form.type,
|
||||||
|
deptKey: form.deptKey,
|
||||||
|
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 {
|
||||||
|
previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid);
|
||||||
|
}
|
||||||
|
|
||||||
function onOk(): void {
|
function onOk(): void {
|
||||||
const n = props.node;
|
const n = props.node;
|
||||||
if (!n) {
|
if (!n) {
|
||||||
@@ -127,6 +182,13 @@ function onOk(): void {
|
|||||||
n.outlets = ["同意", "退回"];
|
n.outlets = ["同意", "退回"];
|
||||||
n.branches = [[createApproval()], [createApproval()]];
|
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;
|
||||||
emit("update:modelValue", false);
|
emit("update:modelValue", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +251,20 @@ function onRemove(): void {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) -->
|
||||||
|
<div class="fld">
|
||||||
|
<Button size="small" type="primary" ghost @click="onPreviewCandidates">预览候选人</Button>
|
||||||
|
<div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div>
|
||||||
|
</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">
|
<div class="fld">
|
||||||
<label>多人审批方式</label>
|
<label>多人审批方式</label>
|
||||||
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
|
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
|
||||||
@@ -200,6 +276,32 @@ function onRemove(): void {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="fld">
|
||||||
<label>办理时限(小时)</label>
|
<label>办理时限(小时)</label>
|
||||||
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
|
||||||
@@ -217,6 +319,7 @@ function onRemove(): void {
|
|||||||
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
|
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div class="dw-foot">
|
<div class="dw-foot">
|
||||||
@@ -235,6 +338,8 @@ function onRemove(): void {
|
|||||||
:userids="form.userIds"
|
:userids="form.userIds"
|
||||||
@on-submit="onUsersPicked"
|
@on-submit="onUsersPicked"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AssigneePreviewModal ref="previewModal" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -259,6 +364,32 @@ function onRemove(): void {
|
|||||||
.hint.warn {
|
.hint.warn {
|
||||||
color: #ff9900;
|
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 {
|
.user-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<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[]>,
|
||||||
|
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>
|
||||||
@@ -3,8 +3,16 @@
|
|||||||
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
|
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
|
||||||
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
|
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
|
||||||
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
|
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
|
||||||
import { computed, inject, ref } from "vue";
|
import { computed, inject, nextTick, ref } from "vue";
|
||||||
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
|
import {
|
||||||
|
BRANCH_LABELS,
|
||||||
|
EDITOR_CTX,
|
||||||
|
EXPR_BUILTIN_SET,
|
||||||
|
ConditionNode,
|
||||||
|
MAX_BRANCHES,
|
||||||
|
MIN_BRANCHES,
|
||||||
|
extractExprVars,
|
||||||
|
} from "../model";
|
||||||
import NodeList from "./NodeList.vue";
|
import NodeList from "./NodeList.vue";
|
||||||
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
|
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
|
||||||
|
|
||||||
@@ -45,6 +53,38 @@ function openTry(i: number): void {
|
|||||||
tryExpr.value = (props.node.conditions || [])[i] || "";
|
tryExpr.value = (props.node.conditions || [])[i] || "";
|
||||||
tryVisible.value = true;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,6 +121,7 @@ function openTry(i: number): void {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="expression" class="cb-cond-row">
|
<div v-if="expression" class="cb-cond-row">
|
||||||
<input
|
<input
|
||||||
|
:ref="(el) => setCondInput(el, i)"
|
||||||
class="cb-cond"
|
class="cb-cond"
|
||||||
:value="(node.conditions || [])[i] || ''"
|
:value="(node.conditions || [])[i] || ''"
|
||||||
placeholder="条件表达式,如 amount > 1000"
|
placeholder="条件表达式,如 amount > 1000"
|
||||||
@@ -90,6 +131,23 @@ function openTry(i: number): void {
|
|||||||
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
|
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
|
||||||
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
|
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
|
||||||
</div>
|
</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}`" />
|
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
|
||||||
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
|
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +289,52 @@ function openTry(i: number): void {
|
|||||||
background: #f2fbf9;
|
background: #f2fbf9;
|
||||||
border-color: #0b7d72;
|
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 {
|
.cb-col-del {
|
||||||
flex: none;
|
flex: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -114,13 +114,20 @@ function onRemove(): void {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fld">
|
<div class="fld">
|
||||||
<label>判断方式</label>
|
<label>判断依据数据什么时候能拿到</label>
|
||||||
<RadioGroup :model-value="form.judgeMode" @on-change="switchJudgeMode">
|
<RadioGroup :model-value="form.judgeMode" @on-change="switchJudgeMode">
|
||||||
<Radio label="SERVICE">服务(外部判断接口)</Radio>
|
<Radio label="EXPRESSION">
|
||||||
<Radio label="EXPRESSION">表达式</Radio>
|
<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>
|
</RadioGroup>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
简单条件用表达式在编辑器内闭环;复杂判断(查库/调外部系统)沿用注册的判断服务
|
原措辞为「EXPRESSION / SERVICE」——引擎实现细节,业务管理员难以判断,选错即返工;
|
||||||
|
改按「数据何时可知」提问,配置者无需理解引擎实现
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +200,19 @@ function onRemove(): void {
|
|||||||
.hint.warn {
|
.hint.warn {
|
||||||
color: #ff9900;
|
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 {
|
.dw-foot {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: -16px;
|
bottom: -16px;
|
||||||
|
|||||||
@@ -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">
|
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
|
||||||
<div class="plus-btn" title="在此插入节点">+</div>
|
<div class="plus-btn" title="在此插入节点">+</div>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<div class="menu-group">常用节点</div>
|
||||||
<div class="menu-item" @click="pick('approval')">
|
<div class="menu-item" @click="pick('approval')">
|
||||||
<span class="mi-icon approval">审</span>
|
<span class="mi-icon approval">审</span>
|
||||||
<span class="mi-text">
|
<span class="mi-text">
|
||||||
@@ -37,6 +38,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
|||||||
<i>知会指定人,不产生待办、不阻塞流程</i>
|
<i>知会指定人,不产生待办、不阻塞流程</i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 高级结构:只是分组展示,四类插入能力一个都没少,插入后的 JSON 语义完全不变 -->
|
||||||
|
<div class="menu-group adv">高级结构</div>
|
||||||
<div class="menu-item" @click="pick('parallel')">
|
<div class="menu-item" @click="pick('parallel')">
|
||||||
<span class="mi-icon parallel">⇉</span>
|
<span class="mi-icon parallel">⇉</span>
|
||||||
<span class="mi-text">
|
<span class="mi-text">
|
||||||
@@ -94,6 +97,17 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
|
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 {
|
.menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
};
|
};
|
||||||
if (item.countersign === "ANY") node.countersign = "ANY";
|
if (item.countersign === "ANY") node.countersign = "ANY";
|
||||||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||||||
|
if (item.assignPolicy) node.assignPolicy = { ...item.assignPolicy };
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
linkTo(id);
|
linkTo(id);
|
||||||
if (item.outlets && item.outlets.length >= 2 && item.branches) {
|
if (item.outlets && item.outlets.length >= 2 && item.branches) {
|
||||||
@@ -487,6 +488,7 @@ function toApprovalNode(n: any): ApprovalNode {
|
|||||||
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
|
||||||
countersign,
|
countersign,
|
||||||
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
|
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
|
||||||
|
assignPolicy: n.assignPolicy ? { ...n.assignPolicy } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ const sourceFlowGuid = ref("");
|
|||||||
const appKey = ref("");
|
const appKey = ref("");
|
||||||
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
|
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
|
||||||
const appOptions = ref<{ appKey: string; appName: string }[]>([]);
|
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());
|
const model = reactive<EditorModel>(createEmptyModel());
|
||||||
/** 科室列表(deptList 聚合行):仅用于下拉选项与"无启用人员"提示 */
|
/** 科室列表(deptList 聚合行):仅用于下拉选项与"无启用人员"提示 */
|
||||||
@@ -69,8 +78,10 @@ const depts = reactive<DeptOption[]>([]);
|
|||||||
const userNames = reactive<Record<number, string>>({});
|
const userNames = reactive<Record<number, string>>({});
|
||||||
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||||||
const routers = reactive<RouterOption[]>([]);
|
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));
|
const defJson = computed(() => toContentJson(model));
|
||||||
|
|
||||||
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
|
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
|
||||||
@@ -201,6 +212,27 @@ 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 {
|
function applyParsed(parsed: ParsedDef): void {
|
||||||
model.code = parsed.code || model.code;
|
model.code = parsed.code || model.code;
|
||||||
model.name = parsed.name || model.name;
|
model.name = parsed.name || model.name;
|
||||||
@@ -217,6 +249,7 @@ async function load(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await loadDepts();
|
await loadDepts();
|
||||||
await loadRouters();
|
await loadRouters();
|
||||||
|
void loadVars();
|
||||||
try {
|
try {
|
||||||
const rows: any[] = (await flowApi.appList().send()) || [];
|
const rows: any[] = (await flowApi.appList().send()) || [];
|
||||||
appOptions.value = rows
|
appOptions.value = rows
|
||||||
@@ -225,6 +258,15 @@ async function load(): Promise<void> {
|
|||||||
} catch {
|
} catch {
|
||||||
appOptions.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;
|
const queryId = route.query.id ? Number(route.query.id) : null;
|
||||||
const queryGuid = (route.query.baseGuid as string) || "";
|
const queryGuid = (route.query.baseGuid as string) || "";
|
||||||
@@ -249,6 +291,8 @@ async function load(): Promise<void> {
|
|||||||
model.code = d?.code || "";
|
model.code = d?.code || "";
|
||||||
model.name = d?.name || "";
|
model.name = d?.name || "";
|
||||||
appKey.value = d?.appKey || "";
|
appKey.value = d?.appKey || "";
|
||||||
|
formUuid.value = d?.formUuid || "";
|
||||||
|
formVersion.value = d?.formVersion == null ? undefined : Number(d.formVersion);
|
||||||
try {
|
try {
|
||||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -384,7 +428,12 @@ provide<EditorCtx>(EDITOR_CTX, {
|
|||||||
model,
|
model,
|
||||||
depts,
|
depts,
|
||||||
userNames,
|
userNames,
|
||||||
|
// 升版本/编辑草稿时由 defGet 带回;新建态为空串(预览据此说明"没有可比对的已发布版本")
|
||||||
|
get flowGuid() {
|
||||||
|
return sourceFlowGuid.value || "";
|
||||||
|
},
|
||||||
routers,
|
routers,
|
||||||
|
availableVars,
|
||||||
issueOf,
|
issueOf,
|
||||||
openNode,
|
openNode,
|
||||||
removeNode,
|
removeNode,
|
||||||
@@ -405,6 +454,16 @@ function onJsonApplied(p: ParsedDef): void {
|
|||||||
applyParsed(p);
|
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> {
|
async function onSave(): Promise<void> {
|
||||||
if (!model.name.trim()) {
|
if (!model.name.trim()) {
|
||||||
Message.warning("请填写流程名称");
|
Message.warning("请填写流程名称");
|
||||||
@@ -414,10 +473,12 @@ async function onSave(): Promise<void> {
|
|||||||
Message.warning("请填写流程编码");
|
Message.warning("请填写流程编码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 归属应用必填(2026-10-12 拍板):新建必须选;升版本继承源线(baseGuid 载入时已带回),
|
// 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度,
|
||||||
// 源线未归属(存量 NULL)时必须在此补选
|
// 不再是新发起资格(资格=flw_usage_grant 独立授权)。显式传入时后端仍校白名单。
|
||||||
if (!(appKey.value || "").trim()) {
|
// 线级停发入口提示见列表页;此处不阻断无归属定义的保存。
|
||||||
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
|
// 关联表单必须成对:改造前只有关联、没有版本号的存量行,重新选一次才能保存(后端同样会拒)
|
||||||
|
if (formUuid.value && !formVersion.value) {
|
||||||
|
Message.warning("该流程关联的表单还没有确切版本号,请在「关联表单」里重新选择一次(版本由表单中心给出,不可手填)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const errors = countErrors(issues.value);
|
const errors = countErrors(issues.value);
|
||||||
@@ -478,6 +539,9 @@ async function submitSave(changeLog?: string): Promise<void> {
|
|||||||
baseGuid: baseGuid.value || undefined,
|
baseGuid: baseGuid.value || undefined,
|
||||||
appKey: (appKey.value || "").trim() || undefined,
|
appKey: (appKey.value || "").trim() || undefined,
|
||||||
changeLog: changeLog || undefined,
|
changeLog: changeLog || undefined,
|
||||||
|
// 关联表单成对提交:空串=显式取消关联(后端同时清空版本),有值=uuid + 中心当时发布版
|
||||||
|
formUuid: formUuid.value,
|
||||||
|
formVersion: formUuid.value ? formVersion.value : undefined,
|
||||||
}).send();
|
}).send();
|
||||||
savedSnapshot.value = currentSnapshot.value;
|
savedSnapshot.value = currentSnapshot.value;
|
||||||
Message.success(willCreateVersion()
|
Message.success(willCreateVersion()
|
||||||
@@ -551,6 +615,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
<Tag v-else color="green">新建</Tag>
|
<Tag v-else color="green">新建</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="hd-right">
|
<div class="hd-right">
|
||||||
|
<!-- 快捷入口上移顶栏(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>
|
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -582,11 +651,28 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
:disabled="!!editId || !!baseGuid" :maxlength="40" />
|
:disabled="!!editId || !!baseGuid" :maxlength="40" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ip-fld">
|
<div class="ip-fld">
|
||||||
<label>归属应用 <span style="color: #ed4014">*</span></label>
|
<label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label>
|
||||||
<Select v-model="appKey" placeholder="归属应用(必选)" filterable>
|
<Select v-model="appKey" placeholder="归属应用(可留空)" filterable clearable>
|
||||||
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="ip-sec">
|
<section class="ip-sec">
|
||||||
@@ -635,16 +721,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
<div class="ip-stat"><b>{{ stats.parallel }}</b><span>并行分支</span></div>
|
<div class="ip-stat"><b>{{ stats.parallel }}</b><span>并行分支</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="ip-sec">
|
|
||||||
<h4>快捷入口</h4>
|
|
||||||
<div class="ip-quick">
|
|
||||||
<Button size="small" @click="settingsDrawer = true">流程设置</Button>
|
|
||||||
<Button size="small" @click="jsonDrawer = true">源码</Button>
|
|
||||||
<Button size="small" :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
|
|
||||||
</div>
|
|
||||||
<div class="ip-tip">「图示预览」仅已载入的草稿/版本可用(同现状 :disabled="!editId")</div>
|
|
||||||
</section>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -713,10 +789,18 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
}
|
}
|
||||||
.hd-right {
|
.hd-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
/* 顶栏工具栏分组竖线(2026-10-15 痛点 7):左侧「查看/配置类」与右侧「保存草稿」主操作拉开层级 */
|
||||||
|
.hd-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: #e5e6eb;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
.ed-canvas {
|
.ed-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -942,11 +1026,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #808695;
|
color: #808695;
|
||||||
}
|
}
|
||||||
.ip-quick {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
/* 校验清单定位高亮(原型 E2,用项目错误红不做原型紫);卡片在子组件内,需 :deep 命中 */
|
/* 校验清单定位高亮(原型 E2,用项目错误红不做原型紫);卡片在子组件内,需 :deep 命中 */
|
||||||
:deep(.nd-locate) {
|
:deep(.nd-locate) {
|
||||||
box-shadow: 0 0 0 4px rgba(237, 64, 20, 0.18) !important;
|
box-shadow: 0 0 0 4px rgba(237, 64, 20, 0.18) !important;
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ export interface AssigneeUsers {
|
|||||||
}
|
}
|
||||||
export type Assignee = AssigneeDept | AssigneeUsers;
|
export type Assignee = AssigneeDept | AssigneeUsers;
|
||||||
|
|
||||||
|
/** 发起时指派: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 {
|
export interface ApprovalNode {
|
||||||
uid: string;
|
uid: string;
|
||||||
kind: "approval";
|
kind: "approval";
|
||||||
@@ -28,6 +36,8 @@ export interface ApprovalNode {
|
|||||||
countersign: Countersign;
|
countersign: Countersign;
|
||||||
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
|
||||||
deadlineHours: number | null;
|
deadlineHours: number | null;
|
||||||
|
/** 发起时指派策略:存在即允许指派;生成 JSON 时关闭则不写字段(与 countersign 省略风格一致) */
|
||||||
|
assignPolicy?: AssignPolicy | null;
|
||||||
/** 多出口(≥2 才有多出口语义):出口按钮文案,办理人按下哪个按钮走哪条泳道;单出口不写字段 */
|
/** 多出口(≥2 才有多出口语义):出口按钮文案,办理人按下哪个按钮走哪条泳道;单出口不写字段 */
|
||||||
outlets?: string[];
|
outlets?: string[];
|
||||||
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
|
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
|
||||||
@@ -117,6 +127,8 @@ export interface DefNodeJson {
|
|||||||
countersign?: Countersign;
|
countersign?: Countersign;
|
||||||
waitFor?: string;
|
waitFor?: string;
|
||||||
deadlineHours?: number;
|
deadlineHours?: number;
|
||||||
|
/** 发起时指派策略(审批节点可选,2026-10-13;关闭态不写字段) */
|
||||||
|
assignPolicy?: AssignPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DefEdgeJson {
|
export interface DefEdgeJson {
|
||||||
@@ -148,6 +160,60 @@ export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"];
|
|||||||
|
|
||||||
export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 };
|
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;
|
let uidSeq = 0;
|
||||||
@@ -324,6 +390,7 @@ export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames:
|
|||||||
/** 卡片标签行(会签/或签 + 时限) */
|
/** 卡片标签行(会签/或签 + 时限) */
|
||||||
export function nodeTags(n: ApprovalNode): string[] {
|
export function nodeTags(n: ApprovalNode): string[] {
|
||||||
const tags = [n.countersign === "ANY" ? "或签" : "会签"];
|
const tags = [n.countersign === "ANY" ? "或签" : "会签"];
|
||||||
|
if (n.assignPolicy) tags.push("可指派");
|
||||||
if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`);
|
if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`);
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
@@ -348,8 +415,12 @@ export interface EditorCtx {
|
|||||||
model: EditorModel;
|
model: EditorModel;
|
||||||
depts: DeptOption[];
|
depts: DeptOption[];
|
||||||
userNames: Record<number, string>;
|
userNames: Record<number, string>;
|
||||||
|
/** 当前流程线 flowGuid(新建/尚未保存时为空):指派预览与已发布版本对账要用 */
|
||||||
|
flowGuid: string;
|
||||||
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
|
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
|
||||||
routers: RouterOption[];
|
routers: RouterOption[];
|
||||||
|
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */
|
||||||
|
availableVars: string[];
|
||||||
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
|
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
|
||||||
openNode: (uid: string) => void;
|
openNode: (uid: string) => void;
|
||||||
removeNode: (uid: string) => void;
|
removeNode: (uid: string) => void;
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
import {
|
import {
|
||||||
BRANCH_LABELS,
|
BRANCH_LABELS,
|
||||||
DeptOption,
|
DeptOption,
|
||||||
|
EXPR_BUILTIN_SET,
|
||||||
EditorItem,
|
EditorItem,
|
||||||
EditorModel,
|
EditorModel,
|
||||||
MAX_BRANCHES,
|
MAX_BRANCHES,
|
||||||
MIN_BRANCHES,
|
MIN_BRANCHES,
|
||||||
RouterOption,
|
RouterOption,
|
||||||
|
unknownExprVars,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
|
||||||
export type IssueLevel = "error" | "warn";
|
export type IssueLevel = "error" | "warn";
|
||||||
@@ -28,11 +30,23 @@ export interface Issue {
|
|||||||
text: string;
|
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 issues: Issue[] = [];
|
||||||
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
|
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
|
||||||
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
|
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 => {
|
const rec = (items: EditorItem[], path: string): void => {
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
@@ -114,6 +128,16 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
|
|||||||
if (!cond.trim()) {
|
if (!cond.trim()) {
|
||||||
push(item.uid, "error", path,
|
push(item.uid, "error", path,
|
||||||
`出口「${o || i + 1}」未配条件表达式(表达式模式下每个出口必须配置,发布会被拦截)`);
|
`出口「${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) {
|
} else if (!item.routerKey) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { Message, Tag } from "view-ui-plus";
|
import { Message, Tag } from "view-ui-plus";
|
||||||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, flowApi } from "@/api/module/flow";
|
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 props = defineProps<{ modelValue: boolean; presetExpression?: string }>();
|
||||||
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
|
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
|
||||||
@@ -25,9 +26,13 @@ const result = ref<{ result: boolean; value?: string; error?: string } | null>(n
|
|||||||
const evaluating = ref(false);
|
const evaluating = ref(false);
|
||||||
const exprBox = ref<any>(null);
|
const exprBox = ref<any>(null);
|
||||||
|
|
||||||
/** 内置变量(线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填) */
|
/**
|
||||||
|
* 内置变量(2026-10-15 抽共享:与 ConditionBox chips、后端 builtinEnv 同源)——
|
||||||
|
* 线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填。
|
||||||
|
* 展示顺序沿用原口径(schId 置末),故此处按名过滤而非直接复用数组顺序。
|
||||||
|
*/
|
||||||
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
|
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
|
||||||
const BUILTIN_SET = new Set(BUILTIN_KEYS);
|
const BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
|
||||||
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
|
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
|
||||||
const builtinRows = computed(() => {
|
const builtinRows = computed(() => {
|
||||||
const p = picked.value;
|
const p = picked.value;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ref, computed, onMounted, nextTick } from "vue";
|
|||||||
import { Tag } from "view-ui-plus";
|
import { Tag } from "view-ui-plus";
|
||||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||||
import { fitDiagram } from "@/utils/diagramImg";
|
import { fitDiagram } from "@/utils/diagramImg";
|
||||||
|
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** InstanceDetail 形状对象(两页同源) */
|
/** InstanceDetail 形状对象(两页同源) */
|
||||||
@@ -107,6 +108,15 @@ const formRows = computed(() => {
|
|||||||
return [{ label: "表单原文", value: String(raw) }];
|
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 同源:改一处必须同步另一处) =====
|
||||||
|
|
||||||
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG) */
|
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG) */
|
||||||
@@ -140,12 +150,17 @@ function rowTime(r: any): string {
|
|||||||
return r.time || r.handleTime || r.arriveTime || "";
|
return r.time || r.handleTime || r.arriveTime || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人") */
|
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人"、
|
||||||
|
* 抄送显"抄送对象";发起/撤回/重提显"提交人") */
|
||||||
function recordActor(t: any): string {
|
function recordActor(t: any): string {
|
||||||
if (t.kind === "ROUTE") return "流程引擎";
|
if (t.kind === "ROUTE") return "流程引擎";
|
||||||
if (traceMode.value !== "dept") {
|
if (traceMode.value !== "dept") {
|
||||||
return t.assigneeName || t.assigneeId || "-";
|
return t.assigneeName || t.assigneeId || "-";
|
||||||
}
|
}
|
||||||
|
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
|
||||||
|
if (t.kind === "CC") {
|
||||||
|
return ccActorText(t);
|
||||||
|
}
|
||||||
if (t.assigneeType === "DEPT_KEY") {
|
if (t.assigneeType === "DEPT_KEY") {
|
||||||
return t.deptName || t.deptKey || t.assigneeName || "-";
|
return t.deptName || t.deptKey || t.assigneeName || "-";
|
||||||
}
|
}
|
||||||
@@ -193,12 +208,16 @@ function labelTagClass(label: string): string {
|
|||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** dept 档下抄送行折叠为一行(N 个接收人 → "抄送对象 N 人",不出现人名);user 档原样。
|
||||||
|
* 节点视图/列表视图共用;时间线视图由 TraceTimeline 内部折叠(口径同源) */
|
||||||
|
const displayTrace = computed(() => collapseCcRows(props.detail?.trace || [], traceMode.value));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
||||||
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
||||||
*/
|
*/
|
||||||
const nodeSteps = computed(() => {
|
const nodeSteps = computed(() => {
|
||||||
const trace: any[] = props.detail?.trace || [];
|
const trace: any[] = displayTrace.value;
|
||||||
const act: string[] = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
const act: string[] = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
||||||
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
||||||
const steps: any[] = [];
|
const steps: any[] = [];
|
||||||
@@ -346,9 +365,9 @@ onMounted(async () => {
|
|||||||
<div class="embed-meta-k">{{ m.k }}</div>
|
<div class="embed-meta-k">{{ m.k }}</div>
|
||||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="full && detail.bizUrl" class="embed-meta-i">
|
<div v-if="full && bizUrlAbs" class="embed-meta-i">
|
||||||
<div class="embed-meta-k">业务详情</div>
|
<div class="embed-meta-k">业务详情</div>
|
||||||
<div class="embed-meta-v"><a :href="detail.bizUrl" target="_blank">查看业务详情</a></div>
|
<div class="embed-meta-v"><a :href="bizUrlAbs" target="_blank">查看业务详情</a></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,8 +421,8 @@ onMounted(async () => {
|
|||||||
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
||||||
:status-text="TRACE_STATUS_TEXT" />
|
:status-text="TRACE_STATUS_TEXT" />
|
||||||
|
|
||||||
<!-- ③ 列表:整个流程周期的完整事件流水 -->
|
<!-- ③ 列表:整个流程周期的完整事件流水(dept 档抄送行已折叠,见 displayTrace) -->
|
||||||
<Table v-else size="small" :columns="recordColumns" :data="detail.trace || []" />
|
<Table v-else size="small" :columns="recordColumns" :data="displayTrace" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="embed-fold" @toggle="onDiagramToggle">
|
<details class="embed-fold" @toggle="onDiagramToggle">
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
// 办件详情抽屉(管理面列表页复用):非 iframe,内部直接复用 InstanceBody(full 口径)。
|
// 办件详情抽屉(管理面列表页复用):非 iframe,内部直接复用 InstanceBody(full 口径)。
|
||||||
// 宽度与 Notice 审批记录抽屉对齐 740px;关闭后清空状态,下次打开重新加载。
|
// 宽度与 Notice 审批记录抽屉对齐 740px;关闭后清空状态,下次打开重新加载。
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { Drawer, Tag } from "view-ui-plus";
|
import { Drawer, Tag } from "view-ui-plus";
|
||||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
|
import { toAppHashUrl } from "@/utils/appUrl";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@@ -49,8 +53,13 @@ const diagramSrc = computed(() => {
|
|||||||
|
|
||||||
function openDiagram() {
|
function openDiagram() {
|
||||||
if (!detail.value?.instanceId) return;
|
if (!detail.value?.instanceId) return;
|
||||||
const url = `${window.location.origin}${import.meta.env.BASE_URL}#/diagram?instanceId=${detail.value.instanceId}&mode=${mode.value}`;
|
// 原写法 `${origin}${BASE_URL}#/diagram?…` 里 BASE_URL 构建期是 "./",拼出
|
||||||
window.open(url, "_blank");
|
// `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) {
|
function statusName(s: number) {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
// 流程轨迹时间线(2026-09-18 新增,办件详情页与办理页共用一个实现):
|
// 流程轨迹时间线(2026-09-18 新增,办件详情页与办理页共用一个实现):
|
||||||
// ① 时间流:后端已按"该行实际发生的时间"升序返回(time 字段),此处不再排序,只按 round 分轮展示;
|
// ① 时间流:后端已按"该行实际发生的时间"升序返回(time 字段),此处不再排序,只按 round 分轮展示;
|
||||||
// ② 两档显示:user=按人员(默认,显示办理人姓名);dept=按科室(DEPT_KEY 节点显科室名、USERS 节点显
|
// ② 两档显示:user=按人员(默认,显示办理人姓名);dept=按科室(DEPT_KEY 节点显科室名、USERS 节点显
|
||||||
// "指定办理人"、发起/撤回/重提显"提交人"),口径与流程示意图的科室视图一致;
|
// "指定办理人"、发起/撤回/重提显"提交人"、抄送显"抄送对象"),口径与流程示意图的科室视图一致;
|
||||||
// ③ 撤回重走留痕:kind=WITHDRAW/RESUBMIT 的行由后端留痕事件行提供,重提处插入"第 N 轮"分隔。
|
// ③ 撤回重走留痕:kind=WITHDRAW/RESUBMIT 的行由后端留痕事件行提供,重提处插入"第 N 轮"分隔。
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { TASK_STATUS } from "@/api/module/flow";
|
import { TASK_STATUS } from "@/api/module/flow";
|
||||||
|
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 后端 detail.trace(已按时间升序,含 kind/time/round/科室富化字段) */
|
/** 后端 detail.trace(已按时间升序,含 kind/time/round/科室富化字段) */
|
||||||
@@ -46,7 +47,8 @@ const KIND_TAG: Record<string, string> = {
|
|||||||
ROUTE: "purple",
|
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));
|
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} 轮审批`;
|
return at ? `第 ${round} 轮审批(重新提交于 ${at})` : `第 ${round} 轮审批`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
|
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人/抄送对象(不暴露具体人名) */
|
||||||
function actor(t: any): string {
|
function actor(t: any): string {
|
||||||
if (t.kind === "ROUTE") return "流程引擎";
|
if (t.kind === "ROUTE") return "流程引擎";
|
||||||
if (props.mode !== "dept") {
|
if (props.mode !== "dept") {
|
||||||
return t.assigneeName || t.assigneeId || "-";
|
return t.assigneeName || t.assigneeId || "-";
|
||||||
}
|
}
|
||||||
|
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
|
||||||
|
if (t.kind === "CC") {
|
||||||
|
return ccActorText(t);
|
||||||
|
}
|
||||||
if (t.assigneeType === "DEPT_KEY") {
|
if (t.assigneeType === "DEPT_KEY") {
|
||||||
return t.deptName || t.deptKey || t.assigneeName || "-";
|
return t.deptName || t.deptKey || t.assigneeName || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,162 @@
|
|||||||
|
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 () => {
|
||||||
|
throw new Error("流程线已停发,不可发起新办件");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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,204 @@
|
|||||||
|
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() as HTMLIFrameElement).getAttribute("src") || "";
|
||||||
|
expect(src.split("#").length - 1).toBe(1);
|
||||||
|
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
|
||||||
|
expect(src).toContain("flowGuid=g-embed");
|
||||||
|
expect(src).toContain("contextId=");
|
||||||
|
expect((wrapper.vm as any).contextId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺少 flowGuid 时不开框、不监听", async () => {
|
||||||
|
const addSpy = vi.spyOn(window, "addEventListener");
|
||||||
|
const wrapper = mountHost({ flowGuid: "" });
|
||||||
|
(wrapper.vm as any).open();
|
||||||
|
await nextTick();
|
||||||
|
expect(frameEl()).toBeNull();
|
||||||
|
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
|
||||||
|
addSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FlowAssignEmbed 消息校验", () => {
|
||||||
|
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);
|
||||||
|
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
|
||||||
|
expect((events[0][0] as any).defVersion).toBe(3);
|
||||||
|
expect((events[0][0] as any).flowGuid).toBe("g-embed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
|
||||||
|
const wrapper = mountHost();
|
||||||
|
await openHost(wrapper);
|
||||||
|
const cid = (wrapper.vm as any).contextId;
|
||||||
|
const fakeOtherWindow = { __other: true };
|
||||||
|
|
||||||
|
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" });
|
||||||
|
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow });
|
||||||
|
await post(wrapper, baseData({ contextId: "stale-from-prev-session" }));
|
||||||
|
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid });
|
||||||
|
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid });
|
||||||
|
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid });
|
||||||
|
await post(wrapper, "plain-string-from-page-script");
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
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: [777] }] }));
|
||||||
|
const events = wrapper.emitted("confirmed");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
|
||||||
|
const wrapper = mountHost();
|
||||||
|
await openHost(wrapper);
|
||||||
|
const cid = (wrapper.vm as any).contextId;
|
||||||
|
(wrapper.vm as any).close();
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.emitted("cancelled")).toHaveLength(1);
|
||||||
|
await post(wrapper, baseData({ contextId: cid }));
|
||||||
|
expect(wrapper.emitted("confirmed")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
|
||||||
|
const wrapper = mountHost();
|
||||||
|
await openHost(wrapper);
|
||||||
|
const cid1 = (wrapper.vm as any).contextId;
|
||||||
|
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] }));
|
||||||
|
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
|
||||||
|
|
||||||
|
// 宿主把 A 记在内存里,重开选 B
|
||||||
|
await openHost(wrapper);
|
||||||
|
const cid2 = (wrapper.vm as any).contextId;
|
||||||
|
expect(cid2).not.toEqual(cid1);
|
||||||
|
|
||||||
|
// 上一轮残帧补发一条:按 context 失效
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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,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,55 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError";
|
||||||
|
|
||||||
|
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */
|
||||||
|
|
||||||
|
test("后端给出通用 code=6 占位文案:换成不误导的兜底话术,不猜原因", () => {
|
||||||
|
assert.equal(describeFlowError("内部服务错误"), FLOW_GENERIC_FALLBACK);
|
||||||
|
assert.equal(describeFlowError("内部服务错误!"), FLOW_GENERIC_FALLBACK);
|
||||||
|
assert.equal(describeFlowError("业务错误"), FLOW_GENERIC_FALLBACK);
|
||||||
|
assert.equal(describeFlowError(""), FLOW_GENERIC_FALLBACK);
|
||||||
|
assert.ok(!FLOW_GENERIC_FALLBACK.includes("成功"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("契约内的具体业务 msg 原样透出,不改写不误导", () => {
|
||||||
|
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049");
|
||||||
|
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理");
|
||||||
|
assert.equal(
|
||||||
|
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"),
|
||||||
|
"该待办缺少流程推进记录,需管理员回填后办理",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("异常/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(raw);
|
||||||
|
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
|
||||||
|
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
|
||||||
|
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
|
||||||
|
assert.equal(describeFlowError("Failed to fetch"), FLOW_NETWORK_FALLBACK);
|
||||||
|
assert.equal(describeFlowError("timeout of 20000ms exceeded"), FLOW_NETWORK_FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Error 对象可直接传入(取 message)", () => {
|
||||||
|
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1");
|
||||||
|
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("页面内联提示带动作名,明确「未生效」而不是成功", () => {
|
||||||
|
const tip = flowFailTip("同意", new Error("内部服务错误"));
|
||||||
|
assert.ok(tip.startsWith("同意未完成:"));
|
||||||
|
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK));
|
||||||
|
assert.ok(!tip.includes("已同意"));
|
||||||
|
});
|
||||||
@@ -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