Compare commits
9
Commits
04fd54e945
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93313cdb7 | ||
|
|
f2841e2740 | ||
|
|
cdea9c559b | ||
|
|
5534cd107a | ||
|
|
1e852c78be | ||
|
|
8392635571 | ||
|
|
4849b53541 | ||
|
|
8853c0faa3 | ||
|
|
ba448fc7a0 |
@@ -19,7 +19,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- name: 安装 git / curl / zip(alpine)
|
||||
run: apk add --no-cache git curl zip
|
||||
run: |
|
||||
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||
apk add --no-cache git curl zip
|
||||
|
||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,21 +41,29 @@ jobs:
|
||||
- name: 构建前端(node 容器)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f frontbuild 2>/dev/null || true
|
||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." frontbuild:/build/
|
||||
docker exec frontbuild sh -c '
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
docker exec "$BUILD_CONTAINER" sh -c '
|
||||
set -e
|
||||
cd /build
|
||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm ci
|
||||
npm run build
|
||||
'
|
||||
echo "=== dist 产物 ==="
|
||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||
# 从容器 cp dist 到宿主工作目录
|
||||
docker cp frontbuild:/build/dist ./dist
|
||||
docker rm -f frontbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
echo "BUILD CHECK OK"
|
||||
|
||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||
|
||||
Generated
+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:prod": "vite build --mode prod",
|
||||
"preview": "vite preview",
|
||||
"api:gen": "alova gen -f"
|
||||
"api:gen": "alova gen -f",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test:line-offline": "esbuild tests/line-offline.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outfile=node_modules/.cache/line-offline.test.mjs && node node_modules/.cache/line-offline.test.mjs",
|
||||
"test:unit": "esbuild tests/line-offline.test.ts tests/app-url.test.ts tests/flow-error.test.ts tests/assign-preview.test.ts tests/convert-roundtrip.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outbase=tests --outdir=node_modules/.cache/unit && node --test node_modules/.cache/unit",
|
||||
"test:component": "vitest run",
|
||||
"test": "npm run test:unit && npm run test:component"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-file-viewer/core": "^0.1.43",
|
||||
@@ -40,13 +45,16 @@
|
||||
"@unocss/preset-rem-to-px": "^66.6.0",
|
||||
"@unocss/preset-uno": "^66.6.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"fast-glob": "^3.3.2",
|
||||
"jsdom": "25.0.1",
|
||||
"typescript": "^5.2.2",
|
||||
"unocss": "^66.6.0",
|
||||
"unplugin-auto-import": "0.18.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vitest": "2.1.9",
|
||||
"vue-tsc": "3.3.13"
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -5,22 +5,29 @@ import { useRouter } from "vue-router";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { baseApi } from "@/api/module/base";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { isBareTaskRoute } from "@/utils/taskEmbed";
|
||||
import { Message } from "view-ui-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const isLogin = ref(false);
|
||||
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
|
||||
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
|
||||
const isEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
const isTokenEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
|
||||
// 去壳任务页(/task/{taskId}?embed=1,v2.1 完整办理内嵌):只去菜单壳,CAS 一律保留——
|
||||
// 办理是写操作,旁路鉴权等于把审批动作挂在免签 URL 上。与 token 页共享"无壳"渲染,
|
||||
// 但鉴权分支只走 isTokenEmbed
|
||||
const isBareTask = computed(() =>
|
||||
isBareTaskRoute(router.currentRoute.value.path, router.currentRoute.value.query.embed));
|
||||
const isShellless = computed(() => isTokenEmbed.value || isBareTask.value);
|
||||
|
||||
// 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
|
||||
// 上面 isEmbed 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
|
||||
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
|
||||
function syncEmbedLayout(embed: boolean) {
|
||||
if (embed) document.body.style.minWidth = "0";
|
||||
else document.body.style.removeProperty("min-width");
|
||||
}
|
||||
watch(isEmbed, syncEmbedLayout, { immediate: true });
|
||||
watch(isShellless, syncEmbedLayout, { immediate: true });
|
||||
onBeforeUnmount(() => syncEmbedLayout(false));
|
||||
|
||||
const layoutData = reactive({
|
||||
@@ -37,8 +44,9 @@ onMounted(async () => {
|
||||
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
|
||||
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
|
||||
await router.isReady();
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)
|
||||
if (isEmbed.value) {
|
||||
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
|
||||
// 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
|
||||
if (isTokenEmbed.value) {
|
||||
isLogin.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +105,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动) -->
|
||||
<router-view v-if="isEmbed" />
|
||||
<template v-else>
|
||||
<layout v-if="isLogin" :layoutData="layoutData" />
|
||||
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
|
||||
token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
|
||||
否则未登录时 iframe 里先闪一个能点的办理面板 -->
|
||||
<router-view v-if="isTokenEmbed" />
|
||||
<router-view v-else-if="isBareTask && isLogin" />
|
||||
<template v-else-if="isLogin">
|
||||
<layout :layoutData="layoutData" />
|
||||
<div class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
+37
-3
@@ -13,8 +13,9 @@ function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
|
||||
|
||||
// alova 的请求体是 Method 构造的第 5 个参数(instance.data),不是 config 上的键:
|
||||
// 写进 config 的 body/data 都会被适配器忽略,POST 将发出空 body。
|
||||
function post<T = any>(url: string, body?: any): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url, {}, body);
|
||||
// 第 4 个参数 config 可带 silentBizToast:调用方自己承担错误提示时,不再叠加全局 toast。
|
||||
function post<T = any>(url: string, body?: any, config?: Record<string, any>): Method<T> {
|
||||
return new Method<T>("POST", alovaInstance, url, config || {}, body);
|
||||
}
|
||||
|
||||
/** 状态枚举(后端 FlwEnums 口径) */
|
||||
@@ -144,7 +145,29 @@ class FlowApi {
|
||||
defGet(id: number | string) {
|
||||
return get(`${ROOT}/admin/def/get`, { id });
|
||||
}
|
||||
defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string; appKey?: string; 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);
|
||||
}
|
||||
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
|
||||
@@ -161,6 +184,17 @@ class FlowApi {
|
||||
defEnable(id: number | string) {
|
||||
return post(`${ROOT}/admin/def/enable/${id}`);
|
||||
}
|
||||
/**
|
||||
* 流程线级停发(T03):整条 flowGuid 停止新发起/预览,在途实例按原版本跑完;
|
||||
* 与版本级 status 正交,返回受影响版本行数。
|
||||
*/
|
||||
defOfflineLine(flowGuid: string, config?: Record<string, any>) {
|
||||
return post<number>(`${ROOT}/admin/def/offlineLine`, { flowGuid }, config);
|
||||
}
|
||||
/** 流程线级恢复(T03):只恢复线可用性,不改写任何版本状态或发布内容;返回受影响版本行数 */
|
||||
defEnableLine(flowGuid: string, config?: Record<string, any>) {
|
||||
return post<number>(`${ROOT}/admin/def/enableLine`, { flowGuid }, config);
|
||||
}
|
||||
defVersions(flowGuid: string) {
|
||||
return get(`${ROOT}/admin/def/versions`, { flowGuid });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -76,10 +76,15 @@ const routeChange = (to: any) => {
|
||||
const navArr: any[] = [];
|
||||
|
||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||
// 匹配不到再退回 modId 对照
|
||||
// 匹配不到再退回 modId 对照。
|
||||
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
|
||||
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
|
||||
// 不相干的「流程编辑器」(验收报告 P3)。
|
||||
const find: any =
|
||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||
menuArr.value.find((x: any) => x.modId === modId);
|
||||
(modId !== undefined && modId !== null
|
||||
? menuArr.value.find((x: any) => x.modId === modId)
|
||||
: null);
|
||||
if (find) {
|
||||
currPath.value = find.page || "";
|
||||
if (find.pid) {
|
||||
@@ -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) {
|
||||
currPath.value = r.path;
|
||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||
@@ -117,7 +125,10 @@ const routeChange = (to: any) => {
|
||||
}
|
||||
|
||||
currPath.value = "";
|
||||
navPath.value = [];
|
||||
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
|
||||
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
|
||||
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
|
||||
navPath.value = title ? [{ name: title }] : [];
|
||||
};
|
||||
|
||||
const linkMenu = (item: any) => {
|
||||
|
||||
@@ -32,6 +32,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/flow/InstanceDetail.vue"),
|
||||
meta: { title: "办件详情", parentId: 102 },
|
||||
},
|
||||
{
|
||||
// T07 托管选人 iframe(F6 接线):业务页 iframe 内嵌,CAS 会话预览候选并回传最终选择
|
||||
path: "/assign-preview",
|
||||
name: "flow_assign_preview",
|
||||
component: () => import("@/views/flow/AssignPreview.vue"),
|
||||
meta: { title: "选择办理人" },
|
||||
},
|
||||
{
|
||||
// 流程示意图深链页(不挂菜单,同 /task/:taskId 模式);跨应用入口 window.open 整页跳转
|
||||
path: "/diagram",
|
||||
|
||||
@@ -34,6 +34,13 @@ const defHooks: Required<RequestHooks> = {
|
||||
};
|
||||
|
||||
/* ============= 错误处理(原 axios 逻辑平移) ============= */
|
||||
/** 抛错时把契约里的码/状态挂到 Error 上:调用方可按码分支,而不是回头猜文案 */
|
||||
function fail(message: string, extra: Record<string, unknown>): never {
|
||||
const err = new Error(message) as Error & Record<string, unknown>;
|
||||
Object.assign(err, extra);
|
||||
throw err;
|
||||
}
|
||||
|
||||
function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||
// http 层错误
|
||||
if (status !== 200) {
|
||||
@@ -48,7 +55,7 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
||||
default:
|
||||
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!;
|
||||
return new Promise(() => {}) as T;
|
||||
case 4:
|
||||
throw new Error(res.msg || "参数错误!");
|
||||
throw Object.assign(new Error(res.msg || "参数错误!"), { bizCode: 4 });
|
||||
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--;
|
||||
if (reqCount === 0) {
|
||||
clearTimeout(timer);
|
||||
@@ -120,18 +132,18 @@ export function createBschAlova(
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
onBizError(e.message);
|
||||
if (!isSilentBizToast(method)) onBizError(e.message);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function onError(err: any) {
|
||||
async function onError(err: any, method?: Method<any>) {
|
||||
reqCount = 0;
|
||||
clearTimeout(timer);
|
||||
onLoadingEnd();
|
||||
|
||||
const msg = err?.message || "网络请求出错";
|
||||
onHttpError(msg);
|
||||
if (!isSilentBizToast(method)) onHttpError(msg);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createBschAlova } from "./alova-core";
|
||||
import { Message } from "view-ui-plus";
|
||||
import conf from "@/assets/config/host.config";
|
||||
import { describeFlowError } from "@/utils/flowError";
|
||||
|
||||
/* 全局 loading 遮罩实例 */
|
||||
let spinInstance: any = null;
|
||||
@@ -30,11 +31,13 @@ export const alovaInstance = createBschAlova(baseUrl, {
|
||||
spinInstance = Message.loading("加载中...");
|
||||
},
|
||||
onLoadingEnd: closeLoading,
|
||||
/* 全局兜底提示:具体业务 msg 原样透出,通用/异常形态换成不误导的用户话术(不泄露堆栈/SQL/令牌) */
|
||||
onBizError(msg: string) {
|
||||
if (!!msg) Message.error(msg);
|
||||
const text = describeFlowError(msg);
|
||||
if (!!text) Message.error(text);
|
||||
},
|
||||
|
||||
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,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>
|
||||
+264
-57
@@ -1,17 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
|
||||
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务信息区块(只认 summaryFields)+ "查看业务详情"跳 bizUrl
|
||||
// + 历史意见 + 同意/拒绝/退回/移交。业务表单数据(formDataJson)不在办理页渲染——托管模式下它是
|
||||
// 条件路由入参,只进管理面办件详情。
|
||||
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
|
||||
import { toAppHashUrl } from "@/utils/appUrl";
|
||||
import { flowFailTip } from "@/utils/flowError";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => String(route.params.taskId || ""));
|
||||
|
||||
/**
|
||||
* 去壳态(embed=1,v2.1 完整办理内嵌):同一个 /task/:taskId 组件、同一套 CAS 用户面接口,
|
||||
* 只把"办完后跳工作台""跳流程图""看业务详情"这三个会离开当前壳的动作为父页面接管——
|
||||
* iframe 里跳出工作台等于把 flow-center 菜单塞进子应用弹窗。
|
||||
*/
|
||||
const isBare = computed(() => isBareTaskRoute(route.path, route.query.embed));
|
||||
/** bare 下办理成功后的结果条文案(不跳走,父页面自己决定关不关) */
|
||||
const doneTip = ref("");
|
||||
/** 办理失败的内联提示:全局 toast 只说服务端给的那句,这里补「哪个动作没生效 + 现在该怎么办」 */
|
||||
const failTip = ref("");
|
||||
|
||||
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
|
||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||
|
||||
@@ -37,6 +52,18 @@ const showTransfer = ref(false);
|
||||
const transferUser = ref("");
|
||||
const transferName = ref("");
|
||||
|
||||
/**
|
||||
* 业务信息行:唯一内容 = 约定键 bizSnapshot.summaryFields(子应用决定展示哪些字段、用什么中文 label,
|
||||
* 口径同 InstanceBody「业务信息」)。不再逐字平铺 bizSnapshot 原始键——那会把 title/summaryFields 这类
|
||||
* 英文 key 直接摆给办理人看,对象值还会渲染成 [object Object]。
|
||||
* 无 summaryFields(老数据/未接入约定的子应用)只回退已知的人类可读键,未知键一律不外露。
|
||||
*/
|
||||
const SUMMARY_FALLBACK_LABELS: Array<[string, string]> = [
|
||||
["title", "标题"],
|
||||
["summary", "摘要"],
|
||||
["attachmentNames", "附件"],
|
||||
];
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
@@ -46,28 +73,25 @@ const snapshotRows = computed(() => {
|
||||
} catch {
|
||||
return [{ k: "摘要", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
||||
if (!obj || typeof obj !== "object") return [];
|
||||
const sf = obj.summaryFields;
|
||||
if (Array.isArray(sf) && sf.length) {
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
k: String(x.label),
|
||||
v: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务表单数据(§3.4):formDataJson 逐键值只读渲染;数组值按文件名/项连接;解析失败降级一行原文 */
|
||||
const formRows = computed(() => {
|
||||
const raw = detail.value?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: formValueText(v) }));
|
||||
}
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
return SUMMARY_FALLBACK_LABELS
|
||||
.filter(([key]) => obj[key] !== null && obj[key] !== undefined && obj[key] !== "")
|
||||
.map(([key, label]) => ({ k: label, v: formValueText(obj[key]) }));
|
||||
});
|
||||
|
||||
/** 值 → 展示文本(业务信息回退行共用):数组按文件名/项连接,对象降级 JSON */
|
||||
function formValueText(v: any): string {
|
||||
if (v === null || v === undefined) return "-";
|
||||
if (Array.isArray(v)) {
|
||||
@@ -90,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() {
|
||||
if (detail.value?.bizUrl) {
|
||||
window.open(detail.value.bizUrl, "_blank");
|
||||
if (isBare.value) {
|
||||
postToParent({ type: "open-biz", bizId: detail.value?.bizId ?? null, bizUrl: detail.value?.bizUrl || "" });
|
||||
return;
|
||||
}
|
||||
if (!bizUrlAbs.value) {
|
||||
Message.warning("业务详情地址暂不可用,请在业务系统内查看");
|
||||
return;
|
||||
}
|
||||
window.open(bizUrlAbs.value, "_blank");
|
||||
}
|
||||
|
||||
/** 流程图仍要能看,但 bare 下不能用 router.push——/diagram 不是 /task/ 前缀,会在 iframe 里长出菜单壳 */
|
||||
function openDiagram() {
|
||||
const target = {
|
||||
path: "/diagram",
|
||||
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
|
||||
};
|
||||
if (isBare.value) {
|
||||
// 新窗口整页打开:地址必须只剩一个 hash(把 resolve 出的 "#/diagram?…" 直接当 path
|
||||
// 会在当前地址后面长出第二个 hash,页面原地不动且不报错——线上 P1-3 即此)
|
||||
window.open(toAppHashUrl(router.resolve(target).href), "_blank");
|
||||
return;
|
||||
}
|
||||
router.push(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 办理成功后的收尾:bare 回 postMessage(handled) + 结果条 + 重新拉详情(本任务转为不可办理态,
|
||||
* 避免"再点一次"),非 bare 保持原样跳工作台。
|
||||
*/
|
||||
async function afterHandled(action: FlowEmbedAction, tip: string) {
|
||||
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) {
|
||||
@@ -102,6 +169,8 @@ async function submit(approved: boolean) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
const label = approved ? "同意" : "拒绝";
|
||||
try {
|
||||
if (approved) {
|
||||
// 多出口节点必须带所选出口
|
||||
@@ -113,27 +182,47 @@ async function submit(approved: boolean) {
|
||||
}
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
|
||||
Message.success("已同意");
|
||||
await afterHandled("approve", "已同意,本任务办理完成");
|
||||
} else {
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||
Message.success("已拒绝");
|
||||
await afterHandled("reject", "已拒绝,本任务办理完成");
|
||||
}
|
||||
router.push("/workbench");
|
||||
} catch (e: any) {
|
||||
// 失败就是失败:不跳走、不清意见、不写成功文案;服务端事务已回滚,任务仍是待办
|
||||
failTip.value = flowFailTip(label, e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二次确认:独立打开用 Modal.confirm(原生体验);bare 态一律行内确认。
|
||||
* iframe 内弹 Modal 会在父页面弹窗之上再叠一层遮罩——父页遮罩盖不到 iframe,视觉上是"弹窗套弹窗",
|
||||
* 且弹出位置只在本页顶部(长表单下离按钮很远)。行内确认条没有这个问题。
|
||||
*/
|
||||
const confirming = ref(false);
|
||||
|
||||
function confirmApprove() {
|
||||
if (outlets.value.length > 1 && !chosenEdge.value) {
|
||||
Message.warning("该节点有多个办理去向,请先选择");
|
||||
return;
|
||||
}
|
||||
if (isBare.value) {
|
||||
confirming.value = true;
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: "同意该任务?",
|
||||
onOk: () => submit(true),
|
||||
});
|
||||
}
|
||||
|
||||
function doApprove() {
|
||||
confirming.value = false;
|
||||
submit(true);
|
||||
}
|
||||
|
||||
// ===== 退回 =====
|
||||
function openReturn() {
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
@@ -143,16 +232,18 @@ function openReturn() {
|
||||
|
||||
function doReturn() {
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
const targets = detail.value?.returnTargets || [];
|
||||
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
|
||||
targets[targets.length - 1] || null;
|
||||
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined)
|
||||
.send()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
Message.success("已退回");
|
||||
showReturn.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("return", "已退回,目标节点重新生成待办");
|
||||
})
|
||||
.catch((e: any) => { failTip.value = flowFailTip("退回", e); })
|
||||
.finally(() => { submitting.value = false; });
|
||||
}
|
||||
|
||||
@@ -164,22 +255,63 @@ async function doTransfer() {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
failTip.value = "";
|
||||
try {
|
||||
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
|
||||
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
|
||||
Message.success("已移交");
|
||||
showTransfer.value = false;
|
||||
router.push("/workbench");
|
||||
await afterHandled("transfer", "已移交,本任务由接手人办理");
|
||||
} catch (e: any) {
|
||||
failTip.value = flowFailTip("移交", e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// ===== 去壳态高度上报(父页面 iframe 自适应,避免固定高度里内滚两层) =====
|
||||
const pageEl = ref<HTMLElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastReportedHeight = 0;
|
||||
|
||||
/** 内容变化(详情加载完、弹窗开合)都会触发;8px 死区防止亚像素抖动刷屏 */
|
||||
function reportHeight() {
|
||||
const el = pageEl.value;
|
||||
if (!el) return;
|
||||
const height = el.scrollHeight;
|
||||
if (!height || Math.abs(height - lastReportedHeight) < 8) return;
|
||||
lastReportedHeight = height;
|
||||
postToParent({ type: "height", height });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => detail.value,
|
||||
async () => {
|
||||
if (!isBare.value || !detail.value) return;
|
||||
await nextTick();
|
||||
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => reportHeight());
|
||||
}
|
||||
if (resizeObserver && pageEl.value) resizeObserver.observe(pageEl.value);
|
||||
reportHeight();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
window.addEventListener("resize", reportHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", reportHeight);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-if="detail">
|
||||
<div ref="pageEl" class="page" :class="{ bare: isBare }" v-if="detail">
|
||||
<Alert v-if="doneTip" type="success" show-icon class="done-tip">{{ doneTip }}</Alert>
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
||||
@@ -189,31 +321,24 @@ onMounted(load);
|
||||
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 业务摘要区块(M1 办理页核心;富文本正文留在业务方,经 bizUrl 查看) -->
|
||||
<!-- 业务信息区块(M1 办理页核心):只渲染子应用给的 summaryFields 中文标签行;
|
||||
富文本正文留在业务方,经 bizUrl 查看 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>业务摘要</template>
|
||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供摘要)</div>
|
||||
<template #title>业务信息</template>
|
||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供业务信息)</div>
|
||||
<div v-else class="snapshot">
|
||||
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="detail.bizUrl" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
|
||||
<Button v-if="canOpenBiz" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
|
||||
查看业务详情
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 业务表单数据(§3.4 只读渲染,非表单引擎;无 formData 时整块隐藏) -->
|
||||
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
|
||||
<template #title>业务表单数据</template>
|
||||
<div class="snapshot">
|
||||
<div v-for="row in formRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<!-- 业务表单数据不在此渲染(§3.4):formDataJson 是托管模式的条件路由入参(deptId/deptName 之类),
|
||||
对办理人是内部噪音;原始表单只在管理面办件详情(InstanceBody full)看 -->
|
||||
|
||||
<!-- 办理区 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
@@ -228,17 +353,54 @@ onMounted(load);
|
||||
</div>
|
||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
||||
placeholder="同意可选填意见;拒绝必须填写意见" />
|
||||
<Alert v-if="failTip" type="error" show-icon class="fail-tip">
|
||||
{{ failTip }}
|
||||
<div class="muted">本次办理未生效,任务仍是你的待办;可稍后重试,或把页面提示与操作时间反馈给流程管理员。</div>
|
||||
</Alert>
|
||||
<div class="actions" v-if="myTaskId">
|
||||
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||
<Button :loading="submitting" @click="openReturn">退回</Button>
|
||||
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||
<template v-if="confirming">
|
||||
<span class="confirm-tip">同意该任务?</span>
|
||||
<Button type="primary" :loading="submitting" @click="doApprove">确定</Button>
|
||||
<Button @click="confirming = false">取消</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||
<Button :loading="submitting" @click="openReturn">退回</Button>
|
||||
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
|
||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</div>
|
||||
|
||||
<!-- bare 态退回/移交:行内面板(iframe 里再弹 Modal 会与父页面弹窗叠成三层) -->
|
||||
<div v-if="isBare && showReturn" class="inline-panel">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">退回至:</span>
|
||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||
placeholder="缺省=最近一个已办节点">
|
||||
<Option value="">(最近一个已办节点)</Option>
|
||||
<Option v-for="t in detail.returnTargets || []" :key="t.nodeId" :value="t.nodeId"
|
||||
:label="t.nodeName || t.nodeId" />
|
||||
</Select>
|
||||
<Button size="small" type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
|
||||
<Button size="small" @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 class="block" :bordered="false" dis-hover>
|
||||
<!-- 流程轨迹(独立打开):中心侧唯一上下文,常显 -->
|
||||
<Card v-if="!isBare" class="block" :bordered="false" dis-hover>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||
@@ -247,12 +409,30 @@ onMounted(load);
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<Button size="small" ghost type="primary"
|
||||
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
|
||||
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗 -->
|
||||
<Modal v-model="showReturn" title="退回" :width="480">
|
||||
<!-- 流程轨迹(bare 态):父页面已有"审批进度"卡片,同一份 trace 不再整块铺开——
|
||||
默认收起,需要核对明细(路由判断/抄送/退回留痕)时再展开 -->
|
||||
<Card v-else class="block" :bordered="false" dis-hover>
|
||||
<details class="trace-fold">
|
||||
<summary>
|
||||
<span class="trace-fold-title">流程轨迹({{ (detail.trace || []).length }} 条)</span>
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button"
|
||||
@click.stop @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</summary>
|
||||
<div class="trace-fold-b">
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
|
||||
</div>
|
||||
</details>
|
||||
</Card>
|
||||
|
||||
<!-- 退回弹窗(独立打开;bare 态走行内面板,见办理区) -->
|
||||
<Modal v-if="!isBare" v-model="showReturn" title="退回" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">退回至:</span>
|
||||
<Select v-model="returnTarget" size="small" style="width: 240px"
|
||||
@@ -269,8 +449,8 @@ onMounted(load);
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 移交弹窗 -->
|
||||
<Modal v-model="showTransfer" title="移交" :width="480">
|
||||
<!-- 移交弹窗(独立打开;bare 态走行内面板) -->
|
||||
<Modal v-if="!isBare" v-model="showTransfer" title="移交" :width="480">
|
||||
<div class="modal-row">
|
||||
<span class="modal-label">移交对象工号:</span>
|
||||
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
|
||||
@@ -287,6 +467,11 @@ onMounted(load);
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
/* 去壳态:iframe 里父页面已给了标题与外边距,这里只留最小呼吸(也稳住高度上报的抖动) */
|
||||
.page.bare { padding: 8px 10px; }
|
||||
.done-tip { margin-bottom: 10px; }
|
||||
.fail-tip { margin: 10px 0 0; }
|
||||
.fail-tip .muted { margin-top: 4px; font-size: 12px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
@@ -294,7 +479,15 @@ onMounted(load);
|
||||
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
|
||||
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
|
||||
.biz-link { margin-top: 8px; }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
|
||||
.confirm-tip { color: #515a6e; font-size: 13px; }
|
||||
/* bare 态行内动作面板:与按钮行同宽,浅底描边,不遮挡父页面 */
|
||||
.inline-panel {
|
||||
margin-top: 10px; padding: 10px 12px;
|
||||
border: 1px solid #e8eaec; border-radius: 6px; background: #fafbfc;
|
||||
}
|
||||
.inline-panel .modal-row { margin-bottom: 4px; }
|
||||
.inline-panel .modal-row .ivu-btn { margin-left: 8px; }
|
||||
.muted { color: #999; }
|
||||
.outlet { margin-bottom: 8px; display: flex; align-items: center; }
|
||||
.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; }
|
||||
@@ -305,4 +498,18 @@ onMounted(load);
|
||||
.opinion { color: #666; }
|
||||
.time { color: #aaa; font-size: 12px; }
|
||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||||
/* bare 态轨迹折叠:summary 自绘(去默认三角),展开后内容与独立页一致 */
|
||||
.trace-fold > summary {
|
||||
cursor: pointer; list-style: none; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.trace-fold > summary::-webkit-details-marker { display: none; }
|
||||
.trace-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; font-size: 12px; transition: .2s; }
|
||||
.trace-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.trace-fold-title { position: relative; padding-left: 9px; }
|
||||
.trace-fold-title::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
|
||||
}
|
||||
.trace-fold-b { padding-top: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ import MyConfirm from "@/components/MyConfirm";
|
||||
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import { actionColumn } from "@/utils/tableActions";
|
||||
import { lineActionErrorDetail, runLineAction } from "@/utils/lineOffline";
|
||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -16,6 +17,8 @@ const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
// 线级启停在途标记(按 flowGuid):确认后的请求期间禁用入口并展示「处理中」
|
||||
const lineBusyGuid = ref("");
|
||||
const versionVisible = ref(false);
|
||||
const versions = ref<any[]>([]);
|
||||
// 源码抽屉(列表行已带回 contentJson,无需再请求)
|
||||
@@ -61,6 +64,29 @@ const renderStatus = (h: any, p: any) => {
|
||||
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
|
||||
};
|
||||
|
||||
/** 流程线级停发状态(T03):与版本 status 正交,必须独立展示,不能混读成“发布/停用” */
|
||||
const renderLineState = (h: any, p: any) => {
|
||||
if (!p.row.flowGuid) {
|
||||
return h("span", { style: "color:#999" }, "—");
|
||||
}
|
||||
const offline = Number(p.row.lineOffline) === 1;
|
||||
const tag = h(Tooltip, {
|
||||
content: offline
|
||||
? "流程线已停发:新发起/选人预览均被拒绝,在途办件按原版本跑完;与版本发布状态相互独立"
|
||||
: "流程线正常:新发起可用;与版本发布状态相互独立",
|
||||
transfer: true,
|
||||
maxWidth: 360,
|
||||
}, () => h(Tag, { color: offline ? "red" : "default" }, () => (offline ? "已停发" : "正常")));
|
||||
return isLineBusy(p.row)
|
||||
? h("span", {}, [tag, h(Tag, { color: "blue", style: "margin-left:6px" }, () => "处理中…")])
|
||||
: tag;
|
||||
};
|
||||
|
||||
function isLineBusy(row: any): boolean {
|
||||
const guid = String(row?.flowGuid || "").trim();
|
||||
return !!guid && lineBusyGuid.value === guid;
|
||||
}
|
||||
|
||||
/** 生效/在途 徽标(2026-10-14 从「流程」列拆出独立成列,口径不变:计数仍按流程线) */
|
||||
const renderRunState = (h: any, p: any) => {
|
||||
const n = inflightMap.value[p.row.flowGuid];
|
||||
@@ -77,8 +103,8 @@ const ellipsisCell = (key: string, tag = "span") => (h: any, p: any) => h(Toolti
|
||||
content: p.row[key] ?? "", transfer: true, maxWidth: 480,
|
||||
}, () => h(tag, p.row[key] ?? ""));
|
||||
|
||||
/** 行操作集(平铺态、分组展开行同一套;禁用规则按 status) */
|
||||
const rowActions = [
|
||||
/** 版本级操作集(平铺态、分组展开行同一套;禁用规则按 status,勿与线级启停混用) */
|
||||
const versionActions = [
|
||||
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r), disabledWhen: (r: any) => r.status !== 0 },
|
||||
{ key: "publish", label: "发布", icon: "icon-fasong", on: (r: any) => onPublish(r), disabledWhen: (r: any) => r.status !== 0 },
|
||||
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
|
||||
@@ -94,6 +120,15 @@ const rowActions = [
|
||||
{ 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 = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "流程", key: "name", minWidth: 180 },
|
||||
@@ -101,6 +136,7 @@ const columns = [
|
||||
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
|
||||
{ title: "版本", key: "version", width: 70, align: "center" },
|
||||
{ title: "状态", width: 90, align: "center", render: renderStatus },
|
||||
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||
{ title: "GUID", key: "flowGuid", width: 320, render: (h: any, p: any) => {
|
||||
// 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁;列宽同分组态,保证 32 位 guid 单行全显
|
||||
const n = inflightMap.value[p.row.flowGuid];
|
||||
@@ -110,7 +146,7 @@ const columns = [
|
||||
]);
|
||||
} },
|
||||
{ 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() {
|
||||
@@ -162,6 +198,8 @@ const groups = computed(() => {
|
||||
return {
|
||||
_key: g, flowGuid: g, id: cur.id, name: cur.name, code: cur.code, appKey: cur.appKey,
|
||||
version: cur.version, status: cur.status, publishTime: cur.publishTime,
|
||||
// 线状态全版本同值;任一行标记停发即按停发展示(防历史脏行让管理员误判可用)
|
||||
lineOffline: vs.some((v: any) => Number(v.lineOffline) === 1) ? 1 : 0,
|
||||
total: vs.length, hasPublished: published.length > 0,
|
||||
versions: vs, _expanded: !!openMap.value[g],
|
||||
};
|
||||
@@ -172,13 +210,14 @@ const groups = computed(() => {
|
||||
out.push({
|
||||
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
|
||||
version: r.version, status: r.status, publishTime: r.publishTime,
|
||||
lineOffline: 0,
|
||||
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
|
||||
});
|
||||
});
|
||||
return out;
|
||||
});
|
||||
|
||||
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作复用 rowActions)。
|
||||
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作只用版本级 versionActions)。
|
||||
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
|
||||
const versionColumns = [
|
||||
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
|
||||
@@ -190,7 +229,7 @@ const versionColumns = [
|
||||
{ title: "改动说明", width: 260, ellipsis: true, render: (h: any, p: any) => (p.row.changeLog
|
||||
? ellipsisCell("changeLog")(h, p)
|
||||
: h("span", { style: "color:#999" }, "(未填写)")) },
|
||||
actionColumn(rowActions, { mode: "text", width: 210 }),
|
||||
actionColumn(versionActions, { mode: "text", width: 210 }),
|
||||
];
|
||||
|
||||
/** 子表各列声明宽之和:作为内层 Table 的 width 传下去,避免 iView 把展开行剩余空间摊到各列
|
||||
@@ -211,6 +250,7 @@ const groupColumns = [
|
||||
{ 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: 90, align: "center", render: renderStatus },
|
||||
{ title: "线状态", width: 96, align: "center", render: renderLineState },
|
||||
// GUID 列宽按 32 位 uuid 实测单行 259px + 内边距 24 定尺寸(留余量,防不同字形更宽);不再截 8 位
|
||||
{ title: "GUID", width: 320, render: (h: any, p: any) => (p.row.flowGuid
|
||||
? h("span", { title: p.row.flowGuid }, p.row.flowGuid)
|
||||
@@ -218,15 +258,16 @@ const groupColumns = [
|
||||
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
|
||||
actionColumn(
|
||||
[
|
||||
...lineActions,
|
||||
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
|
||||
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
|
||||
],
|
||||
{ mode: "text", width: 190, fixed: "right" },
|
||||
{ mode: "text", width: 260, fixed: "right" },
|
||||
),
|
||||
];
|
||||
|
||||
/** 分组态展开/收起:只由左侧箭头触发(Table 的 on-expand),行内任意位置点击不再切换 */
|
||||
function onGroupExpand(row: any, status: boolean) {
|
||||
function onGroupExpand(row?: any, status?: boolean) {
|
||||
if (row && row._key) {
|
||||
openMap.value[row._key] = !!status;
|
||||
}
|
||||
@@ -307,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 返回全量实体),直接开抽屉,无需再拉接口 */
|
||||
function onSource(row: any) {
|
||||
sourceText.value = prettyJson(row.contentJson);
|
||||
@@ -406,8 +544,8 @@ onMounted(() => {
|
||||
</RadioGroup>
|
||||
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
|
||||
{{ viewMode === "group"
|
||||
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明"
|
||||
: "平铺所有版本行(现状口径)" }}
|
||||
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立"
|
||||
: "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
import AssigneePreviewModal from "./AssigneePreviewModal.vue";
|
||||
import type { PreviewNodeConfig } from "../assignPreview";
|
||||
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -114,6 +116,49 @@ function removeUser(id: number): void {
|
||||
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 {
|
||||
const n = props.node;
|
||||
if (!n) {
|
||||
@@ -206,6 +251,20 @@ function onRemove(): void {
|
||||
</div>
|
||||
</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">
|
||||
<label>多人审批方式</label>
|
||||
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
|
||||
@@ -260,6 +319,7 @@ function onRemove(): void {
|
||||
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="dw-foot">
|
||||
@@ -278,6 +338,8 @@ function onRemove(): void {
|
||||
:userids="form.userIds"
|
||||
@on-submit="onUsersPicked"
|
||||
/>
|
||||
|
||||
<AssigneePreviewModal ref="previewModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -302,6 +364,32 @@ function onRemove(): void {
|
||||
.hint.warn {
|
||||
color: #ff9900;
|
||||
}
|
||||
.adv-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
margin: 4px 0 12px;
|
||||
border-top: 1px dashed #e8eaec;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.adv-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.adv-sub,
|
||||
.adv-badge {
|
||||
font-size: 12px;
|
||||
color: #808695;
|
||||
}
|
||||
.adv-badge {
|
||||
color: #ff9900;
|
||||
}
|
||||
.adv-body {
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #f0f0f0;
|
||||
}
|
||||
.user-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -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>
|
||||
@@ -23,6 +23,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
|
||||
<div class="plus-btn" title="在此插入节点">+</div>
|
||||
<template #content>
|
||||
<div class="menu-group">常用节点</div>
|
||||
<div class="menu-item" @click="pick('approval')">
|
||||
<span class="mi-icon approval">审</span>
|
||||
<span class="mi-text">
|
||||
@@ -37,6 +38,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
<i>知会指定人,不产生待办、不阻塞流程</i>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 高级结构:只是分组展示,四类插入能力一个都没少,插入后的 JSON 语义完全不变 -->
|
||||
<div class="menu-group adv">高级结构</div>
|
||||
<div class="menu-item" @click="pick('parallel')">
|
||||
<span class="mi-icon parallel">⇉</span>
|
||||
<span class="mi-text">
|
||||
@@ -94,6 +97,17 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
|
||||
}
|
||||
.menu-group {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 11px;
|
||||
color: #808695;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.menu-group.adv {
|
||||
margin-top: 6px;
|
||||
border-top: 1px dashed #e8eaec;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
@@ -61,6 +61,15 @@ const sourceFlowGuid = ref("");
|
||||
const appKey = ref("");
|
||||
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
|
||||
const appOptions = ref<{ appKey: string; appName: string }[]>([]);
|
||||
/**
|
||||
* 关联表单坐标(flw_def.form_uuid / form_version,v3/TASK_1):行字段、不进 contentJson。
|
||||
* 版本由选中时那一条目录行带出(中心当前发布版),不给手填——存的就是服务端核验过的坐标。
|
||||
*/
|
||||
const formUuid = ref("");
|
||||
const formVersion = ref<number | undefined>(undefined);
|
||||
/** 表单中心目录(同源代理);拉不到时选择器禁用并说明原因,不假装"这所学校没有表单" */
|
||||
const formOptions = ref<any[]>([]);
|
||||
const formCenterError = ref("");
|
||||
|
||||
const model = reactive<EditorModel>(createEmptyModel());
|
||||
/** 科室列表(deptList 聚合行):仅用于下拉选项与"无启用人员"提示 */
|
||||
@@ -249,6 +258,15 @@ async function load(): Promise<void> {
|
||||
} catch {
|
||||
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 queryGuid = (route.query.baseGuid as string) || "";
|
||||
@@ -273,6 +291,8 @@ async function load(): Promise<void> {
|
||||
model.code = d?.code || "";
|
||||
model.name = d?.name || "";
|
||||
appKey.value = d?.appKey || "";
|
||||
formUuid.value = d?.formUuid || "";
|
||||
formVersion.value = d?.formVersion == null ? undefined : Number(d.formVersion);
|
||||
try {
|
||||
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
|
||||
} catch (e: any) {
|
||||
@@ -408,6 +428,10 @@ provide<EditorCtx>(EDITOR_CTX, {
|
||||
model,
|
||||
depts,
|
||||
userNames,
|
||||
// 升版本/编辑草稿时由 defGet 带回;新建态为空串(预览据此说明"没有可比对的已发布版本")
|
||||
get flowGuid() {
|
||||
return sourceFlowGuid.value || "";
|
||||
},
|
||||
routers,
|
||||
availableVars,
|
||||
issueOf,
|
||||
@@ -430,6 +454,16 @@ function onJsonApplied(p: ParsedDef): void {
|
||||
applyParsed(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选表=成对记下 uuid 与中心当时发布版;清空则两列一起清(后端要求成对,半个坐标存不下)。
|
||||
* 版本号一律取自目录行,页面不提供手填——填一个中心没有的版本,只会在发布期撞"坐标不符"。
|
||||
*/
|
||||
function onFormChange(uuid: string): void {
|
||||
const hit = uuid ? formOptions.value.find((f) => f.formUuid === uuid) : null;
|
||||
formUuid.value = hit ? hit.formUuid : "";
|
||||
formVersion.value = hit ? Number(hit.version) : undefined;
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
if (!model.name.trim()) {
|
||||
Message.warning("请填写流程名称");
|
||||
@@ -439,10 +473,12 @@ async function onSave(): Promise<void> {
|
||||
Message.warning("请填写流程编码");
|
||||
return;
|
||||
}
|
||||
// 归属应用必填(2026-10-12 拍板):新建必须选;升版本继承源线(baseGuid 载入时已带回),
|
||||
// 源线未归属(存量 NULL)时必须在此补选
|
||||
if (!(appKey.value || "").trim()) {
|
||||
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
|
||||
// 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度,
|
||||
// 不再是新发起资格(资格=flw_usage_grant 独立授权)。显式传入时后端仍校白名单。
|
||||
// 线级停发入口提示见列表页;此处不阻断无归属定义的保存。
|
||||
// 关联表单必须成对:改造前只有关联、没有版本号的存量行,重新选一次才能保存(后端同样会拒)
|
||||
if (formUuid.value && !formVersion.value) {
|
||||
Message.warning("该流程关联的表单还没有确切版本号,请在「关联表单」里重新选择一次(版本由表单中心给出,不可手填)");
|
||||
return;
|
||||
}
|
||||
const errors = countErrors(issues.value);
|
||||
@@ -503,6 +539,9 @@ async function submitSave(changeLog?: string): Promise<void> {
|
||||
baseGuid: baseGuid.value || undefined,
|
||||
appKey: (appKey.value || "").trim() || undefined,
|
||||
changeLog: changeLog || undefined,
|
||||
// 关联表单成对提交:空串=显式取消关联(后端同时清空版本),有值=uuid + 中心当时发布版
|
||||
formUuid: formUuid.value,
|
||||
formVersion: formUuid.value ? formVersion.value : undefined,
|
||||
}).send();
|
||||
savedSnapshot.value = currentSnapshot.value;
|
||||
Message.success(willCreateVersion()
|
||||
@@ -612,11 +651,28 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
||||
:disabled="!!editId || !!baseGuid" :maxlength="40" />
|
||||
</div>
|
||||
<div class="ip-fld">
|
||||
<label>归属应用 <span style="color: #ed4014">*</span></label>
|
||||
<Select v-model="appKey" placeholder="归属应用(必选)" filterable>
|
||||
<label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label>
|
||||
<Select v-model="appKey" placeholder="归属应用(可留空)" filterable clearable>
|
||||
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="ip-fld">
|
||||
<label>关联表单<span class="ip-mini">可选 · 表单中心 · 成对存版本</span></label>
|
||||
<Select :value="formUuid" clearable filterable placeholder="不关联(原生办理)"
|
||||
:disabled="!!formCenterError" @on-change="onFormChange">
|
||||
<Option v-for="f in formOptions" :key="f.formUuid" :value="f.formUuid">
|
||||
{{ f.name }}({{ f.appCode }}/{{ f.code }} · v{{ f.version }})
|
||||
</Option>
|
||||
</Select>
|
||||
<div v-if="formCenterError" class="ip-mini" style="color: #ed4014">
|
||||
{{ formCenterError }}
|
||||
</div>
|
||||
<div v-else-if="formUuid && !formVersion" class="ip-mini" style="color: #ed4014">
|
||||
历史关联缺版本号,请重新选择表单后再保存
|
||||
</div>
|
||||
<div v-else-if="formUuid" class="ip-mini">已绑定 v{{ formVersion }}(发布时按这组坐标核验快照)</div>
|
||||
<div v-else class="ip-mini">目录只列中心已发布、且已授权给本应用的表单</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ip-sec">
|
||||
|
||||
@@ -415,6 +415,8 @@ export interface EditorCtx {
|
||||
model: EditorModel;
|
||||
depts: DeptOption[];
|
||||
userNames: Record<number, string>;
|
||||
/** 当前流程线 flowGuid(新建/尚未保存时为空):指派预览与已发布版本对账要用 */
|
||||
flowGuid: string;
|
||||
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
|
||||
routers: RouterOption[];
|
||||
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */
|
||||
|
||||
@@ -108,6 +108,15 @@ const formRows = computed(() => {
|
||||
return [{ label: "表单原文", value: String(raw) }];
|
||||
});
|
||||
|
||||
/** 业务详情链接(仅管理面):bizUrl 是裸相对路径,必须用后端解析好的绝对地址(bizUrlAbs),
|
||||
* 否则会落到流程中心自己的 origin 上;解析不出就不给入口,不给打不开的链接。 */
|
||||
const bizUrlAbs = computed(() => {
|
||||
const abs = props.detail?.bizUrlAbs;
|
||||
if (abs) return abs;
|
||||
const raw = props.detail?.bizUrl;
|
||||
return /^https?:\/\//i.test(raw || "") ? raw : "";
|
||||
});
|
||||
|
||||
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG) */
|
||||
@@ -356,9 +365,9 @@ onMounted(async () => {
|
||||
<div class="embed-meta-k">{{ m.k }}</div>
|
||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</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-v"><a :href="detail.bizUrl" target="_blank">查看业务详情</a></div>
|
||||
<div class="embed-meta-v"><a :href="bizUrlAbs" target="_blank">查看业务详情</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
// 办件详情抽屉(管理面列表页复用):非 iframe,内部直接复用 InstanceBody(full 口径)。
|
||||
// 宽度与 Notice 审批记录抽屉对齐 740px;关闭后清空状态,下次打开重新加载。
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Drawer, Tag } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { toAppHashUrl } from "@/utils/appUrl";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
@@ -49,8 +53,13 @@ const diagramSrc = computed(() => {
|
||||
|
||||
function openDiagram() {
|
||||
if (!detail.value?.instanceId) return;
|
||||
const url = `${window.location.origin}${import.meta.env.BASE_URL}#/diagram?instanceId=${detail.value.instanceId}&mode=${mode.value}`;
|
||||
window.open(url, "_blank");
|
||||
// 原写法 `${origin}${BASE_URL}#/diagram?…` 里 BASE_URL 构建期是 "./",拼出
|
||||
// `https://host./#/…`(域名尾点);改为按当前真实地址丢 hash 再接目标 hash
|
||||
const href = router.resolve({
|
||||
path: "/diagram",
|
||||
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
|
||||
}).href;
|
||||
window.open(toAppHashUrl(href), "_blank");
|
||||
}
|
||||
|
||||
function statusName(s: number) {
|
||||
|
||||
@@ -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