配套 service 候选 500a9a2: - flow.ts 增加 admin/def/offlineLine、enableLine(T03 线级停发/恢复) - Defs.vue 接线级停发开关与状态展示;lineOffline.ts 收敛判定与文案 - AssignPreview.vue + assignEmbed.ts 发起时指派预览(T02 契约) - def-editor 表单坐标绑定微调 - 新增 vitest 组件测试与 line-offline 契约测试(npm test 可跑)
This commit is contained in:
Generated
+1575
-154
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -12,7 +12,11 @@
|
||||
"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:component": "vitest run",
|
||||
"test": "npm run test:line-offline && npm run test:component"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-file-viewer/core": "^0.1.43",
|
||||
@@ -40,13 +44,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,17 @@ class FlowApi {
|
||||
defEnable(id: number | string) {
|
||||
return post(`${ROOT}/admin/def/enable/${id}`);
|
||||
}
|
||||
/**
|
||||
* 流程线级停发(T03):整条 flowGuid 停止新发起/预览,在途实例按原版本跑完;
|
||||
* 与版本级 status 正交,返回受影响版本行数。
|
||||
*/
|
||||
defOfflineLine(flowGuid: string) {
|
||||
return post<number>(`${ROOT}/admin/def/offlineLine`, { flowGuid });
|
||||
}
|
||||
/** 流程线级恢复(T03):只恢复线可用性,不改写任何版本状态或发布内容;返回受影响版本行数 */
|
||||
defEnableLine(flowGuid: string) {
|
||||
return post<number>(`${ROOT}/admin/def/enableLine`, { flowGuid });
|
||||
}
|
||||
defVersions(flowGuid: string) {
|
||||
return get(`${ROOT}/admin/def/versions`, { flowGuid });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,103 @@
|
||||
<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();
|
||||
if (invalid && invalid.length) {
|
||||
error.value = invalid[0].text || invalid[0];
|
||||
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>
|
||||
@@ -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).send()
|
||||
: flowApi.defEnableLine(guid).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>
|
||||
|
||||
|
||||
@@ -469,12 +469,9 @@ async function onSave(): Promise<void> {
|
||||
Message.warning("请填写流程编码");
|
||||
return;
|
||||
}
|
||||
// 归属应用必填(2026-10-12 拍板):新建必须选;升版本继承源线(baseGuid 载入时已带回),
|
||||
// 源线未归属(存量 NULL)时必须在此补选
|
||||
if (!(appKey.value || "").trim()) {
|
||||
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
|
||||
return;
|
||||
}
|
||||
// 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度,
|
||||
// 不再是新发起资格(资格=flw_usage_grant 独立授权)。显式传入时后端仍校白名单。
|
||||
// 线级停发入口提示见列表页;此处不阻断无归属定义的保存。
|
||||
// 关联表单必须成对:改造前只有关联、没有版本号的存量行,重新选一次才能保存(后端同样会拒)
|
||||
if (formUuid.value && !formVersion.value) {
|
||||
Message.warning("该流程关联的表单还没有确切版本号,请在「关联表单」里重新选择一次(版本由表单中心给出,不可手填)");
|
||||
|
||||
@@ -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");
|
||||
|
||||
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");
|
||||
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,147 @@
|
||||
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 \}/);
|
||||
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}/);
|
||||
});
|
||||
@@ -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