feat(flow): 落地 FL2.0 原型——部门选人抽屉 / 授权管理页 / 退回重提入口(基线 origin/main)
build / build (push) Successful in 1m31s

在 c93313c 之上增量落地原型与前端规范,保留既有流程图入口/失败提示/指派预览/线级停发:

- 审批/抄送抽屉按原型重构(422→1026 / 239→742 行):科室树+成员多选、SYSTEM/CUSTOM 分支、
  CUSTOM 分类导航自动定位、跨节点导航重置、保存后重开回显
- 新增授权管理页 /admin/usage-grants(UsageGrants.vue,417 行):校域清单、按流程线筛选、
  授予/撤销后回读,失败不宣称成功
- 路由与 API:新增 usage-grant(schoolList/summary/grant/revoke)、department(groups/options/resolve/members)、
  instance/resubmit;TaskHandle 增加「退回待修改」入口(仅 status=50 显示)
- 错误处理沿用 flowError 统一出口,不透出后端原文
- 组件/契约测试:flow-error、flow2-contract、department-roundtrip、task-result

不含构建产物(dist-verify)与依赖目录
This commit is contained in:
chenxing
2026-10-30 10:15:47 +08:00
parent c93313cdb7
commit bed57846f4
28 changed files with 2829 additions and 253 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"api:gen": "alova gen -f", "api:gen": "alova gen -f",
"typecheck": "vue-tsc --noEmit", "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: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:unit": "esbuild tests/line-offline.test.ts tests/app-url.test.ts tests/flow-error.test.ts tests/assign-preview.test.ts tests/convert-roundtrip.test.ts tests/flow2-contract.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outbase=tests --outdir=node_modules/.cache/unit && node --test node_modules/.cache/unit",
"test:component": "vitest run", "test:component": "vitest run",
"test": "npm run test:unit && npm run test:component" "test": "npm run test:unit && npm run test:component"
}, },
+71 -14
View File
@@ -18,12 +18,13 @@ function post<T = any>(url: string, body?: any, config?: Record<string, any>): M
return new Method<T>("POST", alovaInstance, url, config || {}, body); return new Method<T>("POST", alovaInstance, url, config || {}, body);
} }
/** 状态枚举(后端 FlwEnums 口径) */ /** 状态枚举(后端 FlwEnums 口径;50=D14 退回待修改,仍是在途非终态) */
export const INSTANCE_STATUS: Record<number, string> = { export const INSTANCE_STATUS: Record<number, string> = {
10: "审批中", 10: "审批中",
20: "已完成", 20: "已完成",
30: "已拒绝", 30: "已拒绝",
40: "已撤回", 40: "已撤回",
50: "退回待修改",
}; };
/** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */ /** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */
@@ -32,6 +33,7 @@ export const INSTANCE_STATUS_COLOR: Record<number, string> = {
20: "green", 20: "green",
30: "red", 30: "red",
40: "gray", 40: "gray",
50: "orange",
}; };
export const TASK_STATUS: Record<number, string> = { export const TASK_STATUS: Record<number, string> = {
@@ -85,28 +87,39 @@ class FlowApi {
return get(`${ROOT}/task/${taskId}/detail`); return get(`${ROOT}/task/${taskId}/detail`);
} }
/** 同意(多出口节点必须传 edgeName=所选出口名,单出口可省) */ /** 同意(多出口节点必须传 edgeName=所选出口名,单出口可省) */
approve(taskId: number | string, opinion?: string, edgeName?: string) { approve(taskId: number | string, opinion?: string, edgeName?: string, config?: Record<string, any>) {
return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null, edgeName: edgeName || null }); return post(`${ROOT}/task/${taskId}/approve`, { opinion: opinion || null, edgeName: edgeName || null }, config);
} }
/** 退回(targetNodeId 可选=缺省最近已办主线节点;opinion 可选) */ /** 退回(targetNodeId 可选=缺省最近已办主线节点;opinion 可选) */
returnBack(taskId: number | string, targetNodeId?: string, opinion?: string) { returnBack(taskId: number | string, targetNodeId?: string, opinion?: string, config?: Record<string, any>) {
return post(`${ROOT}/task/${taskId}/returnBack`, { targetNodeId: targetNodeId || null, opinion: opinion || null }); return post(`${ROOT}/task/${taskId}/returnBack`, { targetNodeId: targetNodeId || null, opinion: opinion || null }, config);
}
/**
* D14 退回发起人修改(2026-10-29):与普通"退回到上游审批节点"是两件事。
* 服务端把办件置 50 退回待修改并整体失效在途代次;发起人重提后同实例同轮次从第一级重审。
*/
returnToSubmitter(taskId: number | string, opinion: string, config?: Record<string, any>) {
return post(`${ROOT}/task/${taskId}/returnToSubmitter`, { opinion }, config);
}
/** 发起人重提(仅 50 退回待修改状态、仅发起人本人;同实例同轮次) */
resubmit(instanceId: number | string, config?: Record<string, any>) {
return post(`${ROOT}/instance/${instanceId}/resubmit`, {}, config);
} }
/** 移交(toUserId 必填;reason 可选) */ /** 移交(toUserId 必填;reason 可选) */
transfer(taskId: number | string, toUserId: number, reason?: string) { transfer(taskId: number | string, toUserId: number, reason?: string, config?: Record<string, any>) {
return post(`${ROOT}/task/${taskId}/transfer`, { toUserId, reason: reason || null }); return post(`${ROOT}/task/${taskId}/transfer`, { toUserId, reason: reason || null }, config);
} }
/** 拒绝(opinion 必填) */ /** 拒绝(opinion 必填) */
reject(taskId: number | string, opinion: string) { reject(taskId: number | string, opinion: string, config?: Record<string, any>) {
return post(`${ROOT}/task/${taskId}/reject`, { opinion }); return post(`${ROOT}/task/${taskId}/reject`, { opinion }, config);
} }
/** 撤回(提交人;仅尚无任务被办理时) */ /** 撤回(提交人;仅尚无任务被办理时) */
withdraw(instanceId: number | string) { withdraw(instanceId: number | string, config?: Record<string, any>) {
return post(`${ROOT}/instance/${instanceId}/withdraw`); return post(`${ROOT}/instance/${instanceId}/withdraw`, {}, config);
} }
/** 催办(提交人/管理员) */ /** 催办(提交人/管理员) */
urge(instanceId: number | string) { urge(instanceId: number | string, config?: Record<string, any>) {
return post(`${ROOT}/instance/${instanceId}/urge`); return post(`${ROOT}/instance/${instanceId}/urge`, {}, config);
} }
// ==================== 用户面:流程示意图 ==================== // ==================== 用户面:流程示意图 ====================
@@ -159,7 +172,8 @@ class FlowApi {
*/ */
defSave(body: { defSave(body: {
id?: number; id?: number;
code: string; /** 内部编码:可缺省=由后端生成稳定唯一码(A3);编辑时回传既有编码保持不变 */
code?: string;
name: string; name: string;
contentJson: string; contentJson: string;
baseGuid?: string; baseGuid?: string;
@@ -207,6 +221,49 @@ class FlowApi {
return get(`${ROOT}/admin/def/diagram`, params); return get(`${ROOT}/admin/def/diagram`, params);
} }
// ==================== 管理面:部门目录(C1,UserBase 权威来源) ====================
/** 第一级「部门组」:系统组织(SYSTEM)+ 该校自定义分类(CUSTOM) */
deptGroups() {
return get<{ departmentType: string; categoryId: number | null; name: string; departmentCount: number }[]>(
`${ROOT}/admin/department/groups`);
}
/** 第二级可选部门(缺省只返回启用且未软删者) */
deptOptions(params: { type?: string; categoryId?: number; withChildren?: boolean } = {}) {
return get<{ departmentType: string; departmentId: number; name: string; parentId: number | null;
categoryId: number | null; categoryName: string | null; enabled: boolean; deleted: boolean;
selectable: boolean; memberCount: number }[]>(`${ROOT}/admin/department/options`, params);
}
/** 已存引用的名称/状态回显(含停用与软删=UserBase resolve-batch 历史口径) */
deptResolve(type: string, departmentId: number) {
return get<any[]>(`${ROOT}/admin/department/resolve`, { type, departmentId });
}
/** 部门成员(只读预览;scope=select 新指派严档 / history 既有实例宽档) */
deptDirectoryMembers(type: string, departmentId: number, scope?: "select" | "history") {
return get<any[]>(`${ROOT}/admin/department/members`, { type, departmentId, scope });
}
// ==================== 管理面:应用使用许可(D02/D09/D13 独立授权) ====================
/** 某流程线的授权清单(含已撤销行,审计可见) */
grantList(flowGuid: string) {
return get<any[]>(`${ROOT}/admin/usage-grant/list`, { flowGuid });
}
/** 本校全部授权行(校域由管理员会话派生,前端不传 schId) */
grantSchoolList() {
return get<any[]>(`${ROOT}/admin/usage-grant/schoolList`);
}
/** 流程线 → 生效授权应用数(列表页授权摘要列) */
grantSummary() {
return get<Record<string, number>>(`${ROOT}/admin/usage-grant/summary`);
}
/** 授予/重申使用许可(appKey 由后端解析成稳定主体;学校来自管理员会话) */
grantSet(body: { flowGuid: string; appKey: string; remark?: string }, config?: Record<string, any>) {
return post(`${ROOT}/admin/usage-grant/grant`, body, config);
}
/** 撤销使用许可(enabled=0 行保留;只拦新发起,在途与已受理回执不受影响) */
grantRevoke(body: { flowGuid: string; appId: number; remark?: string }, config?: Record<string, any>) {
return post(`${ROOT}/admin/usage-grant/revoke`, body, config);
}
// ==================== 管理面:科室管理(2026-10-10,取代旧 /map/* 映射 CRUD) ==================== // ==================== 管理面:科室管理(2026-10-10,取代旧 /map/* 映射 CRUD) ====================
/** 科室列表(deptKey=自增 id 字符串 + 启用成员数;编辑器 ctx.depts 同结构) */ /** 科室列表(deptKey=自增 id 字符串 + 启用成员数;编辑器 ctx.depts 同结构) */
deptList() { deptList() {
+33 -9
View File
@@ -41,9 +41,12 @@ const contextId = ref("");
const frameEl = ref<HTMLIFrameElement | null>(null); const frameEl = ref<HTMLIFrameElement | null>(null);
/** 本次打开锁定的父↔子通道条件;非响应式:只在 open 时刷新 */ /** 本次打开锁定的父↔子通道条件;非响应式:只在 open 时刷新 */
let expectedOrigin = ""; let expectedOrigin = "";
let expectedFlowGuid = "";
let listening = false; let listening = false;
/** 本次会话是否已经由 iframe 明确答复过(确认/取消),用于区分"宿主自己关框" */ /** 本次会话是否已经收口(确认或取消各只允许一次),用于区分"宿主自己关框"与重复关闭 */
let settled = false; let settled = false;
/** 框是否处于本次打开的生命周期内:close/X/遮罩/程序关闭统一走同一 settle 路径 */
let opened = false;
function newContextId(): string { function newContextId(): string {
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@ -57,14 +60,17 @@ function closeListener(): void {
} }
function onMessage(ev: MessageEvent): void { function onMessage(ev: MessageEvent): void {
if (!opened || settled || !listening) return;
const msg: AssignEmbedMessage | null = isValidAssignMessage( const msg: AssignEmbedMessage | null = isValidAssignMessage(
ev, ev,
expectedOrigin, expectedOrigin,
frameEl.value?.contentWindow ?? null, frameEl.value?.contentWindow ?? null,
contextId.value, contextId.value,
expectedFlowGuid,
); );
if (!msg) return; if (!msg) return;
settled = true; settled = true;
opened = false;
closeListener(); closeListener();
visible.value = false; visible.value = false;
if (msg.type === "assign-cancelled") { if (msg.type === "assign-cancelled") {
@@ -72,6 +78,7 @@ function onMessage(ev: MessageEvent): void {
return; return;
} }
emit("confirmed", { emit("confirmed", {
// buildMessage 已保证确认消息必带非空 flowGuid;兜底 props 只为满足类型,不是信任来源
flowGuid: msg.flowGuid || props.flowGuid, flowGuid: msg.flowGuid || props.flowGuid,
defVersion: msg.defVersion, defVersion: msg.defVersion,
assigns: msg.assigns ?? [], assigns: msg.assigns ?? [],
@@ -83,6 +90,8 @@ function open(): void {
// 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效 // 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效
closeListener(); closeListener();
settled = false; settled = false;
expectedFlowGuid = props.flowGuid;
opened = true;
contextId.value = newContextId(); contextId.value = newContextId();
const query = `flowGuid=${encodeURIComponent(props.flowGuid)}&contextId=${encodeURIComponent(contextId.value)}`; const query = `flowGuid=${encodeURIComponent(props.flowGuid)}&contextId=${encodeURIComponent(contextId.value)}`;
frameUrl.value = toAppHashUrl(`#/assign-preview?${query}`); frameUrl.value = toAppHashUrl(`#/assign-preview?${query}`);
@@ -92,25 +101,40 @@ function open(): void {
listening = true; listening = true;
} }
/** 宿主主动收起(等价于取消):同样摘监听,避免后续消息继续影响本页 */ /**
function close(): void { * 唯一收口路径(F07):宿主程序关闭、点 X、点遮罩都进这里。
* 已确认/已取消的会话不再补发第二次;未答复过的关闭按"取消"通知宿主(不覆盖原有选择)。
*/
function settleAsCancel(): void {
closeListener(); closeListener();
if (visible.value && !settled) emit("cancelled");
visible.value = false; visible.value = false;
if (opened && !settled) {
settled = true;
opened = false;
emit("cancelled");
}
} }
// 点遮罩/X 关闭走这里;已 settled 的关闭由 iframe 自己回过话,不再补发 cancelled // v-model 把 X/遮罩置 false 时也会走这里;只保证监听被摘,emit 由 settleAsCancel 负责,避免双发
watch(visible, (val) => { watch(visible, (val) => {
if (!val) closeListener(); if (!val) settleAsCancel();
}); });
onBeforeUnmount(closeListener); watch(() => props.flowGuid, settleAsCancel);
onBeforeUnmount(settleAsCancel);
defineExpose({ open, close, contextId }); defineExpose({ open, close: settleAsCancel, contextId });
</script> </script>
<template> <template>
<Modal v-model="visible" :title="title || '选择办理人'" :width="760" :footer-hide="true" class="assign-embed-modal"> <Modal
v-model="visible"
:title="title || '选择办理人'"
:width="760"
:footer-hide="true"
class="assign-embed-modal"
@on-cancel="settleAsCancel"
>
<iframe <iframe
v-if="frameUrl" v-if="frameUrl"
ref="frameEl" ref="frameEl"
+8
View File
@@ -97,6 +97,14 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/admin/Onboarding.vue"), component: () => import("@/views/flow/admin/Onboarding.vue"),
meta: { title: "接入向导", modId: 108 }, meta: { title: "接入向导", modId: 108 },
}, },
{
// 应用使用许可(E6):flw_usage_grant 独立授权面,与流程定义归属(flw_def.app_key)、实例来源应用三者正交。
// 菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=109);本表只登记路由,菜单数据不在本工程
path: "/admin/usage-grants",
name: "flow_admin_usage_grants",
component: () => import("@/views/flow/admin/UsageGrants.vue"),
meta: { title: "使用许可", modId: 109 },
},
{ {
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout): // 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填 // 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
+14 -9
View File
@@ -2,6 +2,7 @@ import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch"; import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue"; import VueHook from "alova/vue";
import Utils from ".."; import Utils from "..";
import { describeFlowError } from "@/utils/flowError";
/* ============= 类型定义 ============= */ /* ============= 类型定义 ============= */
export interface RequestHooks { export interface RequestHooks {
/** 前置票据/通用头注入 */ /** 前置票据/通用头注入 */
@@ -10,10 +11,10 @@ export interface RequestHooks {
onLoadingStart?: () => void; onLoadingStart?: () => void;
/** 加载结束 */ /** 加载结束 */
onLoadingEnd?: () => void; onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/ /** 业务错误提示(code !== 0);第二参=带 bizCode/httpStatus 凭据的原始错误,供可信度判定(F05) */
onBizError?: (msg: string) => void; onBizError?: (msg: string, err?: unknown) => void;
/** http 错误提示(status !== 200)*/ /** http 错误提示(status !== 200) */
onHttpError?: (msg: string) => void; onHttpError?: (msg: string, err?: unknown) => void;
} }
/** 和后端约定的通用响应格式 */ /** 和后端约定的通用响应格式 */
@@ -53,7 +54,7 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
msg = "请求的资源不存在!"; msg = "请求的资源不存在!";
break; break;
default: default:
msg = res?.msg || "内部服务错误!"; msg = "服务响应异常,请刷新核对操作结果";
} }
fail(msg, { httpStatus: status }); fail(msg, { httpStatus: status });
} }
@@ -71,9 +72,13 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
window.location.href = res.urlToRedirectTo!; window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T; return new Promise(() => {}) as T;
case 4: case 4:
throw Object.assign(new Error(res.msg || "参数错误!"), { bizCode: 4 }); case 2:
case 3:
case 5:
throw Object.assign(new Error(describeFlowError({ message: res.msg || "操作被拒绝", bizCode: res.code, httpStatus: 200 })),
{ bizCode: res.code, httpStatus: 200 });
default: default:
throw Object.assign(new Error(res.msg || "业务错误"), { bizCode: res.code }); throw Object.assign(new Error("服务响应异常,请刷新核对操作结果"), { bizCode: res.code, httpStatus: 200 });
} }
} }
@@ -132,7 +137,7 @@ export function createBschAlova(
} catch (e: any) { } catch (e: any) {
console.log(e); console.log(e);
if (!isSilentBizToast(method)) onBizError(e.message); if (!isSilentBizToast(method)) onBizError(e?.message, e);
return Promise.reject(e); return Promise.reject(e);
} }
} }
@@ -143,7 +148,7 @@ export function createBschAlova(
onLoadingEnd(); onLoadingEnd();
const msg = err?.message || "网络请求出错"; const msg = err?.message || "网络请求出错";
if (!isSilentBizToast(method)) onHttpError(msg); if (!isSilentBizToast(method)) onHttpError(msg, err);
return Promise.reject(err); return Promise.reject(err);
} }
+6 -5
View File
@@ -31,13 +31,14 @@ export const alovaInstance = createBschAlova(baseUrl, {
spinInstance = Message.loading("加载中..."); spinInstance = Message.loading("加载中...");
}, },
onLoadingEnd: closeLoading, onLoadingEnd: closeLoading,
/* 全局兜底提示:具体业务 msg 原样透出,通用/异常形态换成不误导的用户话术(不泄露堆栈/SQL/令牌) */ /* 全局兜底提示(F05):只有带 bizCode 的业务信封原文才回显,
onBizError(msg: string) { HTTP 状态文本/异常形态/无凭据文本一律换成不误导的通用话术(堆栈/SQL/令牌不进页面) */
const text = describeFlowError(msg); onBizError(msg: string, err?: unknown) {
const text = describeFlowError(err ?? msg);
if (!!text) Message.error(text); if (!!text) Message.error(text);
}, },
onHttpError(msg: string) { onHttpError(msg: string, err?: unknown) {
Message.error({ title: "请求未完成", content: describeFlowError(msg) }); Message.error({ title: "请求未完成", content: describeFlowError(err ?? msg) });
}, },
}); });
+50 -8
View File
@@ -33,7 +33,8 @@ export interface AssignEmbedMessage {
export function parentOrigin(): string { export function parentOrigin(): string {
try { try {
if (document.referrer && document.referrer !== location.href) { if (document.referrer && document.referrer !== location.href) {
return new URL(document.referrer).origin; const origin = new URL(document.referrer).origin;
return origin === 'null' ? '' : origin;
} }
} catch { } catch {
/* referrer 不可解析=无锁定目标,只收不发 */ /* referrer 不可解析=无锁定目标,只收不发 */
@@ -46,28 +47,42 @@ export function buildMessage(msg: AssignEmbedMessage): AssignEmbedMessage | null
if (!msg || msg.proto !== ASSIGN_EMBED_PROTO || msg.v !== ASSIGN_EMBED_VERSION) { if (!msg || msg.proto !== ASSIGN_EMBED_PROTO || msg.v !== ASSIGN_EMBED_VERSION) {
return null; return null;
} }
if (!msg.contextId || !msg.type) { if (!isNonEmptyString(msg.contextId) || !['assign-confirmed', 'assign-cancelled'].includes(msg.type)) {
return null; return null;
} }
if (msg.type === 'assign-confirmed') { if (msg.type === 'assign-confirmed') {
if (!msg.flowGuid || !msg.defVersion || !Array.isArray(msg.assigns)) { if (!isNonEmptyString(msg.flowGuid) || !isPositiveInteger(msg.defVersion)) {
return null;
}
if (!isValidAssigns(msg.assigns)) {
return null; return null;
} }
} }
return msg; return msg;
} }
/** 接收侧(父页)校验:origin/source/形状/context 逐项核,任一不过=丢弃(不告警不打扰) */ /**
* 接收侧(父页)校验(F03 收口,2026-10-29)。
*
* <p>原实现对 `expectedOrigin`/`expectedSource` 为空的场景"跳过该道校验",属放行口径;
* 现要求三条件(精确 origin、iframe 窗口引用、本次 context)全部实到位才处理消息,
* 任一缺失或不符即丢弃——宿主拿不到未经验证的消息去覆盖节点配置。</p>
*
* <p>载荷逐字段校验:`flowGuid` 必须等于打开 iframe 时锁定的流程;`defVersion` 是正整数;
* `assigns` 是 `{nodeId: 非空字符串, userIds: 正整数 ID 数组}` 的数组,畸形载荷整条拒绝。</p>
*/
export function isValidAssignMessage( export function isValidAssignMessage(
ev: MessageEvent, ev: MessageEvent,
expectedOrigin: string, expectedOrigin: string,
expectedSource: Window | null, expectedSource: Window | null,
expectedContextId: string, expectedContextId: string,
expectedFlowGuid = '',
): AssignEmbedMessage | null { ): AssignEmbedMessage | null {
if (expectedOrigin && ev.origin !== expectedOrigin) { if (!isNonEmptyString(expectedOrigin) || expectedOrigin === '*' || expectedOrigin === 'null'
|| !expectedSource || !isNonEmptyString(expectedContextId) || !isNonEmptyString(expectedFlowGuid)) {
return null; return null;
} }
if (expectedSource && ev.source !== expectedSource) { if (ev.origin !== expectedOrigin || ev.source !== expectedSource) {
return null; return null;
} }
const data = ev.data as Partial<AssignEmbedMessage> | undefined; const data = ev.data as Partial<AssignEmbedMessage> | undefined;
@@ -77,9 +92,36 @@ export function isValidAssignMessage(
if (data.type !== 'assign-confirmed' && data.type !== 'assign-cancelled') { if (data.type !== 'assign-confirmed' && data.type !== 'assign-cancelled') {
return null; return null;
} }
if (!data.contextId || data.contextId !== expectedContextId) { if (data.contextId !== expectedContextId) {
// 旧 frame / 换线后的迟到消息:按 context 失效 // 旧 frame / 换线后的迟到消息:按 context 失效
return null; return null;
} }
return buildMessage(data as AssignEmbedMessage); const msg = buildMessage(data as AssignEmbedMessage);
if (!msg) {
return null;
}
if (msg.type === 'assign-confirmed' && expectedFlowGuid && msg.flowGuid !== expectedFlowGuid) {
// 异流程回传:可能是换线竞态或伪造,不覆盖本节点配置
return null;
}
return msg;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function isPositiveInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
function isValidAssigns(value: unknown): boolean {
if (!Array.isArray(value)) return false;
return value.every((row) => {
if (!row || typeof row !== 'object') return false;
const { nodeId, userIds } = row as { nodeId?: unknown; userIds?: unknown };
if (!isNonEmptyString(nodeId)) return false;
if (!Array.isArray(userIds) || userIds.length === 0) return false;
return userIds.every(isPositiveInteger) && new Set(userIds).size === userIds.length;
});
} }
+84 -28
View File
@@ -1,13 +1,16 @@
/** /**
* 服务端错误文案的可读化(FL上线独立验收报告 P0-1 附带项:审批失败只甩「内部服务错误」)。 * 服务端错误呈现边界(FL上线独立验收报告 P0-1 附带项 + Codex 终审 F05/F04 收口,2026-10-29)。
* *
* 边界:前端只做**文案呈现**,不猜原因、不绕校验、不把失败说成成功。 * 口径:
* 后端把某类失败收敛成具体业务码 + 人话 msg 后(如 D09「无激活凭据」),这些 msg 原样透出即可; * 1. **可信来源才回显原文**。只有本应用与后端约定的业务信封({@code {code,msg,data}},code≠0)
* 仍然只给通用占位文案(code=6 内部服务错误)或异常/SQL/令牌形态的原文时,一律换成不误导的 * 携带的 msg 才可能是"给人看的业务话",其错误对象由 alova 层挂上 {@code bizCode}。
* 通用提示,原文不进 DOM——堆栈、SQL、Token 不外泄。 * HTTP 状态文本("Internal Server Error")、异常/SQL/Token/堆栈、以及任何无法判定来源的裸文本
* 一律换成通用安全提示——不是"命中黑名单才挡",而是"拿不到可信凭据就不放行"。
* 2. **失败 ≠ 未生效**(F04)。服务端明确拒绝与"请求已发出但结果未知"必须分开说:
* 超时/断网/响应丢失时不能断言"本次办理未生效、任务仍是你的待办"。
*/ */
/** 后端/网关的通用占位文案:出现即说明没有可用诊断信息,需要换成不误导的用户话术 */ /** 通用占位文案:出现即说明没有可用诊断信息 */
const GENERIC_HINTS = [ const GENERIC_HINTS = [
"内部服务错误", "内部服务错误",
"服务器错误", "服务器错误",
@@ -20,9 +23,10 @@ const GENERIC_HINTS = [
"null", "null",
]; ];
/** 明显是异常原文而不是用户文案的形态:关键字命中即按通用提示处理,不回显原文 */ /** 异常原文形态:即使来自业务信封也不回显 */
const INTERNAL_SHAPES = [ const INTERNAL_SHAPES = [
/exception/i, /exception/i,
/(?:[A-Z]:\\|\/(?:srv|var|home|opt|etc)\/)/i,
/\bat\s+[a-z]+\.[a-z]+\./i, /\bat\s+[a-z]+\.[a-z]+\./i,
/java\./i, /java\./i,
/sql/i, /sql/i,
@@ -34,42 +38,94 @@ const INTERNAL_SHAPES = [
/^\s*\{[\s\S]*["']?(status|error|traceback)["']?\s*[:=]/i, /^\s*\{[\s\S]*["']?(status|error|traceback)["']?\s*[:=]/i,
]; ];
/** 网络/会话层:alova 封装把非 200 与 fetch 失败都收敛成这几句 */ /** 网络/会话层形态:alova 封装把 fetch 失败与部分 401/403 收敛成这几句 */
const NETWORK_SHAPES = [/^网络错误/, /网络请求出错/, /failed to fetch/i, /fetch failed/i, /network/i, /timeout/i]; const NETWORK_SHAPES = [/^网络错误/, /网络请求出错/, /failed to fetch/i, /fetch failed/i, /network/i, /timeout/i];
export const FLOW_GENERIC_FALLBACK = export const FLOW_GENERIC_FALLBACK =
"服务未能处理本次请求,操作没有生效。可稍后重试;多次失败请联系流程管理员,并提供操作时间。"; "服务未能处理本次请求。请稍后重试;多次失败请联系流程管理员,并提供操作时间。";
export const FLOW_NETWORK_FALLBACK = "网络连接异常,或登录状态已失效,请刷新页面后重试。"; export const FLOW_NETWORK_FALLBACK = "网络连接异常,或登录状态已失效,请刷新页面后重试。";
function looksGeneric(raw: string): boolean { /** F04:结果未知时的话术——不断言未生效,也不断言成功 */
const t = raw.trim(); export const FLOW_UNKNOWN_FALLBACK =
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 { export type FlowErrorKind = "business" | "network" | "unknown";
return NETWORK_SHAPES.some((re) => re.test(raw.trim()));
interface FlowErrorInfo {
kind: FlowErrorKind;
text: string;
} }
/** /**
* 业务失败 → 用户可读文案。 * 判定错误来源可信度。
* 已在契约里的具体业务 msg(如「流程线不存在:xxx」「该任务已被办理」)原样返回,不做改写; * {@code bizCode} 存在=后端业务信封明确拒绝;{@code httpStatus}/无凭据=结果不可信或未知。
* 通用/异常形态返回兜底话术。
*/ */
export function describeFlowError(raw: unknown): string { export function classifyFlowError(raw: unknown): FlowErrorInfo {
if (raw === null || raw === undefined) {
return { kind: "unknown", text: "" };
}
const err = raw as Record<string, any>;
const text = typeof raw === "string" const text = typeof raw === "string"
? raw ? raw
: raw && typeof raw === "object" && "message" in raw : typeof err.message === "string"
? String((raw as { message?: unknown }).message ?? "") ? err.message
: ""; : String(err ?? "");
if (looksNetwork(text)) return FLOW_NETWORK_FALLBACK; const trimmed = text.trim();
if (looksGeneric(text)) return FLOW_GENERIC_FALLBACK; if (typeof err.httpStatus === "number" && err.httpStatus !== 200) {
return text.trim(); return { kind: NETWORK_SHAPES.some(re => re.test(trimmed)) ? "network" : "unknown", text: trimmed };
}
if (NETWORK_SHAPES.some((re) => re.test(trimmed))) {
return { kind: "network", text: trimmed };
}
// 业务信封明确拒绝:只有带 bizCode 才认定"服务端明确答复了这次请求"
if ([2, 3, 4, 5].includes(err.bizCode)) {
return { kind: "business", text: trimmed };
}
return { kind: "unknown", text: trimmed };
} }
/** 页面内联提示用:动作名 + 可读原因 + 下一步(不重复弹全局 toast) */ function isUnsafeBusinessText(text: string): boolean {
if (!text) return true;
if (GENERIC_HINTS.some((g) => text === g || text === `${g}!` || text === `${g}!`)) return true;
return INTERNAL_SHAPES.some((re) => re.test(text));
}
/**
* 错误 → 用户可读文案。业务拒绝回显服务端原文(且过滤异常形态);网络失败走网络话术;
* 其余(HTTP 5xx 原文、无法判定来源的文本、丢响应)统一通用安全提示,不回显任意后端文本。
*/
export function describeFlowError(raw: unknown): string {
const { kind, text } = classifyFlowError(raw);
if (kind === "network") return FLOW_NETWORK_FALLBACK;
if (kind === "business") return isUnsafeBusinessText(text) ? FLOW_GENERIC_FALLBACK : text;
return FLOW_GENERIC_FALLBACK;
}
/** 服务端明确拒绝时的读法(F04 与"结果未知"配对使用) */
export function isDefiniteBusinessRejection(raw: unknown): boolean {
return classifyFlowError(raw).kind === "business";
}
/** 只读查询/列表加载失败:不声称写操作结果,只说明没读到 */
export function flowFailTip(action: string, raw: unknown): string { export function flowFailTip(action: string, raw: unknown): string {
return `${action}未完成:${describeFlowError(raw)}`; return `${action}未完成:${describeFlowError(raw)}`;
} }
/**
* 办理/提交类动作失败(F04):明确拒绝说拒绝;结果未知说"待确认",
* 不写"本次办理未生效、任务仍是你的待办"——请求可能已提交,前端无从推导回滚。
*/
export function flowSubmitFailTip(action: string, raw: unknown): string {
const { kind } = classifyFlowError(raw);
if (kind === "business") {
const { text } = classifyFlowError(raw);
return `${action}被拒绝:${isUnsafeBusinessText(text) ? FLOW_GENERIC_FALLBACK : text}`;
}
return `${action}结果待确认:${kind === "network" ? FLOW_NETWORK_FALLBACK : FLOW_UNKNOWN_FALLBACK}`;
}
/** 动作已成功、但随后的列表/详情刷新失败:不能说成整体失败 */
export function flowReloadFailTip(action: string): string {
return `${action}已提交成功,但页面数据刷新失败,请手动刷新查看最新状态。`;
}
+67 -17
View File
@@ -10,7 +10,7 @@ import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue"; import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed"; import { isBareTaskRoute, postToParent, type FlowEmbedAction } from "@/utils/taskEmbed";
import { toAppHashUrl } from "@/utils/appUrl"; import { toAppHashUrl } from "@/utils/appUrl";
import { flowFailTip } from "@/utils/flowError"; import { flowSubmitFailTip, flowReloadFailTip } from "@/utils/flowError";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -152,8 +152,11 @@ function openDiagram() {
* 避免"再点一次"),非 bare 保持原样跳工作台。 * 避免"再点一次"),非 bare 保持原样跳工作台。
*/ */
async function afterHandled(action: FlowEmbedAction, tip: string) { async function afterHandled(action: FlowEmbedAction, tip: string) {
myTaskId.value = "";
doneTip.value = tip;
try {
if (!isBare.value) { if (!isBare.value) {
router.push("/workbench"); await router.push("/workbench");
return; return;
} }
postToParent({ type: "handled", taskId: taskId.value, action }); postToParent({ type: "handled", taskId: taskId.value, action });
@@ -161,6 +164,9 @@ async function afterHandled(action: FlowEmbedAction, tip: string) {
opinion.value = ""; opinion.value = "";
chosenEdge.value = ""; chosenEdge.value = "";
await load(); await load();
} catch {
failTip.value = flowReloadFailTip("办理");
}
} }
async function submit(approved: boolean) { async function submit(approved: boolean) {
@@ -171,6 +177,8 @@ async function submit(approved: boolean) {
submitting.value = true; submitting.value = true;
failTip.value = ""; failTip.value = "";
const label = approved ? "同意" : "拒绝"; const label = approved ? "同意" : "拒绝";
// F05:本页有行内失败提示,抑制全局 toast,同一失败只出现一条
const silent = { silentBizToast: true };
try { try {
if (approved) { if (approved) {
// 多出口节点必须带所选出口 // 多出口节点必须带所选出口
@@ -180,17 +188,24 @@ async function submit(approved: boolean) {
submitting.value = false; submitting.value = false;
return; return;
} }
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send(); await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName, silent).send();
Message.success("已同意");
await afterHandled("approve", "已同意,本任务办理完成");
} else { } else {
await flowApi.reject(myTaskId.value, opinion.value.trim()).send(); await flowApi.reject(myTaskId.value, opinion.value.trim(), silent).send();
Message.success("已拒绝");
await afterHandled("reject", "已拒绝,本任务办理完成");
} }
} catch (e: any) { } catch (e: any) {
// 失败就是失败:不跳走、不清意见、不写成功文案;服务端事务已回滚,任务仍是待办 // F04:服务端明确拒绝才说"被拒绝";超时/断网/丢响应只说"结果待确认",
failTip.value = flowFailTip(label, e); // 不能断言未生效——请求可能已提交,前端无从推导回滚
failTip.value = flowSubmitFailTip(label, e);
submitting.value = false;
return;
}
// 办理已成功:后续收尾(通知父页/重新拉详情)失败不能改口说"没办成"
try {
Message.success(approved ? "已同意" : "已拒绝");
await afterHandled(approved ? "approve" : "reject",
approved ? "已同意,本任务办理完成" : "已拒绝,本任务办理完成");
} catch (e: any) {
failTip.value = flowReloadFailTip(label);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -236,14 +251,38 @@ function doReturn() {
const targets = detail.value?.returnTargets || []; const targets = detail.value?.returnTargets || [];
const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) || const known = targets.find((t: any) => String(t.nodeId) === returnTarget.value) ||
targets[targets.length - 1] || null; targets[targets.length - 1] || null;
flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined, opinion.value.trim() || undefined) flowApi.returnBack(myTaskId.value, known ? String(known.nodeId) : undefined,
opinion.value.trim() || undefined, { silentBizToast: true })
.send() .send()
.then(async () => { .then(async () => {
Message.success("已退回"); Message.success("已退回");
showReturn.value = false; showReturn.value = false;
await afterHandled("return", "已退回,目标节点重新生成待办"); await afterHandled("return", "已退回,目标节点重新生成待办");
}) })
.catch((e: any) => { failTip.value = flowFailTip("退回", e); }) .catch((e: any) => { failTip.value = flowSubmitFailTip("退回", e); })
.finally(() => { submitting.value = false; });
}
/**
* D14 退回发起人修改(与上面的"退回到上游审批节点"是两件事,弹窗文案分开):
* 服务端置办件为 50 退回待修改、失效全部在途代次;发起人重提后同实例同轮次从第一级重审。
* 只有服务端返回成功才更新本页状态。
*/
function doReturnToSubmitter() {
if (!opinion.value.trim()) {
Message.warning("退回发起人修改必须填写退回原因");
return;
}
submitting.value = true;
failTip.value = "";
flowApi.returnToSubmitter(myTaskId.value, opinion.value.trim(), { silentBizToast: true })
.send()
.then(async () => {
Message.success("已退回发起人修改");
showReturn.value = false;
await afterHandled("return", "已退回发起人修改,等待发起人重新提交");
})
.catch((e: any) => { failTip.value = flowSubmitFailTip("退回发起人", e); })
.finally(() => { submitting.value = false; }); .finally(() => { submitting.value = false; });
} }
@@ -258,12 +297,13 @@ async function doTransfer() {
failTip.value = ""; failTip.value = "";
try { try {
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入) // 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send(); await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined, { silentBizToast: true })
.send();
Message.success("已移交"); Message.success("已移交");
showTransfer.value = false; showTransfer.value = false;
await afterHandled("transfer", "已移交,本任务由接手人办理"); await afterHandled("transfer", "已移交,本任务由接手人办理");
} catch (e: any) { } catch (e: any) {
failTip.value = flowFailTip("移交", e); failTip.value = flowSubmitFailTip("移交", e);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -355,7 +395,7 @@ onBeforeUnmount(() => {
placeholder="同意可选填意见;拒绝必须填写意见" /> placeholder="同意可选填意见;拒绝必须填写意见" />
<Alert v-if="failTip" type="error" show-icon class="fail-tip"> <Alert v-if="failTip" type="error" show-icon class="fail-tip">
{{ failTip }} {{ failTip }}
<div class="muted">本次办理未生效,任务仍是你的待办;可稍后重试,或把页面提示与操作时间反馈给流程管理员。</div> <div class="muted">请先刷新核对最新状态;如仍有疑问,把页面提示与操作时间反馈给流程管理员。</div>
</Alert> </Alert>
<div class="actions" v-if="myTaskId"> <div class="actions" v-if="myTaskId">
<template v-if="confirming"> <template v-if="confirming">
@@ -383,9 +423,14 @@ onBeforeUnmount(() => {
:label="t.nodeName || t.nodeId" /> :label="t.nodeName || t.nodeId" />
</Select> </Select>
<Button size="small" type="primary" :loading="submitting" @click="doReturn">确认退回</Button> <Button size="small" type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
<Button size="small" :loading="submitting" @click="doReturnToSubmitter">退回发起人修改</Button>
<Button size="small" @click="showReturn = false">取消</Button> <Button size="small" @click="showReturn = false">取消</Button>
</div> </div>
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</div> <div class="muted modal-tip">
退回节点:目标节点重新生成待办,并行分支内节点不可作为退回目标。<br>
退回发起人修改:办件转入「退回待修改」,发起人修改后重新提交仍属同一办件、同一申报轮次,
历史审批记录保留,并从第一级重新审批。
</div>
</div> </div>
<div v-if="isBare && showTransfer" class="inline-panel"> <div v-if="isBare && showTransfer" class="inline-panel">
@@ -442,9 +487,14 @@ onBeforeUnmount(() => {
:label="t.nodeName || t.nodeId" /> :label="t.nodeName || t.nodeId" />
</Select> </Select>
</div> </div>
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</div> <div class="muted modal-tip">
退回节点:目标节点重新生成待办,并行分支内节点不可作为退回目标。<br>
退回发起人修改:办件转入「退回待修改」,发起人重新提交后仍属同一办件、同一申报轮次,
历史审批记录保留,并从第一级重新审批(须填写退回原因)。
</div>
<template #footer> <template #footer>
<Button @click="showReturn = false">取消</Button> <Button @click="showReturn = false">取消</Button>
<Button :loading="submitting" @click="doReturnToSubmitter">退回发起人修改</Button>
<Button type="primary" :loading="submitting" @click="doReturn">确认退回</Button> <Button type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
</template> </template>
</Modal> </Modal>
+33 -4
View File
@@ -10,6 +10,7 @@ import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue"; import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
import { OperatorEnum } from "@/types"; import { OperatorEnum } from "@/types";
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow"; import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import { flowSubmitFailTip } from "@/utils/flowError";
import { actionColumn } from "@/utils/tableActions"; import { actionColumn } from "@/utils/tableActions";
type TabKey = "todo" | "done" | "cc" | "my"; type TabKey = "todo" | "done" | "cc" | "my";
@@ -85,10 +86,14 @@ const myColumns = [
actionColumn( actionColumn(
[ [
{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) }, { key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) },
{ key: "withdraw", label: "撤回", icon: "icon-yaohao", on: (r: any) => onWithdraw(r), disabledWhen: (r: any) => r.status !== 10, danger: true }, {
key: "resubmit", label: "重新提交", icon: "icon-fasong", on: (r: any) => onResubmit(r),
disabledWhen: (r: any) => r.status !== 50,
},
{ key: "withdraw", label: "撤回", icon: "icon-yaohao", on: (r: any) => onWithdraw(r), disabledWhen: (r: any) => r.status !== 10 && r.status !== 50, danger: true },
{ key: "urge", label: "催办", icon: "icon-fasong", on: (r: any) => onUrge(r), disabledWhen: (r: any) => r.status !== 10 }, { key: "urge", label: "催办", icon: "icon-fasong", on: (r: any) => onUrge(r), disabledWhen: (r: any) => r.status !== 10 },
], ],
{ mode: "text", width: 180, fixed: "right" }, { mode: "text", width: 230, fixed: "right" },
), ),
]; ];
@@ -130,11 +135,34 @@ async function onWithdraw(row: any) {
info: "撤回后流程终止,不可恢复", info: "撤回后流程终止,不可恢复",
async onOk() { async onOk() {
try { try {
await flowApi.withdraw(row.instanceId).send(); await flowApi.withdraw(row.instanceId, { silentBizToast: true }).send();
Message.success("已撤回"); Message.success("已撤回");
myTableRef.value?.reloadData(OperatorEnum.edit); myTableRef.value?.reloadData(OperatorEnum.edit);
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || "撤回失败(可能已有任务被办理)"); Message.error(flowSubmitFailTip("撤回", e));
}
},
});
}
/**
* D14 发起人重提:只对「退回待修改」(status=50)可用。
* 文案把话说清——同一办件、同一申报轮次,重新提交后从第一级重新审批,历史记录保留;
* 业务表单内容请在来源应用里改,FL 不代替业务系统编辑数据。
*/
async function onResubmit(row: any) {
await MyConfirm({
title: "重新提交",
msg: `确定重新提交办件「${row.bizTitle}」吗?`,
info: "仍是同一办件、同一申报轮次,历史审批记录保留,并从第一级审批重新开始。"
+ "需要修改的业务内容请先在来源应用内改好再提交。",
async onOk() {
try {
await flowApi.resubmit(row.instanceId, { silentBizToast: true }).send();
Message.success("已重新提交,流程从第一级重新审批");
myTableRef.value?.reloadData(OperatorEnum.edit);
} catch (e: any) {
Message.error(flowSubmitFailTip("重新提交", e));
} }
}, },
}); });
@@ -205,6 +233,7 @@ onMounted(loadApps);
<Option :value="20" label="已完成" /> <Option :value="20" label="已完成" />
<Option :value="30" label="已拒绝" /> <Option :value="30" label="已拒绝" />
<Option :value="40" label="已撤回" /> <Option :value="40" label="已撤回" />
<Option :value="50" label="退回待修改" />
</Select> </Select>
<Select v-model="filters.my" class="search-wrap_sel" clearable filterable placeholder="按应用筛选" <Select v-model="filters.my" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
@on-change="onFilterChange('my')"> @on-change="onFilterChange('my')">
+8 -3
View File
@@ -448,7 +448,7 @@ watch(tab, (t) => {
<div class="main-container yh-tabs-card"> <div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" v-model="tab"> <Tabs class="tabs-panel" v-model="tab">
<TabPane label="应用管理" name="apps"></TabPane> <TabPane label="应用管理" name="apps"></TabPane>
<TabPane label="科室管理" name="depts"></TabPane> <TabPane label="科室管理(旧机制)" name="depts"></TabPane>
<TabPane label="对接健康度" name="health"></TabPane> <TabPane label="对接健康度" name="health"></TabPane>
</Tabs> </Tabs>
@@ -469,13 +469,18 @@ watch(tab, (t) => {
</div> </div>
</div> </div>
<!-- 科室管理 --> <!-- 科室管理:迁移完成前暂留,仅供历史 DEPT_KEY 定义继续解析与清理,不是新配置的权威来源 -->
<div class="tab-body" v-show="tab === 'depts'"> <div class="tab-body" v-show="tab === 'depts'">
<Alert type="warning" show-icon class="mb-12">
本页是<b>旧机制</b>:FL 本地科室表只继续解析<b>已发布定义里遗留的「按科室」节点</b>。
新建/编辑流程的部门主体一律走 UserBase 部门目录(流程编辑器「部门」选项),
在这里新建科室<b>不会</b>让新流程可选,也不会改变部门主体的候选人。
</Alert>
<div class="fix mb-12"> <div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="onCreate">新建科室</Button> <Button class="fl" type="primary" size="large" @click="onCreate">新建科室</Button>
<Button class="fl" size="large" style="margin-left: 8px" @click="onGroupPreview">一键拉取教师组</Button> <Button class="fl" size="large" style="margin-left: 8px" @click="onGroupPreview">一键拉取教师组</Button>
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px"> <span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
流程节点「按科室」办理的解析源;重复拉取=刷新组名与成员集(不重复建组) 仅供遗留「按科室」节点解析;重复拉取=刷新组名与成员集(不重复建组)
</span> </span>
</div> </div>
<div class="table-region" ref="deptRegionRef"> <div class="table-region" ref="deptRegionRef">
+78 -2
View File
@@ -38,6 +38,11 @@ const viewMode = ref<"group" | "flat">("group");
const openMap = ref<Record<string, boolean>>({}); const openMap = ref<Record<string, boolean>>({});
// 议题二④:flowGuid → 在途办件数;拉取失败降级空 map(行内显示「—」,列表不崩) // 议题二④:flowGuid → 在途办件数;拉取失败降级空 map(行内显示「—」,列表不崩)
const inflightMap = ref<Record<string, number>>({}); const inflightMap = ref<Record<string, number>>({});
// E6:flowGuid → 生效授权应用数(使用许可摘要,与「应用」列的归属口径无关);缺键=0 是后端真实计数
const grantMap = ref<Record<string, number>>({});
const grantSummaryFailed = ref(false);
// 摘要接口只在本页实例内提示一次:load() 会被筛选/各动作反复触发,不能每次都弹
const grantSummaryWarned = ref(false);
/** 在途展示:数字=件数(0 也展示);undefined=接口无该线数据 → 「—」 */ /** 在途展示:数字=件数(0 也展示);undefined=接口无该线数据 → 「—」 */
function inflightText(guid: string): string { function inflightText(guid: string): string {
@@ -97,6 +102,17 @@ const renderRunState = (h: any, p: any) => {
]); ]);
}; };
/** 授权摘要列(E6):生效授权应用数。接口失败整列显示 -,不把"没读到"报成 0 个许可 */
const renderGrantSummary = (h: any, p: any) => {
if (!p.row.flowGuid) {
return h("span", { style: "color:#999" }, "—");
}
if (grantSummaryFailed.value) {
return h("span", { style: "color:#999" }, "-");
}
return h("span", String(Number(grantMap.value[p.row.flowGuid]) || 0));
};
/** 固定宽文本列 + 省略号 + Tooltip:列上写了 render 时 iView 内置 column.tooltip 不生效(cell 只在 /** 固定宽文本列 + 省略号 + Tooltip:列上写了 render 时 iView 内置 column.tooltip 不生效(cell 只在
* renderType==='normal' 分支渲染 tooltip),故手挂 Tooltip;ellipsis 仍走 column 配置。 */ * renderType==='normal' 分支渲染 tooltip),故手挂 Tooltip;ellipsis 仍走 column 配置。 */
const ellipsisCell = (key: string, tag = "span") => (h: any, p: any) => h(Tooltip, { const ellipsisCell = (key: string, tag = "span") => (h: any, p: any) => h(Tooltip, {
@@ -126,14 +142,21 @@ const lineActions = [
{ 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) }, { key: "lineEnable", label: "恢复流程线", icon: "icon-play", on: (r: any) => onLineEnable(r), when: (r: any) => !!r.flowGuid && Number(r.lineOffline) === 1, disabledWhen: (r: any) => isLineBusy(r) },
]; ];
/** 使用许可入口(E6):授权是流程线级、且与本页的归属/发布状态无关,故只跳转不就地改 */
const grantAction = {
key: "usageGrants", label: "使用许可", icon: "icon-shezhi", on: (r: any) => onViewGrants(r),
when: (r: any) => !!r.flowGuid,
};
/** 平铺态(每行=一个版本)同时提供线级入口;展开的版本子表仍只用版本级操作,避免误操作整线 */ /** 平铺态(每行=一个版本)同时提供线级入口;展开的版本子表仍只用版本级操作,避免误操作整线 */
const flatActions = [...versionActions, ...lineActions]; const flatActions = [...versionActions, ...lineActions, grantAction];
const columns = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" }, { title: "序号", type: "index", width: 70, align: "center" },
{ title: "流程", key: "name", minWidth: 180 }, { title: "流程", key: "name", minWidth: 180 },
{ title: "编码", key: "code", width: 200 }, { title: "编码", key: "code", width: 200 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp }, { title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
{ title: "授权摘要", width: 90, align: "center", render: renderGrantSummary },
{ title: "版本", key: "version", width: 70, align: "center" }, { title: "版本", key: "version", width: 70, align: "center" },
{ title: "状态", width: 90, align: "center", render: renderStatus }, { title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "线状态", width: 96, align: "center", render: renderLineState }, { title: "线状态", width: 96, align: "center", render: renderLineState },
@@ -154,11 +177,27 @@ async function load() {
try { try {
rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || []; rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || [];
loadInflight(); loadInflight();
loadGrantSummary();
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/** 授权摘要(E6):与列表同批取,失败只置标记 + 提示一次,列内显示 -(不冒充 0) */
async function loadGrantSummary() {
try {
grantMap.value = ((await flowApi.grantSummary().send()) as any) || {};
grantSummaryFailed.value = false;
} catch (e: any) {
grantMap.value = {};
grantSummaryFailed.value = true;
if (!grantSummaryWarned.value) {
grantSummaryWarned.value = true;
Message.warning(`授权摘要加载失败,该列暂显示为 -:${e?.message || "接口无响应"}`);
}
}
}
/** 在途统计(议题二④):列表到位后取 distinct flowGuids 批量查,失败静默降级(行内显示「—」) */ /** 在途统计(议题二④):列表到位后取 distinct flowGuids 批量查,失败静默降级(行内显示「—」) */
async function loadInflight() { async function loadInflight() {
const guids = Array.from(new Set(rows.value.map((r: any) => r.flowGuid).filter(Boolean))).slice(0, 100); const guids = Array.from(new Set(rows.value.map((r: any) => r.flowGuid).filter(Boolean))).slice(0, 100);
@@ -248,6 +287,8 @@ const groupColumns = [
// 编码实测单行 155px + 内边距 24 → 160 会折行,故 190 // 编码实测单行 155px + 内边距 24 → 160 会折行,故 190
{ title: "编码", key: "code", width: 200 }, { title: "编码", key: "code", width: 200 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp }, { title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
// 授权摘要(E6):本校该流程线上「生效」许可的应用数;与「应用」列的归属无关
{ title: "授权摘要", width: 90, align: "center", render: renderGrantSummary },
{ title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) }, { title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) },
{ title: "状态", width: 90, align: "center", render: renderStatus }, { title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "线状态", width: 96, align: "center", render: renderLineState }, { title: "线状态", width: 96, align: "center", render: renderLineState },
@@ -259,10 +300,12 @@ const groupColumns = [
actionColumn( actionColumn(
[ [
...lineActions, ...lineActions,
grantAction,
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) }, { key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) }, { key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
], ],
{ mode: "text", width: 260, fixed: "right" }, // maxInline 4:四动作全部直出,避免把既有「编辑信息」挤进「更多」削弱原入口
{ mode: "text", width: 320, maxInline: 4, fixed: "right" },
), ),
]; ];
@@ -472,6 +515,15 @@ function onDiagram(row: any) {
router.push({ path: "/diagram", query: { defId: row.id } }); router.push({ path: "/diagram", query: { defId: row.id } });
} }
/** 使用许可(E6):跳独立授权页并预选该流程线;归属应用与授权许可是两套数据,不在本页就地改 */
function onViewGrants(row: any) {
if (!row.flowGuid) {
Message.warning("该行缺少流程线标识,无法定位使用许可");
return;
}
router.push({ path: "/admin/usage-grants", query: { flowGuid: String(row.flowGuid) } });
}
// ---- 编辑信息(元数据原地编辑,2026-10-10 议题2:改名称/归属应用不升版本) ---- // ---- 编辑信息(元数据原地编辑,2026-10-10 议题2:改名称/归属应用不升版本) ----
const metaVisible = ref(false); const metaVisible = ref(false);
const metaSaving = ref(false); const metaSaving = ref(false);
@@ -547,6 +599,13 @@ onMounted(() => {
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立" ? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立"
: "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }} : "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }}
</span> </span>
<!-- 三处入口分属三套数据(E6):不写清就会被读成"选了应用即自动获得发起权" -->
<p class="grants-hint fl" style="width: 100%">
「应用」列=流程定义归属(<code>flw_def.app_key</code>,创建方与审计元数据);
「授权摘要」列=该流程线上生效的<b>使用许可</b>数(<code>flw_usage_grant</code>,独立授权记录);
办件详情的来源应用=<b>实例</b>实际发起方。三者互不等价:归属某应用不等于该应用可发起,
未授予许可也<b>不影响流程可见与编辑</b>,只影响能否发起。点行内「使用许可」到独立页授予/撤销。
</p>
</div> </div>
<div class="table-region" ref="defsRegionRef"> <div class="table-region" ref="defsRegionRef">
@@ -606,4 +665,21 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* 三概念口径说明(E6):紧贴筛选行,避免管理员把「应用」列读成授权 */
.grants-hint {
margin-top: 6px;
color: #808695;
font-size: 12px;
line-height: 1.8;
}
.grants-hint b {
color: #17233d;
}
.grants-hint code {
background: #f0f2f5;
padding: 0 4px;
border-radius: 2px;
font-size: 11px;
}
</style> </style>
+1
View File
@@ -88,6 +88,7 @@ function onSearch() {
<Option :value="20" label="已完成" /> <Option :value="20" label="已完成" />
<Option :value="30" label="已拒绝" /> <Option :value="30" label="已拒绝" />
<Option :value="40" label="已撤回" /> <Option :value="40" label="已撤回" />
<Option :value="50" label="退回待修改" />
<Option value="hung" label="路由挂起" /> <Option value="hung" label="路由挂起" />
</Select> </Select>
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search" <Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
+417
View File
@@ -0,0 +1,417 @@
<script setup lang="ts">
// 应用使用许可管理页(E6,2026-10-28):flw_usage_grant 的独立授权面。
// 后端授权闸门(fail-closed)已生效,此前无前端入口,管理员无法授予/撤销 —— 本页补上入口,接口全部复用既有 /admin/usage-grant/*。
// 数据源恒为 grantSchoolList()(校域由管理员会话派生,**没有也不该有 schId 入参**);筛选只做客户端二次过滤,
// 因为取数本身已是服务端校域范围,不再假装"按流程线服务端分页"。
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { Message, Tag, Button } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import { flowApi } from "@/api/module/flow";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import { flowFailTip, flowSubmitFailTip } from "@/utils/flowError";
const route = useRoute();
const loading = ref(false);
const rows = ref<any[]>([]);
/** 清单加载失败的原因(非空即"没读到",不得渲染成空表冒充成功) */
const loadError = ref("");
// 流程线下拉/名称回显(defList 全量版本 → 按 flowGuid 去重,取最高版本的名称)
const flows = ref<{ flowGuid: string; name: string; code: string }[]>([]);
const flowNameMap = ref<Record<string, string>>({});
const flowsError = ref("");
// 授予弹窗的应用下拉:本校已开启应用白名单(flw_app enabled=1);
// 拉取失败时保持下拉禁用(后端会把 appKey 解析成 UserBase 稳定主体并校验校内开通,手填必然被拒)
const apps = ref<{ appKey: string; appName: string }[]>([]);
const appsError = ref("");
const filterFlow = ref("");
const filterApp = ref("");
/** "" = 全部;"1" = 生效;"0" = 已撤销 */
const filterStatus = ref("");
const regionRef = ref<HTMLElement | null>(null);
const tableH = useElementHeight(regionRef);
/** 更新时间格式化 YYYY-MM-DD HH:mm(后端 LocalDateTime 带 T,空值显示 -) */
function fmtTime(raw: any): string {
return raw ? String(raw).replace("T", " ").slice(0, 16) : "-";
}
/** 流程线名称:定义清单没加载成功时不猜名字,直接说明口径缺失 */
function flowNameOf(guid: string): string {
const n = flowNameMap.value[String(guid || "")];
if (n) return n;
return flowsError.value ? "名称未加载(流程清单获取失败)" : "(流程定义不存在或已改名)";
}
/** 应用展示名:快照优先(grant 当时写入的 appNameSnapshot),缺省回退 appKeySnapshot */
function appLabelOf(row: any): string {
return row.appNameSnapshot || row.appKeySnapshot || "-";
}
const filteredRows = computed(() => {
return rows.value.filter((r: any) => {
if (filterFlow.value && String(r.flowGuid || "") !== filterFlow.value) return false;
if (filterApp.value && String(r.appKeySnapshot || "") !== filterApp.value) return false;
if (filterStatus.value !== "" && Number(r.enabled) !== Number(filterStatus.value)) return false;
return true;
});
});
/** 应用筛选项:按已取回清单里的 appKeySnapshot 去重(含已停用/已撤销的历史快照,不只本校当前白名单) */
const appFilterOptions = computed(() => {
const seen = new Map<string, string>();
rows.value.forEach((r: any) => {
const k = String(r.appKeySnapshot || "");
if (k && !seen.has(k)) seen.set(k, r.appNameSnapshot || k);
});
return Array.from(seen.entries()).map(([appKey, appName]) => ({ appKey, appName }));
});
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{
title: "流程线",
minWidth: 260,
render: (h: any, p: any) =>
h("div", { class: "ug-cell2" }, [
h("span", { class: "ug-cell-n" }, flowNameOf(p.row.flowGuid)),
h("span", { class: "ug-cell-k" }, p.row.flowGuid || "—"),
]),
},
{
title: "应用",
minWidth: 200,
render: (h: any, p: any) =>
h("div", { class: "ug-cell2" }, [
h("span", { class: "ug-cell-n" }, appLabelOf(p.row)),
h("span", { class: "ug-cell-k" }, p.row.appKeySnapshot || "-"),
]),
},
// 稳定主体(UserBase appId):撤销按它下刀,appKey 换凭据不影响已授予的许可
{ title: "稳定主体", width: 110, align: "center", render: (h: any, p: any) => h("span", p.row.appId == null ? "-" : String(p.row.appId)) },
{
title: "状态",
width: 100,
align: "center",
render: (h: any, p: any) =>
Number(p.row.enabled) === 1 ? h(Tag, { color: "green" }, () => "生效") : h(Tag, () => "已撤销"),
},
{
title: "备注",
minWidth: 180,
render: (h: any, p: any) => (p.row.remark
? h("span", String(p.row.remark))
: h("span", { style: "color:#999" }, "(未填写)")),
},
{ title: "更新时间", width: 150, render: (h: any, p: any) => h("span", fmtTime(p.row.updateTime || p.row.createTime)) },
actionColumn(
[
{ key: "revoke", label: "撤销", icon: "icon-stop", on: (r: any) => onRevoke(r), when: (r: any) => Number(r.enabled) === 1, danger: true },
// 已撤销行保留在清单里(enabled=0 不删行),给一条原地重申的通路,免得管理员去猜 appKey
{ key: "regrant", label: "重新授予", icon: "icon-play", on: (r: any) => onRegrant(r), when: (r: any) => Number(r.enabled) !== 1 },
],
{ mode: "text", width: 130, fixed: "right" },
),
];
async function load() {
loading.value = true;
loadError.value = "";
try {
rows.value = ((await flowApi.grantSchoolList().send()) as any[]) || [];
} catch (e: any) {
rows.value = [];
loadError.value = flowFailTip("使用许可清单加载", e);
} finally {
loading.value = false;
}
}
/** 流程线清单(下拉 + 名称回显):失败置 flowsError,下拉留空并说明原因 */
async function loadFlows() {
flowsError.value = "";
try {
const list: any[] = (await flowApi.defList().send()) || [];
const best: Record<string, any> = {};
list.forEach((r: any) => {
const g = String(r.flowGuid || "");
if (!g) return;
if (!best[g] || Number(r.version) > Number(best[g].version)) best[g] = r;
});
flows.value = Object.keys(best)
.map((g) => ({ flowGuid: g, name: best[g].name || g, code: best[g].code || "" }))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
const map: Record<string, string> = {};
flows.value.forEach((f) => { map[f.flowGuid] = f.name; });
flowNameMap.value = map;
} catch (e: any) {
flows.value = [];
flowNameMap.value = {};
flowsError.value = flowFailTip("流程清单加载", e);
}
}
/** 应用白名单(授予下拉数据源):只取 enabled=1,与后端「校内已开通」口径对齐 */
async function loadApps() {
appsError.value = "";
try {
const list: any[] = (await flowApi.appList().send()) || [];
apps.value = list
.filter((r: any) => r.enabled === 1)
.map((r: any) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
} catch (e: any) {
apps.value = [];
appsError.value = flowFailTip("本校应用清单加载", e);
}
}
// ==================== 授予 ====================
const grantVisible = ref(false);
const grantSaving = ref(false);
const grantError = ref("");
const grantForm = ref<{ flowGuid: string; appKey: string; remark: string }>({ flowGuid: "", appKey: "", remark: "" });
function onGrant() {
grantError.value = "";
grantForm.value = { flowGuid: filterFlow.value || "", appKey: "", remark: "" };
grantVisible.value = true;
}
/** 重申:把已撤销行的 appKey 直接填进同一个表单,管理员确认后走同一条 grant 接口 */
function onRegrant(row: any) {
grantError.value = "";
grantForm.value = { flowGuid: String(row.flowGuid || ""), appKey: String(row.appKeySnapshot || ""), remark: row.remark || "" };
grantVisible.value = true;
}
/** 当前 appKey 不在启用集合时兜底展示(重申历史快照可能已停用,仍要看得懂选的是什么,保存由后端拦) */
const grantAppOptions = computed(() => {
const list = apps.value.map((a) => ({ value: a.appKey, label: a.appName }));
const cur = grantForm.value.appKey;
if (cur && !list.some((o) => o.value === cur)) {
list.unshift({ value: cur, label: `${cur}(当前未开启,保存可能被拒)` });
}
return list;
});
async function doGrant() {
if (!grantForm.value.flowGuid) {
grantError.value = "请选择流程线";
return;
}
if (!grantForm.value.appKey) {
grantError.value = "请选择要授予许可的应用";
return;
}
grantSaving.value = true;
grantError.value = "";
const body = {
flowGuid: grantForm.value.flowGuid,
appKey: grantForm.value.appKey,
remark: grantForm.value.remark.trim() || undefined,
};
try {
// 本页有行内失败面,抑制全局 toast,同一失败只出现一条
await flowApi.grantSet(body, { silentBizToast: true }).send();
grantVisible.value = false;
// 成功后一律回读后端,不本地拼行(appId/快照名由服务端解析,前端无从推导)
await load();
if (!loadError.value) {
Message.success("已授予使用许可,清单已按后端结果刷新");
} else {
Message.warning("授予已提交成功,但清单刷新失败,请重试查看最新状态");
}
} catch (e: any) {
grantError.value = flowSubmitFailTip("授予", e);
} finally {
grantSaving.value = false;
}
}
// ==================== 撤销 ====================
function onRevoke(row: any) {
if (row.appId == null) {
Message.warning("该记录缺少稳定主体 appId,无法撤销,请在后端核对数据来源");
return;
}
MyConfirm({
title: "撤销使用许可",
msg: `确定撤销「${appLabelOf(row)}」对流程「${flowNameOf(row.flowGuid)}」的使用许可吗?`,
// 影响面按闸门语义写死:只拦新发起,不回溯在途与已受理请求
info: "撤销只阻止该应用的新发起;在途办件与已受理的请求回执不受影响,历史记录保留(该行以「已撤销」状态继续在清单中可审计)",
async onOk() {
try {
await flowApi.grantRevoke({ flowGuid: String(row.flowGuid), appId: Number(row.appId) }, { silentBizToast: true }).send();
await load();
if (!loadError.value) {
Message.success("已撤销使用许可,清单已按后端结果刷新");
} else {
Message.warning("撤销已提交成功,但清单刷新失败,请重试查看最新状态");
}
} catch (e: any) {
Message.error(flowSubmitFailTip("撤销", e));
}
},
});
}
onMounted(() => {
// 流程定义页「使用许可」跳转带 flowGuid,预选到该流程线
const q = route.query.flowGuid;
if (q) filterFlow.value = String(q);
load();
loadFlows();
loadApps();
});
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<!-- 三个概念必须同屏讲清:归属≠许可,许可≠可见性 -->
<div class="ug-explain mb-12">
<div class="ug-explain-t">「谁建的流程」「谁能发起」「这单是谁发的」是三件事,本页只管第二件</div>
<div class="ug-concepts">
<div class="ug-concept">
<b>① 流程定义归属</b>
<span>记在 <code>flw_def.app_key</code>,只表示这条流程线由哪个应用创建/归属,是审计与筛选用的元数据,<b>不产生任何发起权限</b>。</span>
</div>
<div class="ug-concept">
<b>② 应用使用许可</b>
<span>即本页管理的 <code>flw_usage_grant</code> 独立授权记录:授予谁,谁才能发起这条流程线的办件。与归属相互独立——可以给非归属应用授予,也可以撤销归属应用自己的许可。</span>
</div>
<div class="ug-concept">
<b>③ 实例来源应用</b>
<span>办件发起时实际调用方解析出的稳定主体,随办件留档只用于追溯,不反过来参与授权判定。</span>
</div>
</div>
<div class="ug-notes">
<div>· 授予许可要求该应用<b>已在该校开通</b>:后端会把 appKey 解析成 UserBase 稳定主体并校验校内开通,未开通或 UserBase 不可达会被直接拒绝。</div>
<div>· 新建流程<b>不因未授权而不可见</b>——授权只影响「能否发起」,流程定义本身仍然可见、可编辑、可发布。</div>
<div>· 撤销许可只阻止新发起,在途办件与已受理的请求回执不受影响;授权行不物理删除,撤销后以「已撤销」状态保留可审计。</div>
</div>
</div>
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="onGrant">授予使用许可</Button>
<Select v-model="filterFlow" clearable filterable placeholder="按流程线筛选" class="fl"
style="width: 240px; margin-left: 8px">
<Option v-for="f in flows" :key="f.flowGuid" :value="f.flowGuid">{{ f.name }}</Option>
</Select>
<Select v-model="filterApp" clearable filterable placeholder="按应用筛选" class="fl"
style="width: 200px; margin-left: 8px">
<Option v-for="a in appFilterOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
<Select v-model="filterStatus" clearable placeholder="授权状态" class="fl" style="width: 140px; margin-left: 8px">
<Option value="1">生效</Option>
<Option value="0">已撤销</Option>
</Select>
<Button class="fl" size="large" style="margin-left: 8px" :loading="loading" @click="load()">刷新</Button>
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
清单为本校全部授权行(校域由管理员会话派生);筛选只在已取回的数据上做二次过滤
</span>
</div>
<!-- 失败必须可见:加载失败/流程清单失败/应用清单失败各自一条,不靠空表暗示"没有数据" -->
<Alert v-if="loadError" type="error" show-icon class="mb-12">
{{ loadError }}
<Button size="small" type="error" style="margin-left: 10px" @click="load()">重试</Button>
</Alert>
<Alert v-if="flowsError" type="warning" show-icon class="mb-12">
{{ flowsError }}:流程线名称与「流程线」下拉暂无候选(授权行本身仍来自后端清单,不受影响)
<Button size="small" type="warning" style="margin-left: 10px" @click="loadFlows()">重试流程清单</Button>
</Alert>
<Alert v-if="appsError" type="warning" show-icon class="mb-12">
{{ appsError }}:授予弹窗的应用下拉保持禁用(后端按 appKey 解析稳定主体,手填必被拒),
<Button size="small" type="warning" style="margin-left: 10px" @click="loadApps()">重试应用清单</Button>
</Alert>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="filteredRows" :loading="loading" class="link-table table-bottom-box"
:height="tableH || undefined"
:no-data-text="loadError ? '授权清单未加载成功(见上方错误提示),不代表本校没有许可' : '暂无使用许可记录(尚未授予任何应用)'" />
</div>
<Modal v-model="grantVisible" title="授予应用使用许可" :mask-closable="false" width="560">
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 10px">
许可的对象是稳定应用主体:后端按 appKey 解析 UserBase 主体并校验该校开通,前端不传学校、也不接受手填 appKey。
</p>
<Alert v-if="grantError" type="error" show-icon class="mb-12">{{ grantError }}</Alert>
<Alert v-if="flowsError" type="warning" show-icon class="mb-12">
{{ flowsError }}:流程线下拉暂无候选,可关闭后重试流程清单加载
</Alert>
<Alert v-if="appsError" type="warning" show-icon class="mb-12">
{{ appsError }}:应用下拉已禁用,请先重试应用清单再授予
</Alert>
<Form :label-width="90">
<FormItem label="流程线">
<Select v-model="grantForm.flowGuid" filterable placeholder="选择流程线" :disabled="!!flowsError">
<Option v-for="f in flows" :key="f.flowGuid" :value="f.flowGuid">
{{ f.name }}({{ f.code }})
</Option>
</Select>
</FormItem>
<FormItem label="应用">
<Select v-model="grantForm.appKey" filterable placeholder="选择本校已开启的应用" :disabled="!!appsError">
<Option v-for="o in grantAppOptions" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
</FormItem>
<FormItem label="备注">
<Input v-model="grantForm.remark" type="textarea" :rows="2" :maxlength="200"
placeholder="授权缘由(选填,随许可行落库供后续审计)" />
</FormItem>
</Form>
<template #footer>
<Button @click="grantVisible = false">取消</Button>
<Button type="primary" :loading="grantSaving" :disabled="!!appsError || !!flowsError" @click="doGrant">授予</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped>
/* 概念说明区:三概念并列 + 三条边界,管理员不必跳文档就能判断"该点哪个按钮" */
.main-container {
display: flex;
flex-direction: column;
}
.ug-explain {
background: #f7f9fc;
border: 1px solid #e4e9f2;
border-radius: 4px;
padding: 12px 14px;
}
.ug-explain-t { color: #17233d; font-weight: 600; font-size: 13px; margin-bottom: 8px; }
.ug-concepts { display: flex; flex-wrap: wrap; gap: 10px; }
.ug-concept {
flex: 1 1 260px;
min-width: 260px;
background: #fff;
border: 1px solid #e8eaef;
border-radius: 3px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.7;
color: #515a6e;
}
.ug-concept > b { color: #17233d; display: block; margin-bottom: 2px; }
.ug-concept code { background: #f0f2f5; padding: 0 4px; border-radius: 2px; font-size: 11px; }
/* 概念正文里的强调只变色不变块,否则 <b> 会把行劈断 */
.ug-concept span b { color: #c0392b; }
.ug-notes { margin-top: 8px; }
.ug-notes div { font-size: 12px; line-height: 1.8; color: #515a6e; }
.ug-notes b { color: #17233d; }
.ug-cell2 { display: flex; flex-direction: column; line-height: 1.35; }
.ug-cell-n { color: #17233d; font-weight: 500; }
.ug-cell-k { color: #98a1b3; font-size: 12px; }
</style>
@@ -8,8 +8,9 @@
* - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。 * - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。
*/ */
import { describeFlowError } from "@/utils/flowError"; import { describeFlowError } from "@/utils/flowError";
import type { DeptRef } from "./model";
export type PreviewAssigneeType = "DEPT_KEY" | "USERS"; export type PreviewAssigneeType = "DEPT" | "DEPT_KEY" | "USERS";
export interface PreviewAssignPolicy { export interface PreviewAssignPolicy {
mode?: string; mode?: string;
@@ -24,6 +25,8 @@ export interface PreviewNodeConfig {
name: string; name: string;
type: PreviewAssigneeType; type: PreviewAssigneeType;
deptKey: string; deptKey: string;
/** UserBase 部门引用(type=DEPT 时生效;SYSTEM/CUSTOM 消歧位必带) */
deptRefs?: DeptRef[];
userIds: number[]; userIds: number[];
countersign: "ALL" | "ANY"; countersign: "ALL" | "ANY";
assignPolicy: PreviewAssignPolicy | null | undefined; assignPolicy: PreviewAssignPolicy | null | undefined;
@@ -62,6 +65,8 @@ export interface NodePreviewResult {
export interface PreviewApi { export interface PreviewApi {
deptMembers: (deptId: string) => Promise<any[]>; deptMembers: (deptId: string) => Promise<any[]>;
/** UserBase 新部门目录成员(scope=select 严档,与运行时审批人解析同口径) */
deptDirectoryMembers?: (type: string, departmentId: number, scope?: string) => Promise<any[]>;
lookupUser: (userId: number) => Promise<any>; lookupUser: (userId: number) => Promise<any>;
applyPreview: (flowGuid: string) => Promise<any>; applyPreview: (flowGuid: string) => Promise<any>;
} }
@@ -99,6 +104,78 @@ async function deptRows(deptKey: string, api: PreviewApi): Promise<CandidateRow[
})); }));
} }
/**
* UserBase 部门主体预览(C4):数据来源、主体类型、实际候选、会签/或签规则与不可预览原因都要说清。
* 一个用户可属多个被选部门 → 按用户去重;目录不可用照实报错,不把"查不到"当"没人"。
*/
async function resolveDepartmentPreview(
cfg: PreviewNodeConfig,
api: PreviewApi,
published: PublishedView,
notes: string[],
): Promise<NodePreviewResult> {
const headline = "按部门(UserBase 目录)解析";
const refs = cfg.deptRefs || [];
if (!refs.length) {
return { ok: false, headline, rows: [], blocked: "尚未选择部门,请先在「部门」里选择至少一个部门。", published, notes };
}
if (!api.deptDirectoryMembers) {
return {
ok: false,
headline,
rows: [],
blocked: "部门目录成员通道未接通(deptDirectoryMembers 缺失),不能拿本地科室冒充结果。",
published,
notes,
};
}
const rows: CandidateRow[] = [];
const seen = new Set<number>();
const failed: string[] = [];
for (const ref of refs) {
const key = `${ref.departmentType}:${ref.departmentId}`;
let members: any[];
try {
members = (await api.deptDirectoryMembers(ref.departmentType, ref.departmentId, "select")) || [];
} catch (e: any) {
failed.push(`${key}:${describeFlowError(e)}`);
continue;
}
for (const m of members) {
const id = Number(m.id ?? m.userId);
if (!id || seen.has(id)) continue; // 多部门归属同一人:只列一次
seen.add(id);
rows.push({
userId: id,
userName: m.name || m.userName || `#${id}`,
warn: Number(m.status) === 1
? undefined
: `账号状态${m.status == null ? "未知" : m.status}(非启用),正式办理时会被服务端拒绝`,
});
}
}
if (failed.length) {
return {
ok: false,
headline,
rows,
blocked: `部门目录解析失败(不是"没有候选人"):${failed.join(";")}`,
published,
notes,
};
}
const usable = rows.filter((r) => !r.warn);
notes.unshift("候选数据来源=UserBase 部门目录 /oapi/departments/{type}/{id}/members?scope=select。");
return {
ok: usable.length > 0,
headline: `${headline} · ${refs.map((r) => `${r.departmentType}:${r.departmentId}`).join("、")} · ${rows.length} 人(${usable.length} 人启用)`,
rows,
blocked: usable.length ? undefined : "所选部门下没有启用账号,发布会被服务端资格终审拦截。",
published,
notes,
};
}
/** 已发布版本对账:只有本线已保存(有 flowGuid)时才有可比对象 */ /** 已发布版本对账:只有本线已保存(有 flowGuid)时才有可比对象 */
export async function loadPublishedView( export async function loadPublishedView(
cfg: PreviewNodeConfig, cfg: PreviewNodeConfig,
@@ -152,6 +229,10 @@ export async function resolveNodePreview(
]; ];
const published = await loadPublishedView(cfg, flowGuid, api); const published = await loadPublishedView(cfg, flowGuid, api);
if (cfg.type === "DEPT") {
return resolveDepartmentPreview(cfg, api, published, notes);
}
if (cfg.type === "DEPT_KEY") { if (cfg.type === "DEPT_KEY") {
if (!cfg.deptKey) { if (!cfg.deptKey) {
return { ok: false, headline: "按科室映射解析", rows: [], blocked: "尚未选择科室映射,请先在「科室映射」里选一个科室。", published, notes }; return { ok: false, headline: "按科室映射解析", rows: [], blocked: "尚未选择科室映射,请先在「科室映射」里选一个科室。", published, notes };
@@ -2,6 +2,11 @@
// 审批节点配置抽屉(方案 §3.4):打开时把节点值拷进本地表单,确定时写回—— // 审批节点配置抽屉(方案 §3.4):打开时把节点值拷进本地表单,确定时写回——
// 分段控件切换只改本地状态,不会因重渲染丢失未保存输入(原型阶段踩过该坑)。 // 分段控件切换只改本地状态,不会因重渲染丢失未保存输入(原型阶段踩过该坑)。
// 指定用户改为树形选人弹窗(参照 Notice 自定义组选人),id+name 同帧拿到,不再手输 ID。 // 指定用户改为树形选人弹窗(参照 Notice 自定义组选人),id+name 同帧拿到,不再手输 ID。
// 审批主体口径(C1/C3,2026-10-29):新配置只有「部门(UserBase 权威目录)」与「指定人员」两条路;
// 本地科室(DEPT_KEY,flw_dept 旧表)只作只读兼容——仅当它正是节点当前值时才出现在分段控件里,
// 切走即消失、不可再选,且不做任何静默回落(历史定义仍能打开、原样保存)。
// 展示规则(C3)与主体分开存:「节点显示方式」独立成块,displayMode 未显式配置时不写字段,
// 保证历史定义保存后 JSON 字节级不变。
import { computed, inject, reactive, ref, watch } from "vue"; import { computed, inject, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import UserCheckModal from "@/components/UserCheckModal.vue"; import UserCheckModal from "@/components/UserCheckModal.vue";
@@ -9,6 +14,10 @@ import { flowApi } from "@/api/module/flow";
import AssigneePreviewModal from "./AssigneePreviewModal.vue"; import AssigneePreviewModal from "./AssigneePreviewModal.vue";
import type { PreviewNodeConfig } from "../assignPreview"; import type { PreviewNodeConfig } from "../assignPreview";
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model"; import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
import type { DeptRef, DisplayMode } from "../model";
/** 审批主体三态:DEPT=UserBase 部门(新)|USERS=指定人员|DEPT_KEY=本地科室(只读兼容) */
type SubjectType = "DEPT" | "DEPT_KEY" | "USERS";
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@@ -25,8 +34,9 @@ const ctx = inject(EDITOR_CTX)!;
const form = reactive({ const form = reactive({
name: "", name: "",
type: "DEPT_KEY" as "DEPT_KEY" | "USERS", type: "DEPT" as SubjectType,
deptKey: "", deptKey: "",
deptRefs: [] as DeptRef[],
userIds: [] as number[], userIds: [] as number[],
countersign: "ANY" as "ALL" | "ANY", countersign: "ANY" as "ALL" | "ANY",
deadlineHours: null as number | null, deadlineHours: null as number | null,
@@ -36,6 +46,9 @@ const form = reactive({
assignMin: 1, assignMin: 1,
assignMax: null as number | null, assignMax: null as number | null,
assignRequired: false, assignRequired: false,
/** 空串=节点未显式配置展示方式(缺省按人员),保存时不写字段 */
displayMode: "" as "" | DisplayMode,
displayLabel: "",
}); });
const userCheckModal = ref<any>(); const userCheckModal = ref<any>();
@@ -46,13 +59,68 @@ const userModalSources = [
}, },
]; ];
// ==================== 部门主体(UserBase 目录)状态 ====================
interface UbDept {
departmentType: "SYSTEM" | "CUSTOM";
departmentId: number;
name: string;
parentId: number | null;
categoryId: number | null;
categoryName: string;
enabled: boolean;
deleted: boolean;
selectable: boolean;
memberCount: number;
}
/** 引用回显:只有 deptResolve 能给出已停用/已软删部门的名称,故选中与载入都归一到这张表 */
interface UbInfo {
categoryId?: number | null;
name: string;
memberCount: number;
warn: string;
}
const ubGroups = ref<any[]>([]);
const ubGroupKey = ref("");
const ubOptions = ref<UbDept[]>([]);
/** 逐级下钻路径(末项即当前层;id=null 为组根) */
const ubNav = ref<{ id: number | null; name: string }[]>([]);
const ubKeyword = ref("");
const ubAddChildren = ref(false);
const ubLoading = ref(false);
const ubErr = ref("");
const ubInfo = reactive<Record<string, UbInfo>>({});
/** 并发去重:载入节点与切主体可能同时触发,早退会让自动选组落空,故复用同一 in-flight 请求。
* 必须与上面的状态同批声明——syncFromNode 的 immediate watch 会同步走到 loadUbGroups,声明靠后会 TDZ */
let ubGroupsPromise: Promise<void> | null = null;
/** 展示名默认填入的判据:管理员改过一次就再也不覆盖 */
const labelTouched = ref(false);
let autoLabel = "";
function ubKeyOf(type: string, id: number | string): string {
return `${type}:${id}`;
}
function syncFromNode(): void { function syncFromNode(): void {
ubGroupKey.value = "";
const n = props.node; const n = props.node;
if (!n) return; if (!n) return;
form.name = n.name; form.name = n.name;
form.type = n.assignee?.type === "USERS" ? "USERS" : "DEPT_KEY"; const a = n.assignee;
form.deptKey = n.assignee?.type === "DEPT_KEY" ? n.assignee.deptKey : ""; // 旧机制的空占位(createApproval 造的新节点 {DEPT_KEY,""})不算"选了本地科室",
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : []; // 直接落到新口径,避免新建节点一打开就顶着个只读旧选项
form.type = a && !(a.type === "DEPT_KEY" && !a.deptKey.trim()) ? a.type : "DEPT";
form.deptKey = a?.type === "DEPT_KEY" ? a.deptKey : "";
// 不带 name:名称只从目录解析回显,避免"存了个当时的名字"被误当关联依据
form.deptRefs =
a?.type === "DEPT"
? (a.deptRefs || []).map((r) => ({
departmentType: r.departmentType,
departmentId: Number(r.departmentId),
}))
: [];
form.userIds = a?.type === "USERS" ? [...a.userIds] : [];
form.countersign = n.countersign; form.countersign = n.countersign;
form.deadlineHours = n.deadlineHours; form.deadlineHours = n.deadlineHours;
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2); form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
@@ -61,6 +129,15 @@ function syncFromNode(): void {
form.assignMin = n.assignPolicy?.min ?? 1; form.assignMin = n.assignPolicy?.min ?? 1;
form.assignMax = n.assignPolicy?.max ?? null; form.assignMax = n.assignPolicy?.max ?? null;
form.assignRequired = !!n.assignPolicy?.required; form.assignRequired = !!n.assignPolicy?.required;
form.displayMode = n.displayMode === "DEPT" || n.displayMode === "USER" ? n.displayMode : "";
form.displayLabel = n.displayLabel || "";
autoLabel = form.displayLabel;
// 已有展示名视为管理员(或历史配置)定过的值,不被部门名默认覆盖
labelTouched.value = !!n.displayLabel;
if (form.type === "DEPT") {
void ensureUbContext();
void resolveUbRefs();
}
} }
watch( watch(
@@ -72,7 +149,7 @@ watch(
); );
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */ /** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
const deptOptions = computed(() => { const legacyDeptOptions = computed(() => {
const list = ctx.depts.map((d) => ({ const list = ctx.depts.map((d) => ({
value: d.deptKey, value: d.deptKey,
label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 名办理人`, label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 名办理人`,
@@ -84,7 +161,7 @@ const deptOptions = computed(() => {
}); });
/** 当前科室的映射状态提示(与 validate.ts 同口径:仅提示,不阻断存草稿) */ /** 当前科室的映射状态提示(与 validate.ts 同口径:仅提示,不阻断存草稿) */
const deptHint = computed(() => { const legacyDeptHint = computed(() => {
if (form.type !== "DEPT_KEY" || !form.deptKey) return ""; if (form.type !== "DEPT_KEY" || !form.deptKey) return "";
const d = ctx.depts.find((x) => x.deptKey === form.deptKey); const d = ctx.depts.find((x) => x.deptKey === form.deptKey);
if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`; if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`;
@@ -92,6 +169,288 @@ const deptHint = computed(() => {
return ""; return "";
}); });
// ==================== 部门目录取数(真实接口,失败如实上抛到界面) ====================
const ubGroupItems = computed(() =>
ubGroups.value.map((g: any) => {
const type: "SYSTEM" | "CUSTOM" = g.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM";
const categoryId = g.categoryId == null ? null : Number(g.categoryId);
const name = g.name || (type === "SYSTEM" ? "系统组织" : `自定义分类#${categoryId ?? "?"}`);
return {
key: ubKeyOf(type, categoryId ?? ""),
type,
categoryId,
name,
label: `${name}(${type === "SYSTEM" ? "系统组织" : "自定义部门"}·${Number(g.departmentCount) || 0} 个部门)`,
};
})
);
function ubGroupByKey(key: string) {
return ubGroupItems.value.find((g) => g.key === key) || null;
}
function loadUbGroups(): Promise<void> {
if (ubGroups.value.length) return Promise.resolve();
if (!ubGroupsPromise) {
ubGroupsPromise = (async () => {
ubLoading.value = true;
ubErr.value = "";
try {
// 显式泛型的 get<T[]> 在 alova 类型里会把 send() 收成 {},先经 any 落一次再入列表
const raw: any = await flowApi.deptGroups().send();
ubGroups.value = raw || [];
} catch (e: any) {
ubErr.value = `部门组加载失败:${e?.message || e}`;
} finally {
ubLoading.value = false;
}
})().finally(() => {
ubGroupsPromise = null;
});
}
return ubGroupsPromise;
}
async function loadUbOptions(): Promise<void> {
const g = ubGroupByKey(ubGroupKey.value);
ubOptions.value = [];
if (!g) return;
ubNav.value = [{ id: null, name: g.name }];
ubLoading.value = true;
ubErr.value = "";
try {
// 系统组织是层级树,一次取全子树后在前端按 parentId 重建;自定义分类是平表,不必展开子树
const raw: any = await flowApi
.deptOptions({
type: g.type,
categoryId: g.categoryId ?? undefined,
withChildren: g.type === "SYSTEM" ? true : undefined,
})
.send();
const rows: any[] = raw || [];
ubOptions.value = rows.map((r: any): UbDept => ({
departmentType: r.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM",
departmentId: Number(r.departmentId),
name: r.name || `#${r.departmentId}`,
parentId: r.parentId == null ? null : Number(r.parentId),
categoryId: r.categoryId == null ? null : Number(r.categoryId),
categoryName: r.categoryName || "",
enabled: r.enabled !== false,
deleted: r.deleted === true,
selectable: r.selectable !== false,
memberCount: Number(r.memberCount) || 0,
}));
} catch (e: any) {
ubErr.value = `部门列表加载失败:${e?.message || e}`;
} finally {
ubLoading.value = false;
}
}
/** 已存引用逐条解析名称(含停用/软删):这是唯一能让"看不见部门"变成"看得见并警告"的通道 */
async function resolveUbRefs(): Promise<void> {
for (const r of form.deptRefs) {
const key = ubKeyOf(r.departmentType, r.departmentId);
if (ubInfo[key]) continue;
let row: any;
try {
const raw: any = await flowApi.deptResolve(r.departmentType, r.departmentId).send();
const rows: any[] = raw || [];
row = rows.find((x: any) => Number(x.departmentId) === r.departmentId) || rows[0] || null;
} catch (e: any) {
ubInfo[key] = {
name: `${r.departmentType}#${r.departmentId}`,
memberCount: 0,
warn: `部门信息解析失败:${e?.message || e}`,
};
continue;
}
if (!row) {
ubInfo[key] = {
name: `${r.departmentType}#${r.departmentId}`,
memberCount: 0,
warn: "UserBase 部门目录已查不到该部门,历史引用仍可解析,新配置请改选其他部门",
};
continue;
}
ubInfo[key] = {
categoryId: row.categoryId == null ? null : Number(row.categoryId),
name: row.name || `#${r.departmentId}`,
memberCount: Number(row.memberCount) || 0,
warn:
row.deleted === true
? "该部门已删除,新配置不可选,历史引用仍可解析"
: row.enabled === false
? "该部门已停用,新配置不可选,历史引用仍可解析"
: row.selectable === false
? "该部门当前不可选(无成员或未开放),新配置不可选,历史引用仍可解析"
: "",
};
}
// 名称到手后才补默认展示名:否则切主体时机会把 "SYSTEM#id" 这类兜底串当成部门名填进去
applyDefaultDisplayLabel();
}
/** 打开/切到部门主体时才取数:不选部门主体的节点(含旧定义)不发多余请求 */
async function ensureUbContext(): Promise<void> {
await loadUbGroups();
await resolveUbRefs();
if (ubGroupKey.value) return;
const customRef = form.deptRefs.find(r => r.departmentType === "CUSTOM");
if (customRef) {
let categoryId = ubInfo[ubKeyOf(customRef.departmentType, customRef.departmentId)]?.categoryId;
if (categoryId == null) {
try {
const rows: any = await flowApi.deptOptions({ type: "CUSTOM" }).send();
categoryId = (rows || []).find((r: any) => r.departmentType === "CUSTOM" && Number(r.departmentId) === customRef.departmentId)?.categoryId;
} catch { /* Keep the original reference when category discovery is unavailable. */ }
}
const group = ubGroupItems.value.find(g => g.type === "CUSTOM" && g.categoryId === Number(categoryId));
if (group) { ubGroupKey.value = group.key; return; }
}
const sys = ubGroupItems.value.find((g) => g.type === "SYSTEM");
if (form.deptRefs.some((r) => r.departmentType === "SYSTEM") && sys) ubGroupKey.value = sys.key;
else if (ubGroupItems.value.length === 1) ubGroupKey.value = ubGroupItems.value[0].key;
}
watch(ubGroupKey, () => {
void loadUbOptions();
});
async function ubRetry(): Promise<void> {
ubGroups.value = [];
await loadUbGroups();
// 已有组就重载列表;没组则交给 ubGroupKey 的 watch 触发,避免同一次重试发两遍
if (ubGroupKey.value) await loadUbOptions();
else await ensureUbContext();
}
function ubNameOf(r: DeptRef): string {
return ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.name
|| ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)?.name
|| `${r.departmentType}#${r.departmentId}`;
}
function ubMemberCountOf(r: DeptRef): number {
const info = ubInfo[ubKeyOf(r.departmentType, r.departmentId)];
if (info) return info.memberCount;
return ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)
?.memberCount || 0;
}
const ubWarnings = computed(() =>
form.deptRefs
.map((r) => ({
key: ubKeyOf(r.departmentType, r.departmentId),
name: ubNameOf(r),
warn: ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.warn || "",
}))
.filter((x) => x.warn)
.map((x) => ({ key: x.key, text: `「${x.name}」${x.warn}` }))
);
/** 当前可见部门:有关键字则跨层级检索,否则按 parentId 逐级展开 */
const ubVisible = computed<UbDept[]>(() => {
const kw = ubKeyword.value.trim();
if (kw) return ubOptions.value.filter((o) => o.name.includes(kw));
const cur = ubNav.value.length ? ubNav.value[ubNav.value.length - 1].id : null;
if (cur == null) {
const ids = new Set(ubOptions.value.map((o) => o.departmentId));
// 父级不在本组返回集的即为根(自定义分类平表、以及父链断裂的孤儿部门都从根显示,避免看不见)
return ubOptions.value.filter((o) => o.parentId == null || !ids.has(o.parentId));
}
return ubOptions.value.filter((o) => o.parentId === cur);
});
function ubHasChildren(o: UbDept): boolean {
return ubOptions.value.some((x) => x.parentId === o.departmentId);
}
function ubDrill(o: UbDept): void {
ubNav.value = [...ubNav.value, { id: o.departmentId, name: o.name }];
ubKeyword.value = "";
}
function ubUp(): void {
if (ubNav.value.length > 1) ubNav.value = ubNav.value.slice(0, -1);
}
function ubJump(i: number): void {
ubNav.value = ubNav.value.slice(0, i + 1);
}
function isUbPicked(o: UbDept): boolean {
return form.deptRefs.some((r) => r.departmentType === o.departmentType && r.departmentId === o.departmentId);
}
function ubPick(o: UbDept): void {
const key = ubKeyOf(o.departmentType, o.departmentId);
if (!form.deptRefs.some((r) => ubKeyOf(r.departmentType, r.departmentId) === key)) {
form.deptRefs.push({ departmentType: o.departmentType, departmentId: o.departmentId });
}
if (!ubInfo[key]?.warn) ubInfo[key] = { name: o.name, memberCount: o.memberCount, warn: "" };
}
function toggleUbDept(o: UbDept): void {
if (!o.selectable) {
Message.warning(`「${o.name}」当前不可选(停用/删除/无成员),不能作为新配置`);
return;
}
const key = ubKeyOf(o.departmentType, o.departmentId);
const i = form.deptRefs.findIndex((r) => ubKeyOf(r.departmentType, r.departmentId) === key);
if (i >= 0) form.deptRefs.splice(i, 1);
else ubPick(o);
applyDefaultDisplayLabel();
}
function removeUbRef(r: DeptRef): void {
const key = ubKeyOf(r.departmentType, r.departmentId);
const i = form.deptRefs.findIndex((x) => ubKeyOf(x.departmentType, x.departmentId) === key);
if (i >= 0) form.deptRefs.splice(i, 1);
applyDefaultDisplayLabel();
}
/** 勾了"含下级"则连同子树一起算(环/父子互指有 seen 兜底) */
function ubSubtree(o: UbDept): UbDept[] {
const out: UbDept[] = [];
const seen = new Set<string>([ubKeyOf(o.departmentType, o.departmentId)]);
const stack: UbDept[] = [o];
while (stack.length) {
const cur = stack.pop()!;
out.push(cur);
ubOptions.value.forEach((x) => {
const k = ubKeyOf(x.departmentType, x.departmentId);
if (x.parentId === cur.departmentId && !seen.has(k)) {
seen.add(k);
stack.push(x);
}
});
}
return out;
}
const ubPickTargets = computed<UbDept[]>(() => {
const out: UbDept[] = [];
const seen = new Set<string>();
ubVisible.value.forEach((o) => {
(ubAddChildren.value ? ubSubtree(o) : [o]).forEach((d) => {
const k = ubKeyOf(d.departmentType, d.departmentId);
if (d.selectable && !seen.has(k)) {
seen.add(k);
out.push(d);
}
});
});
return out;
});
function addUbBatch(): void {
const list = ubPickTargets.value;
list.forEach((o) => ubPick(o));
applyDefaultDisplayLabel();
Message.success(list.length ? `已加入 ${list.length} 个部门` : "没有可加入的部门");
}
function userNameOf(id: number): string { function userNameOf(id: number): string {
return ctx.userNames[id] || `#${id}`; return ctx.userNames[id] || `#${id}`;
} }
@@ -116,6 +475,40 @@ function removeUser(id: number): void {
if (i >= 0) form.userIds.splice(i, 1); if (i >= 0) form.userIds.splice(i, 1);
} }
// ==================== 节点显示方式(C3:与主体分开的独立规则) ====================
const displayModeModel = computed<DisplayMode>({
get: () => (form.displayMode === "DEPT" ? "DEPT" : "USER"),
set: (v) => {
// 目标态就是当前态时不写入:未显式配置的历史节点保存后仍不含 displayMode 字段
if (v === (form.displayMode || "USER")) return;
form.displayMode = v;
},
});
watch(
() => form.displayLabel,
(v) => {
if (v !== autoLabel) labelTouched.value = true;
}
);
/** 「默认填入、管理员可改」:只有部门主体才给默认名;主体是指定人员时一律留空由管理员自填 */
function applyDefaultDisplayLabel(): void {
if (labelTouched.value) return;
const first = form.deptRefs[0];
const next = form.type === "DEPT" && first ? ubNameOf(first) : "";
autoLabel = next;
form.displayLabel = next;
}
watch(
() => form.type,
() => {
if (form.type === "DEPT") void ensureUbContext();
applyDefaultDisplayLabel();
}
);
// ===== 高级设置分层(P2 核心/高级):只挪位置,不改字段、不改数据语义 ===== // ===== 高级设置分层(P2 核心/高级):只挪位置,不改字段、不改数据语义 =====
/** 高级区是否展开;打开抽屉时若已有高级配置则默认展开,避免"配了但看不见" */ /** 高级区是否展开;打开抽屉时若已有高级配置则默认展开,避免"配了但看不见" */
const advOpen = ref(false); const advOpen = ref(false);
@@ -140,8 +533,10 @@ function previewConfig(): PreviewNodeConfig {
return { return {
uid: props.node?.uid || "", uid: props.node?.uid || "",
name: form.name, name: form.name,
// 预览口径与抽屉当前主体一致:部门主体走 UserBase 目录成员通道解析(不回退本地科室)
type: form.type, type: form.type,
deptKey: form.deptKey, deptKey: form.deptKey,
deptRefs: [...form.deptRefs],
userIds: [...form.userIds], userIds: [...form.userIds],
countersign: form.countersign, countersign: form.countersign,
assignPolicy: form.assignEnabled assignPolicy: form.assignEnabled
@@ -156,6 +551,10 @@ function previewConfig(): PreviewNodeConfig {
} }
function onPreviewCandidates(): void { function onPreviewCandidates(): void {
if (form.type === "DEPT") {
Message.warning("部门(UserBase)主体的设计期候选人预览尚未接入,实际办理人由服务端在发起时按目录解析");
return;
}
previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid); previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid);
} }
@@ -169,11 +568,22 @@ function onOk(): void {
Message.warning("节点名称不能为空"); Message.warning("节点名称不能为空");
return; return;
} }
if (form.displayMode === "DEPT" && !form.displayLabel.trim()) {
Message.warning("按部门显示时展示名称必填(发布校验会拒绝空值)");
return;
}
n.name = form.name.trim(); n.name = form.name.trim();
n.assignee = if (form.type === "USERS") n.assignee = { type: "USERS", userIds: [...form.userIds] };
form.type === "USERS" else if (form.type === "DEPT") {
? { type: "USERS", userIds: [...form.userIds] } // 只存类型化引用(departmentType + departmentId),学校范围由后端从 CAS 会话派生
: { type: "DEPT_KEY", deptKey: form.deptKey }; n.assignee = {
type: "DEPT",
deptRefs: form.deptRefs.map((r) => ({
departmentType: r.departmentType,
departmentId: r.departmentId,
})),
};
} else n.assignee = { type: "DEPT_KEY", deptKey: form.deptKey };
n.countersign = form.countersign; n.countersign = form.countersign;
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null; n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
// 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点; // 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点;
@@ -189,6 +599,8 @@ function onOk(): void {
required: form.assignRequired, required: form.assignRequired,
} }
: undefined; : undefined;
n.displayMode = form.displayMode || undefined;
n.displayLabel = form.displayLabel.trim() || undefined;
emit("update:modelValue", false); emit("update:modelValue", false);
} }
@@ -215,41 +627,121 @@ function onRemove(): void {
<div class="fld"> <div class="fld">
<label>审批人类型</label> <label>审批人类型</label>
<RadioGroup v-model="form.type" type="button" button-style="solid"> <RadioGroup v-model="form.type" type="button" button-style="solid">
<Radio label="DEPT_KEY">按科室映射</Radio> <Radio label="DEPT">部门</Radio>
<Radio label="USERS">指定用户</Radio> <Radio label="USERS">指定人员</Radio>
<Radio v-if="form.type === 'DEPT_KEY'" label="DEPT_KEY">本地科室(旧机制)</Radio>
</RadioGroup> </RadioGroup>
<div class="hint">部门取 UserBase 权威部门目录;指定人员逐个点名;本地科室是 FL 旧表,只读兼容</div>
</div> </div>
<div v-if="form.type === 'DEPT_KEY'" class="fld"> <div v-if="form.type === 'DEPT'" class="fld">
<label>部门(UserBase 目录)</label>
<Select v-model="ubGroupKey" placeholder="选择部门组(系统组织 / 自定义分类)" :loading="ubLoading">
<Option v-for="g in ubGroupItems" :key="g.key" :value="g.key" :label="g.label" />
</Select>
<div v-if="ubErr" class="hint warn">
⚠ {{ ubErr }}
<Button size="small" style="margin-left: 6px" @click="ubRetry">重试</Button>
</div>
<div v-else-if="!ubGroupItems.length && !ubLoading" class="hint warn">
⚠ 部门目录没有返回任何部门组(系统组织与本校自定义分类均为空)
</div>
<div class="ub-picked">
<Tag
v-for="r in form.deptRefs"
:key="ubKeyOf(r.departmentType, r.departmentId)"
closable
color="blue"
@on-close="removeUbRef(r)"
>
{{ ubNameOf(r) }} · {{ ubMemberCountOf(r) }} 人
</Tag>
<span v-if="!form.deptRefs.length" class="hint">尚未选择部门</span>
</div>
<div v-for="w in ubWarnings" :key="w.key" class="hint warn">⚠ {{ w.text }}</div>
<Input
v-model="ubKeyword"
size="small"
placeholder="按名称筛选(跨层级;留空则逐级浏览)"
style="margin-top: 8px"
/>
<div class="ub-nav">
<Button size="small" :disabled="ubNav.length <= 1" @click="ubUp">‹ 上级</Button>
<span
v-for="(p, i) in ubNav"
:key="`${p.id}-${i}`"
class="ub-crumb"
:class="{ last: i === ubNav.length - 1 }"
@click="ubJump(i)"
>
{{ i > 0 ? "/" : "" }}{{ p.name }}
</span>
</div>
<div class="ub-list">
<div
v-for="o in ubVisible"
:key="ubKeyOf(o.departmentType, o.departmentId)"
class="ub-row"
:class="{ off: !o.selectable }"
>
<span class="ub-check" @click="toggleUbDept(o)">{{ isUbPicked(o) ? "☑" : "☐" }}</span>
<span class="ub-name" @click="toggleUbDept(o)">{{ o.name }}</span>
<span v-if="o.categoryName" class="ub-cat">{{ o.categoryName }}</span>
<span class="ub-count">{{ o.memberCount }} 人</span>
<span v-if="!o.selectable" class="ub-offtag">{{ o.deleted ? "已删除" : !o.enabled ? "已停用" : "无成员" }}</span>
<span v-if="ubHasChildren(o)" class="ub-drill" @click="ubDrill(o)">下级 ▸</span>
</div>
<div v-if="ubLoading" class="ub-empty">部门加载中…</div>
<div v-else-if="!ubVisible.length" class="ub-empty">
{{ ubGroupKey ? "当前层级/关键字下没有部门" : "请先选择部门组" }}
</div>
</div>
<div class="ub-foot">
<Checkbox v-model="ubAddChildren">含下级部门</Checkbox>
<Button size="small" type="primary" ghost :disabled="!ubPickTargets.length" @click="addUbBatch">
加入 {{ ubPickTargets.length }} 个部门
</Button>
</div>
<div class="hint">
引用按「目录类型 + 部门 id」成对保存(同值 id 在系统组织与自定义部门下不等价);
本校范围由后端从管理员 CAS 会话取,前端不传
</div>
</div>
<div v-else-if="form.type === 'DEPT_KEY'" class="fld">
<label>科室映射</label> <label>科室映射</label>
<Select v-model="form.deptKey" filterable placeholder="请选择科室"> <Select v-model="form.deptKey" filterable placeholder="请选择科室">
<Option v-for="o in deptOptions" :key="o.value" :value="o.value" :label="o.label" /> <Option v-for="o in legacyDeptOptions" :key="o.value" :value="o.value" :label="o.label" />
</Select> </Select>
<div v-if="deptHint" class="hint warn">⚠ {{ deptHint }}</div> <div v-if="legacyDeptHint" class="hint warn">⚠ {{ legacyDeptHint }}</div>
<div v-else class="hint">科室 → 办理人的映射在「办理人映射」页维护;发布时校验至少 1 名启用办理人</div> <div v-else class="hint">科室 → 办理人的映射在「办理人映射」页维护;发布时校验至少 1 名启用办理人</div>
<div class="hint warn">
⚠ 本地科室(FL 的 flw_dept 表)是旧机制:本节点的历史引用可原样保存,
但切到「部门」或「指定人员」后不能再选回来,新配置请改用 UserBase 部门
</div>
</div> </div>
<template v-else> <div v-else class="fld">
<div class="fld"> <label>指定用户</label>
<label>指定用户</label> <div class="user-add">
<div class="user-add"> <Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
<Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
</div>
<div class="user-tags">
<Tag
v-for="id in form.userIds"
:key="id"
closable
color="blue"
@on-close="removeUser(id)"
>
{{ userNameOf(id) }}
</Tag>
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
</div>
<div class="hint">会签时每名用户各自产生一条待办任务</div>
</div> </div>
</template> <div class="user-tags">
<Tag
v-for="id in form.userIds"
:key="id"
closable
color="blue"
@on-close="removeUser(id)"
>
{{ userNameOf(id) }}
</Tag>
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
</div>
<div class="hint">会签时每名用户各自产生一条待办任务</div>
</div>
<!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) --> <!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) -->
<div class="fld"> <div class="fld">
@@ -257,6 +749,27 @@ function onRemove(): void {
<div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div> <div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div>
</div> </div>
<!-- 节点显示方式(C3):与审批主体分开的独立规则,不并入上方主体块 -->
<div class="fld sec">
<label>节点显示方式</label>
<RadioGroup v-model="displayModeModel" type="button" button-style="solid">
<Radio label="USER">显示人员</Radio>
<Radio label="DEPT">显示部门</Radio>
</RadioGroup>
<div class="hint">显示人员:只展示本节点自己配置/解析出的办理人,不从成员所属部门反推</div>
<template v-if="displayModeModel === 'DEPT'">
<div class="sub-label">展示名称<span class="req">必填</span></div>
<Input v-model="form.displayLabel" :maxlength="30" placeholder="如:教务处" />
<div v-if="!form.displayLabel.trim()" class="hint warn">
⚠ 展示名称为空时发布会被拦截(后端校验 displayLabel 非空)
</div>
<div v-else class="hint">展示名称与审批主体分开保存:已默认填入所选部门名,可手工改成对外想叫的名字</div>
<div v-if="form.type === 'USERS'" class="hint">
当前主体是指定人员:展示名不由系统推断,请管理员自行填写
</div>
</template>
</div>
<!-- 高级能力:默认收起,已配置时自动展开并在收起态标出条目(不删除、不改语义) --> <!-- 高级能力:默认收起,已配置时自动展开并在收起态标出条目(不删除、不改语义) -->
<div class="adv-head" @click="advOpen = !advOpen"> <div class="adv-head" @click="advOpen = !advOpen">
<span class="adv-caret">{{ advOpen ? "▾" : "▸" }}</span> <span class="adv-caret">{{ advOpen ? "▾" : "▸" }}</span>
@@ -355,6 +868,19 @@ function onRemove(): void {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
} }
.sec {
border-top: 1px dashed #e8eaec;
padding-top: 14px;
}
.sub-label {
margin: 10px 0 6px;
font-size: 13px;
}
.req {
margin-left: 6px;
color: #ed4014;
font-size: 12px;
}
.hint { .hint {
color: #808695; color: #808695;
font-size: 12px; font-size: 12px;
@@ -400,6 +926,84 @@ function onRemove(): void {
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 0; gap: 4px 0;
} }
.ub-picked {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px 0;
}
.ub-nav {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
font-size: 12px;
color: #808695;
flex-wrap: wrap;
}
.ub-crumb {
cursor: pointer;
}
.ub-crumb.last {
color: #2d8cf0;
font-weight: 600;
cursor: default;
}
.ub-list {
margin-top: 6px;
max-height: 220px;
overflow-y: auto;
border: 1px solid #e8eaec;
border-radius: 3px;
}
.ub-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 13px;
}
.ub-row:hover {
background: #f8f8f9;
}
.ub-row.off {
color: #c5c8ce;
}
.ub-check {
cursor: pointer;
}
.ub-name {
flex: 1;
cursor: pointer;
}
.ub-cat {
color: #808695;
font-size: 12px;
}
.ub-count {
color: #808695;
font-size: 12px;
}
.ub-offtag {
color: #ed4014;
font-size: 12px;
}
.ub-drill {
color: #2d8cf0;
font-size: 12px;
cursor: pointer;
}
.ub-empty {
padding: 8px;
color: #808695;
font-size: 12px;
}
.ub-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.mo-row { .mo-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -11,6 +11,8 @@ import { flowApi } from "@/api/module/flow";
const api: PreviewApi = { const api: PreviewApi = {
deptMembers: (deptId) => flowApi.deptMembers(deptId).send() as Promise<any[]>, deptMembers: (deptId) => flowApi.deptMembers(deptId).send() as Promise<any[]>,
deptDirectoryMembers: (type, departmentId, scope) =>
flowApi.deptDirectoryMembers(type, departmentId, scope as "select" | "history" | undefined).send() as Promise<any[]>,
lookupUser: (userId) => flowApi.mapLookupUser(userId).send() as Promise<any>, lookupUser: (userId) => flowApi.mapLookupUser(userId).send() as Promise<any>,
applyPreview: (flowGuid) => flowApi.applyPreview(flowGuid).send() as Promise<any>, applyPreview: (flowGuid) => flowApi.applyPreview(flowGuid).send() as Promise<any>,
}; };
@@ -1,11 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
// 抄送节点配置抽屉(2026-09-23):结构与 ApprovalDrawer 同源(本地表单 + 确定回写), // 抄送节点配置抽屉(2026-09-23):结构与 ApprovalDrawer 同源(本地表单 + 确定回写),
// 去掉审批专属的会签/时限;选人同用 UserCheckModal(teacherTree),科室口径同审批人。 // 去掉审批专属的会签/时限;选人同用 UserCheckModal(teacherTree),主体口径同审批人
// (C1:新配置只有 UserBase 部门 / 指定人员,本地科室 DEPT_KEY 只读兼容,切走不可再选)。
// 抄送不配展示规则(C3 只作用于审批节点)。
import { computed, inject, reactive, ref, watch } from "vue"; import { computed, inject, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import UserCheckModal from "@/components/UserCheckModal.vue"; import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import { NotifyNode, EDITOR_CTX } from "../model"; import { NotifyNode, EDITOR_CTX } from "../model";
import type { DeptRef } from "../model";
/** 抄送对象三态:DEPT=UserBase 部门(新)|USERS=指定人员|DEPT_KEY=本地科室(只读兼容) */
type SubjectType = "DEPT" | "DEPT_KEY" | "USERS";
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
@@ -22,8 +28,9 @@ const ctx = inject(EDITOR_CTX)!;
const form = reactive({ const form = reactive({
name: "", name: "",
type: "DEPT_KEY" as "DEPT_KEY" | "USERS", type: "DEPT" as SubjectType,
deptKey: "", deptKey: "",
deptRefs: [] as DeptRef[],
userIds: [] as number[], userIds: [] as number[],
}); });
@@ -35,13 +42,67 @@ const userModalSources = [
}, },
]; ];
// ==================== 部门主体(UserBase 目录)状态 ====================
interface UbDept {
departmentType: "SYSTEM" | "CUSTOM";
departmentId: number;
name: string;
parentId: number | null;
categoryId: number | null;
categoryName: string;
enabled: boolean;
deleted: boolean;
selectable: boolean;
memberCount: number;
}
/** 引用回显:只有 deptResolve 能给出已停用/已软删部门的名称,故选中与载入都归一到这张表 */
interface UbInfo {
categoryId?: number | null;
name: string;
memberCount: number;
warn: string;
}
const ubGroups = ref<any[]>([]);
const ubGroupKey = ref("");
const ubOptions = ref<UbDept[]>([]);
/** 逐级下钻路径(末项即当前层;id=null 为组根) */
const ubNav = ref<{ id: number | null; name: string }[]>([]);
const ubKeyword = ref("");
const ubAddChildren = ref(false);
const ubLoading = ref(false);
const ubErr = ref("");
const ubInfo = reactive<Record<string, UbInfo>>({});
/** 并发去重:载入节点与切主体可能同时触发,早退会让自动选组落空,故复用同一 in-flight 请求。
* 必须与上面的状态同批声明——syncFromNode 的 immediate watch 会同步走到 loadUbGroups,声明靠后会 TDZ */
let ubGroupsPromise: Promise<void> | null = null;
function ubKeyOf(type: string, id: number | string): string {
return `${type}:${id}`;
}
function syncFromNode(): void { function syncFromNode(): void {
ubGroupKey.value = "";
const n = props.node; const n = props.node;
if (!n) return; if (!n) return;
form.name = n.name; form.name = n.name;
form.type = n.receivers?.type === "USERS" ? "USERS" : "DEPT_KEY"; const a = n.receivers;
form.deptKey = n.receivers?.type === "DEPT_KEY" ? n.receivers.deptKey : ""; // 旧机制空值不视为"选了本地科室",新节点直接落到部门口径
form.userIds = n.receivers?.type === "USERS" ? [...n.receivers.userIds] : []; form.type = a && !(a.type === "DEPT_KEY" && !a.deptKey.trim()) ? a.type : "DEPT";
form.deptKey = a?.type === "DEPT_KEY" ? a.deptKey : "";
// 不带 name:名称只从目录解析回显,避免"存了个当时的名字"被误当关联依据
form.deptRefs =
a?.type === "DEPT"
? (a.deptRefs || []).map((r) => ({
departmentType: r.departmentType,
departmentId: Number(r.departmentId),
}))
: [];
form.userIds = a?.type === "USERS" ? [...a.userIds] : [];
if (form.type === "DEPT") {
void ensureUbContext();
void resolveUbRefs();
}
} }
watch( watch(
@@ -53,7 +114,7 @@ watch(
); );
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */ /** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
const deptOptions = computed(() => { const legacyDeptOptions = computed(() => {
const list = ctx.depts.map((d) => ({ const list = ctx.depts.map((d) => ({
value: d.deptKey, value: d.deptKey,
label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`, label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`,
@@ -65,7 +126,7 @@ const deptOptions = computed(() => {
}); });
/** 当前科室的映射状态提示(抄送运行时宽容:无人只跳过不阻塞,故仅提示不谈"拦截") */ /** 当前科室的映射状态提示(抄送运行时宽容:无人只跳过不阻塞,故仅提示不谈"拦截") */
const deptHint = computed(() => { const legacyDeptHint = computed(() => {
if (form.type !== "DEPT_KEY" || !form.deptKey) return ""; if (form.type !== "DEPT_KEY" || !form.deptKey) return "";
const d = ctx.depts.find((x) => x.deptKey === form.deptKey); const d = ctx.depts.find((x) => x.deptKey === form.deptKey);
if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`; if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`;
@@ -73,6 +134,283 @@ const deptHint = computed(() => {
return ""; return "";
}); });
// ==================== 部门目录取数(真实接口,失败如实上抛到界面) ====================
const ubGroupItems = computed(() =>
ubGroups.value.map((g: any) => {
const type: "SYSTEM" | "CUSTOM" = g.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM";
const categoryId = g.categoryId == null ? null : Number(g.categoryId);
const name = g.name || (type === "SYSTEM" ? "系统组织" : `自定义分类#${categoryId ?? "?"}`);
return {
key: ubKeyOf(type, categoryId ?? ""),
type,
categoryId,
name,
label: `${name}(${type === "SYSTEM" ? "系统组织" : "自定义部门"}·${Number(g.departmentCount) || 0} 个部门)`,
};
})
);
function ubGroupByKey(key: string) {
return ubGroupItems.value.find((g) => g.key === key) || null;
}
function loadUbGroups(): Promise<void> {
if (ubGroups.value.length) return Promise.resolve();
if (!ubGroupsPromise) {
ubGroupsPromise = (async () => {
ubLoading.value = true;
ubErr.value = "";
try {
// 显式泛型的 get<T[]> 在 alova 类型里会把 send() 收成 {},先经 any 落一次再入列表
const raw: any = await flowApi.deptGroups().send();
ubGroups.value = raw || [];
} catch (e: any) {
ubErr.value = `部门组加载失败:${e?.message || e}`;
} finally {
ubLoading.value = false;
}
})().finally(() => {
ubGroupsPromise = null;
});
}
return ubGroupsPromise;
}
async function loadUbOptions(): Promise<void> {
const g = ubGroupByKey(ubGroupKey.value);
ubOptions.value = [];
if (!g) return;
ubNav.value = [{ id: null, name: g.name }];
ubLoading.value = true;
ubErr.value = "";
try {
// 系统组织是层级树,一次取全子树后在前端按 parentId 重建;自定义分类是平表,不必展开子树
const raw: any = await flowApi
.deptOptions({
type: g.type,
categoryId: g.categoryId ?? undefined,
withChildren: g.type === "SYSTEM" ? true : undefined,
})
.send();
const rows: any[] = raw || [];
ubOptions.value = rows.map((r: any): UbDept => ({
departmentType: r.departmentType === "CUSTOM" ? "CUSTOM" : "SYSTEM",
departmentId: Number(r.departmentId),
name: r.name || `#${r.departmentId}`,
parentId: r.parentId == null ? null : Number(r.parentId),
categoryId: r.categoryId == null ? null : Number(r.categoryId),
categoryName: r.categoryName || "",
enabled: r.enabled !== false,
deleted: r.deleted === true,
selectable: r.selectable !== false,
memberCount: Number(r.memberCount) || 0,
}));
} catch (e: any) {
ubErr.value = `部门列表加载失败:${e?.message || e}`;
} finally {
ubLoading.value = false;
}
}
/** 已存引用逐条解析名称(含停用/软删):这是唯一能让"看不见部门"变成"看得见并警告"的通道 */
async function resolveUbRefs(): Promise<void> {
for (const r of form.deptRefs) {
const key = ubKeyOf(r.departmentType, r.departmentId);
if (ubInfo[key]) continue;
let row: any;
try {
const raw: any = await flowApi.deptResolve(r.departmentType, r.departmentId).send();
const rows: any[] = raw || [];
row = rows.find((x: any) => Number(x.departmentId) === r.departmentId) || rows[0] || null;
} catch (e: any) {
ubInfo[key] = {
name: `${r.departmentType}#${r.departmentId}`,
memberCount: 0,
warn: `部门信息解析失败:${e?.message || e}`,
};
continue;
}
if (!row) {
ubInfo[key] = {
name: `${r.departmentType}#${r.departmentId}`,
memberCount: 0,
warn: "UserBase 部门目录已查不到该部门,历史引用仍可解析,新配置请改选其他部门",
};
continue;
}
ubInfo[key] = {
categoryId: row.categoryId == null ? null : Number(row.categoryId),
name: row.name || `#${r.departmentId}`,
memberCount: Number(row.memberCount) || 0,
warn:
row.deleted === true
? "该部门已删除,新配置不可选,历史引用仍可解析"
: row.enabled === false
? "该部门已停用,新配置不可选,历史引用仍可解析"
: row.selectable === false
? "该部门当前不可选(无成员或未开放),新配置不可选,历史引用仍可解析"
: "",
};
}
}
/** 打开/切到部门主体时才取数:不选部门主体的节点(含旧定义)不发多余请求 */
async function ensureUbContext(): Promise<void> {
await loadUbGroups();
await resolveUbRefs();
if (ubGroupKey.value) return;
const customRef = form.deptRefs.find(r => r.departmentType === "CUSTOM");
if (customRef) {
let categoryId = ubInfo[ubKeyOf(customRef.departmentType, customRef.departmentId)]?.categoryId;
if (categoryId == null) {
try {
const rows: any = await flowApi.deptOptions({ type: "CUSTOM" }).send();
categoryId = (rows || []).find((r: any) => r.departmentType === "CUSTOM" && Number(r.departmentId) === customRef.departmentId)?.categoryId;
} catch { /* Keep the original reference when category discovery is unavailable. */ }
}
const group = ubGroupItems.value.find(g => g.type === "CUSTOM" && g.categoryId === Number(categoryId));
if (group) { ubGroupKey.value = group.key; return; }
}
const sys = ubGroupItems.value.find((g) => g.type === "SYSTEM");
if (form.deptRefs.some((r) => r.departmentType === "SYSTEM") && sys) ubGroupKey.value = sys.key;
else if (ubGroupItems.value.length === 1) ubGroupKey.value = ubGroupItems.value[0].key;
}
watch(ubGroupKey, () => {
void loadUbOptions();
});
async function ubRetry(): Promise<void> {
ubGroups.value = [];
await loadUbGroups();
// 已有组就重载列表;没组则交给 ubGroupKey 的 watch 触发,避免同一次重试发两遍
if (ubGroupKey.value) await loadUbOptions();
else await ensureUbContext();
}
function ubNameOf(r: DeptRef): string {
return ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.name
|| ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)?.name
|| `${r.departmentType}#${r.departmentId}`;
}
function ubMemberCountOf(r: DeptRef): number {
const info = ubInfo[ubKeyOf(r.departmentType, r.departmentId)];
if (info) return info.memberCount;
return ubOptions.value.find((o) => o.departmentId === r.departmentId && o.departmentType === r.departmentType)
?.memberCount || 0;
}
const ubWarnings = computed(() =>
form.deptRefs
.map((r) => ({
key: ubKeyOf(r.departmentType, r.departmentId),
name: ubNameOf(r),
warn: ubInfo[ubKeyOf(r.departmentType, r.departmentId)]?.warn || "",
}))
.filter((x) => x.warn)
.map((x) => ({ key: x.key, text: `「${x.name}」${x.warn}` }))
);
/** 当前可见部门:有关键字则跨层级检索,否则按 parentId 逐级展开 */
const ubVisible = computed<UbDept[]>(() => {
const kw = ubKeyword.value.trim();
if (kw) return ubOptions.value.filter((o) => o.name.includes(kw));
const cur = ubNav.value.length ? ubNav.value[ubNav.value.length - 1].id : null;
if (cur == null) {
const ids = new Set(ubOptions.value.map((o) => o.departmentId));
// 父级不在本组返回集的即为根(自定义分类平表、以及父链断裂的孤儿部门都从根显示,避免看不见)
return ubOptions.value.filter((o) => o.parentId == null || !ids.has(o.parentId));
}
return ubOptions.value.filter((o) => o.parentId === cur);
});
function ubHasChildren(o: UbDept): boolean {
return ubOptions.value.some((x) => x.parentId === o.departmentId);
}
function ubDrill(o: UbDept): void {
ubNav.value = [...ubNav.value, { id: o.departmentId, name: o.name }];
ubKeyword.value = "";
}
function ubUp(): void {
if (ubNav.value.length > 1) ubNav.value = ubNav.value.slice(0, -1);
}
function ubJump(i: number): void {
ubNav.value = ubNav.value.slice(0, i + 1);
}
function isUbPicked(o: UbDept): boolean {
return form.deptRefs.some((r) => r.departmentType === o.departmentType && r.departmentId === o.departmentId);
}
function ubPick(o: UbDept): void {
const key = ubKeyOf(o.departmentType, o.departmentId);
if (!form.deptRefs.some((r) => ubKeyOf(r.departmentType, r.departmentId) === key)) {
form.deptRefs.push({ departmentType: o.departmentType, departmentId: o.departmentId });
}
if (!ubInfo[key]?.warn) ubInfo[key] = { name: o.name, memberCount: o.memberCount, warn: "" };
}
function toggleUbDept(o: UbDept): void {
if (!o.selectable) {
Message.warning(`「${o.name}」当前不可选(停用/删除/无成员),不能作为新配置`);
return;
}
const key = ubKeyOf(o.departmentType, o.departmentId);
const i = form.deptRefs.findIndex((r) => ubKeyOf(r.departmentType, r.departmentId) === key);
if (i >= 0) form.deptRefs.splice(i, 1);
else ubPick(o);
}
function removeUbRef(r: DeptRef): void {
const key = ubKeyOf(r.departmentType, r.departmentId);
const i = form.deptRefs.findIndex((x) => ubKeyOf(x.departmentType, x.departmentId) === key);
if (i >= 0) form.deptRefs.splice(i, 1);
}
/** 勾了"含下级"则连同子树一起算(环/父子互指有 seen 兜底) */
function ubSubtree(o: UbDept): UbDept[] {
const out: UbDept[] = [];
const seen = new Set<string>([ubKeyOf(o.departmentType, o.departmentId)]);
const stack: UbDept[] = [o];
while (stack.length) {
const cur = stack.pop()!;
out.push(cur);
ubOptions.value.forEach((x) => {
const k = ubKeyOf(x.departmentType, x.departmentId);
if (x.parentId === cur.departmentId && !seen.has(k)) {
seen.add(k);
stack.push(x);
}
});
}
return out;
}
const ubPickTargets = computed<UbDept[]>(() => {
const out: UbDept[] = [];
const seen = new Set<string>();
ubVisible.value.forEach((o) => {
(ubAddChildren.value ? ubSubtree(o) : [o]).forEach((d) => {
const k = ubKeyOf(d.departmentType, d.departmentId);
if (d.selectable && !seen.has(k)) {
seen.add(k);
out.push(d);
}
});
});
return out;
});
function addUbBatch(): void {
const list = ubPickTargets.value;
list.forEach((o) => ubPick(o));
Message.success(list.length ? `已加入 ${list.length} 个部门` : "没有可加入的部门");
}
function userNameOf(id: number): string { function userNameOf(id: number): string {
return ctx.userNames[id] || `#${id}`; return ctx.userNames[id] || `#${id}`;
} }
@@ -108,10 +446,17 @@ function onOk(): void {
return; return;
} }
n.name = form.name.trim(); n.name = form.name.trim();
n.receivers = if (form.type === "USERS") n.receivers = { type: "USERS", userIds: [...form.userIds] };
form.type === "USERS" else if (form.type === "DEPT") {
? { type: "USERS", userIds: [...form.userIds] } // 只存类型化引用(departmentType + departmentId),本校范围由后端从 CAS 会话派生
: { type: "DEPT_KEY", deptKey: form.deptKey }; n.receivers = {
type: "DEPT",
deptRefs: form.deptRefs.map((r) => ({
departmentType: r.departmentType,
departmentId: r.departmentId,
})),
};
} else n.receivers = { type: "DEPT_KEY", deptKey: form.deptKey };
emit("update:modelValue", false); emit("update:modelValue", false);
} }
@@ -138,41 +483,121 @@ function onRemove(): void {
<div class="fld"> <div class="fld">
<label>抄送对象类型</label> <label>抄送对象类型</label>
<RadioGroup v-model="form.type" type="button" button-style="solid"> <RadioGroup v-model="form.type" type="button" button-style="solid">
<Radio label="DEPT_KEY">按科室映射</Radio> <Radio label="DEPT">部门</Radio>
<Radio label="USERS">指定用户</Radio> <Radio label="USERS">指定人员</Radio>
<Radio v-if="form.type === 'DEPT_KEY'" label="DEPT_KEY">本地科室(旧机制)</Radio>
</RadioGroup> </RadioGroup>
<div class="hint">部门取 UserBase 权威部门目录;指定人员逐个点名;本地科室是 FL 旧表,只读兼容</div>
</div> </div>
<div v-if="form.type === 'DEPT_KEY'" class="fld"> <div v-if="form.type === 'DEPT'" class="fld">
<label>部门(UserBase 目录)</label>
<Select v-model="ubGroupKey" placeholder="选择部门组(系统组织 / 自定义分类)" :loading="ubLoading">
<Option v-for="g in ubGroupItems" :key="g.key" :value="g.key" :label="g.label" />
</Select>
<div v-if="ubErr" class="hint warn">
⚠ {{ ubErr }}
<Button size="small" style="margin-left: 6px" @click="ubRetry">重试</Button>
</div>
<div v-else-if="!ubGroupItems.length && !ubLoading" class="hint warn">
⚠ 部门目录没有返回任何部门组(系统组织与本校自定义分类均为空)
</div>
<div class="ub-picked">
<Tag
v-for="r in form.deptRefs"
:key="ubKeyOf(r.departmentType, r.departmentId)"
closable
color="cyan"
@on-close="removeUbRef(r)"
>
{{ ubNameOf(r) }} · {{ ubMemberCountOf(r) }} 人
</Tag>
<span v-if="!form.deptRefs.length" class="hint">尚未选择部门</span>
</div>
<div v-for="w in ubWarnings" :key="w.key" class="hint warn">⚠ {{ w.text }}</div>
<Input
v-model="ubKeyword"
size="small"
placeholder="按名称筛选(跨层级;留空则逐级浏览)"
style="margin-top: 8px"
/>
<div class="ub-nav">
<Button size="small" :disabled="ubNav.length <= 1" @click="ubUp">‹ 上级</Button>
<span
v-for="(p, i) in ubNav"
:key="`${p.id}-${i}`"
class="ub-crumb"
:class="{ last: i === ubNav.length - 1 }"
@click="ubJump(i)"
>
{{ i > 0 ? "/" : "" }}{{ p.name }}
</span>
</div>
<div class="ub-list">
<div
v-for="o in ubVisible"
:key="ubKeyOf(o.departmentType, o.departmentId)"
class="ub-row"
:class="{ off: !o.selectable }"
>
<span class="ub-check" @click="toggleUbDept(o)">{{ isUbPicked(o) ? "☑" : "☐" }}</span>
<span class="ub-name" @click="toggleUbDept(o)">{{ o.name }}</span>
<span v-if="o.categoryName" class="ub-cat">{{ o.categoryName }}</span>
<span class="ub-count">{{ o.memberCount }} 人</span>
<span v-if="!o.selectable" class="ub-offtag">{{ o.deleted ? "已删除" : !o.enabled ? "已停用" : "无成员" }}</span>
<span v-if="ubHasChildren(o)" class="ub-drill" @click="ubDrill(o)">下级 ▸</span>
</div>
<div v-if="ubLoading" class="ub-empty">部门加载中…</div>
<div v-else-if="!ubVisible.length" class="ub-empty">
{{ ubGroupKey ? "当前层级/关键字下没有部门" : "请先选择部门组" }}
</div>
</div>
<div class="ub-foot">
<Checkbox v-model="ubAddChildren">含下级部门</Checkbox>
<Button size="small" type="primary" ghost :disabled="!ubPickTargets.length" @click="addUbBatch">
加入 {{ ubPickTargets.length }} 个部门
</Button>
</div>
<div class="hint">
引用按「目录类型 + 部门 id」成对保存(同值 id 在系统组织与自定义部门下不等价);
本校范围由后端从管理员 CAS 会话取,前端不传
</div>
</div>
<div v-else-if="form.type === 'DEPT_KEY'" class="fld">
<label>科室映射</label> <label>科室映射</label>
<Select v-model="form.deptKey" filterable placeholder="请选择科室"> <Select v-model="form.deptKey" filterable placeholder="请选择科室">
<Option v-for="o in deptOptions" :key="o.value" :value="o.value" :label="o.label" /> <Option v-for="o in legacyDeptOptions" :key="o.value" :value="o.value" :label="o.label" />
</Select> </Select>
<div v-if="deptHint" class="hint warn">⚠ {{ deptHint }}</div> <div v-if="legacyDeptHint" class="hint warn">⚠ {{ legacyDeptHint }}</div>
<div v-else class="hint">科室启用中的全部人员都会收到抄送;运行时无人可抄送时只跳过,不阻塞流程</div> <div v-else class="hint">科室启用中的全部人员都会收到抄送;运行时无人可抄送时只跳过,不阻塞流程</div>
<div class="hint warn">
⚠ 本地科室(FL 的 flw_dept 表)是旧机制:本节点的历史引用可原样保存,
但切到「部门」或「指定人员」后不能再选回来,新配置请改用 UserBase 部门
</div>
</div> </div>
<template v-else> <div v-else class="fld">
<div class="fld"> <label>指定用户</label>
<label>指定用户</label> <div class="user-add">
<div class="user-add"> <Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
<Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
</div>
<div class="user-tags">
<Tag
v-for="id in form.userIds"
:key="id"
closable
color="cyan"
@on-close="removeUser(id)"
>
{{ userNameOf(id) }}
</Tag>
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
</div>
<div class="hint">抄送是知会:每人一条站内通知,不产生待办、不需要办理</div>
</div> </div>
</template> <div class="user-tags">
<Tag
v-for="id in form.userIds"
:key="id"
closable
color="cyan"
@on-close="removeUser(id)"
>
{{ userNameOf(id) }}
</Tag>
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
</div>
<div class="hint">抄送是知会:每人一条站内通知,不产生待办、不需要办理</div>
</div>
</Form> </Form>
<div class="dw-foot"> <div class="dw-foot">
@@ -225,6 +650,84 @@ function onRemove(): void {
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 0; gap: 4px 0;
} }
.ub-picked {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px 0;
}
.ub-nav {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
font-size: 12px;
color: #808695;
flex-wrap: wrap;
}
.ub-crumb {
cursor: pointer;
}
.ub-crumb.last {
color: #2d8cf0;
font-weight: 600;
cursor: default;
}
.ub-list {
margin-top: 6px;
max-height: 220px;
overflow-y: auto;
border: 1px solid #e8eaec;
border-radius: 3px;
}
.ub-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 13px;
}
.ub-row:hover {
background: #f8f8f9;
}
.ub-row.off {
color: #c5c8ce;
}
.ub-check {
cursor: pointer;
}
.ub-name {
flex: 1;
cursor: pointer;
}
.ub-cat {
color: #808695;
font-size: 12px;
}
.ub-count {
color: #808695;
font-size: 12px;
}
.ub-offtag {
color: #ed4014;
font-size: 12px;
}
.ub-drill {
color: #2d8cf0;
font-size: 12px;
cursor: pointer;
}
.ub-empty {
padding: 8px;
color: #808695;
font-size: 12px;
}
.ub-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.dw-foot { .dw-foot {
position: sticky; position: sticky;
bottom: -16px; bottom: -16px;
+63 -1
View File
@@ -22,6 +22,8 @@ import {
EditorItem, EditorItem,
EditorModel, EditorModel,
EditorSettings, EditorSettings,
FlowVariable,
VARIABLE_TYPES,
NotifyNode, NotifyNode,
ParallelNode, ParallelNode,
createApproval, createApproval,
@@ -93,6 +95,14 @@ export function toContentJson(model: EditorModel): DefJson {
if (item.countersign === "ANY") node.countersign = "ANY"; if (item.countersign === "ANY") node.countersign = "ANY";
if (item.deadlineHours) node.deadlineHours = item.deadlineHours; if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
if (item.assignPolicy) node.assignPolicy = { ...item.assignPolicy }; if (item.assignPolicy) node.assignPolicy = { ...item.assignPolicy };
// C3 展示规则与审批主体分开存:不配=按人员(历史定义零字段变化,读取行为不变)
if (item.displayMode === "DEPT") {
node.displayMode = "DEPT";
node.displayLabel = item.displayLabel || "";
} else if (item.displayMode === "USER") {
node.displayMode = "USER";
if (item.displayLabel) node.displayLabel = item.displayLabel;
}
nodes.push(node); nodes.push(node);
linkTo(id); linkTo(id);
if (item.outlets && item.outlets.length >= 2 && item.branches) { if (item.outlets && item.outlets.length >= 2 && item.branches) {
@@ -181,6 +191,19 @@ export function toContentJson(model: EditorModel): DefJson {
start: "start", start: "start",
nodes, nodes,
edges, edges,
// D1 变量契约:始终显式写出(无业务变量=空集)。后端新发布版本要求声明,
// 省略会被 VariableContract.validateDefinition 以"必须声明 variables"拦下
variables: (model.variables || []).map((v) => {
// 值域以编辑态逗号串为准(enumText 是 UI 专用字段,不进 contentJson)
const enumValues = (v.enumText || "").split(/[,,]/).map((s) => s.trim()).filter((s) => s.length > 0);
const stringLike = v.type === "string" || v.type === "stringArray";
return {
key: v.key,
type: v.type,
...(v.required ? { required: true } : {}),
...(stringLike && enumValues.length ? { enum: enumValues } : {}),
};
}),
settings: { settings: {
rejectPolicy: "END_REJECTED", rejectPolicy: "END_REJECTED",
urge: { ...model.settings.urge }, urge: { ...model.settings.urge },
@@ -193,9 +216,43 @@ export function toContentJson(model: EditorModel): DefJson {
* bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射; * bpmn 画布与卡片共用同一套 id——画布上选中元素的 id 即卡片 uid,配置抽屉无需二次映射;
* id 仅要求"非空且唯一"(DefValidator 口径)。 * id 仅要求"非空且唯一"(DefValidator 口径)。
*/ */
/** 变量契约读侧:只认后端已知类型,未知类型整项丢弃并留 console 警告(不用假类型继续编辑) */
function parseVariables(raw: unknown): FlowVariable[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out: FlowVariable[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const v = item as Record<string, any>;
if (typeof v.key !== "string" || !v.key || VARIABLE_TYPES.indexOf(v.type) < 0) {
console.warn("[def-editor] 跳过不合法的变量声明:", v);
continue;
}
out.push({
key: v.key,
type: v.type,
required: v.required === true,
enum: Array.isArray(v.enum) ? v.enum.filter((x: unknown) => typeof x === "string") : undefined,
enumText: Array.isArray(v.enum) ? v.enum.filter((x: unknown) => typeof x === "string").join(",") : "",
});
}
return out;
}
function cloneAssignee(a: Assignee | null): Assignee { function cloneAssignee(a: Assignee | null): Assignee {
if (!a) return { type: "DEPT_KEY", deptKey: "" }; if (!a) return { type: "DEPT_KEY", deptKey: "" };
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey }; if (a.type === "USERS") return { type: "USERS", userIds: [...a.userIds] };
if (a.type === "DEPT") {
// 只持久化类型化引用两要素(+id);name 是展示快照,不进 contentJson,
// 避免"存了个当时的部门名"变成下一轮的关联依据(名称一律由目录解析回显)
return {
type: "DEPT",
deptRefs: (a.deptRefs || []).map((r) => ({
departmentType: r.departmentType,
departmentId: r.departmentId,
})),
};
}
return { type: "DEPT_KEY", deptKey: a.deptKey };
} }
export interface ParsedDef { export interface ParsedDef {
@@ -203,6 +260,8 @@ export interface ParsedDef {
name: string; name: string;
list: EditorItem[]; list: EditorItem[];
settings: EditorSettings; settings: EditorSettings;
/** 变量契约(D1):原样带出;历史定义为 undefined(保存时补空集,不静默造声明) */
variables?: FlowVariable[];
/** 端点 name 原样带出(见 EditorModel.startName 注释) */ /** 端点 name 原样带出(见 EditorModel.startName 注释) */
startName?: string; startName?: string;
endName?: string; endName?: string;
@@ -464,6 +523,7 @@ export function fromContentJson(def: any): ParsedDef {
code: def.code || "", code: def.code || "",
name: def.name || "", name: def.name || "",
list: firstWalk.list, list: firstWalk.list,
variables: parseVariables(def.variables),
startName: starts[0].name || undefined, startName: starts[0].name || undefined,
endName: byId[firstWalk.endedAt]?.name || undefined, endName: byId[firstWalk.endedAt]?.name || undefined,
settings: { settings: {
@@ -489,6 +549,8 @@ function toApprovalNode(n: any): ApprovalNode {
countersign, countersign,
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null, deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
assignPolicy: n.assignPolicy ? { ...n.assignPolicy } : undefined, assignPolicy: n.assignPolicy ? { ...n.assignPolicy } : undefined,
displayMode: n.displayMode === "DEPT" ? "DEPT" : n.displayMode === "USER" ? "USER" : undefined,
displayLabel: n.displayLabel ? String(n.displayLabel) : undefined,
}; };
} }
+66 -23
View File
@@ -20,6 +20,7 @@ import {
NotifyNode, NotifyNode,
RouterOption, RouterOption,
UrgeSetting, UrgeSetting,
VARIABLE_TYPES,
collectUserIds, collectUserIds,
createApproval, createApproval,
createCondition, createCondition,
@@ -238,15 +239,46 @@ function applyParsed(parsed: ParsedDef): void {
model.name = parsed.name || model.name; model.name = parsed.name || model.name;
model.list = parsed.list; model.list = parsed.list;
model.settings = parsed.settings; model.settings = parsed.settings;
model.variables = parsed.variables;
// 端点 name 随定义带出,保存时回写(新建流程为空,生成 JSON 不含该字段) // 端点 name 随定义带出,保存时回写(新建流程为空,生成 JSON 不含该字段)
model.startName = parsed.startName; model.startName = parsed.startName;
model.endName = parsed.endName; model.endName = parsed.endName;
void resolveUserNames(); void resolveUserNames();
} }
/**
* 变量契约编辑(D1)。前端只做"明显配错"的拦止(key 形态/类型/重复),
* 表达式引用是否齐备、类型与值域是否成立,权威判据仍是后端 VariableContract 的发布/发起校验。
*/
function addVariable(): void {
if (!model.variables) model.variables = [];
model.variables.push({ key: "", type: "string", required: false, enumText: "" });
}
function removeVariable(index: number): void {
(model.variables || []).splice(index, 1);
}
const BUILTIN_VAR_KEYS = ["instanceId", "instanceVersion", "schId", "bizType", "bizId", "deptId", "deptName"];
function hasInvalidVariables(): boolean {
const list = model.variables || [];
const keys = new Set<string>();
for (const v of list) {
const key = (v.key || "").trim();
if (!key || !/^[A-Za-z0-9_]{1,64}$/.test(key)) return true;
if (BUILTIN_VAR_KEYS.includes(key)) return true;
if (!VARIABLE_TYPES.includes(v.type)) return true;
if (keys.has(key)) return true;
keys.add(key);
const values = (v.enumText || "").split(/[,,]/).map((s) => s.trim()).filter((s) => s.length > 0);
if (values.length && v.type !== "string" && v.type !== "stringArray") return true;
}
return false;
}
async function load(): Promise<void> { async function load(): Promise<void> {
loading.value = true; loading.value = true; try {
try {
await loadDepts(); await loadDepts();
await loadRouters(); await loadRouters();
void loadVars(); void loadVars();
@@ -469,8 +501,9 @@ async function onSave(): Promise<void> {
Message.warning("请填写流程名称"); Message.warning("请填写流程名称");
return; return;
} }
if (!model.code.trim()) { // A3:内部编码由后端生成,普通管理员不手填(新建时留空;编辑时只读回显既有编码)
Message.warning("请填写流程编码"); if (hasInvalidVariables()) {
Message.warning("变量契约不完整:每项都需要 key 与类型(key 只允许字母/数字/下划线,且不能占用内置键)");
return; return;
} }
// 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度, // 归属应用改可选(2026-10-25 T03 定义不绑定唯一应用):appKey 只是"创建方应用"筛选/审计维度,
@@ -486,21 +519,7 @@ async function onSave(): Promise<void> {
Message.error(`校验未通过:${errors[0].text}`); Message.error(`校验未通过:${errors[0].text}`);
return; return;
} }
// 新建:同编码软提示(编码是分类标签,多条流程线可共用同一编码,这里只提醒不阻断) // 新建:编码由后端生成且唯一,不再需要"同编码会开出新流程线"的软确认
if (!editId.value && !baseGuid.value) {
const rows: any[] = (await flowApi.defList().send()) || [];
const sameCode = rows.filter((r) => r.code === model.code.trim()).length;
if (sameCode > 0) {
Modal.confirm({
title: "存在同编码流程",
content: `编码「${model.code.trim()}」已有 ${sameCode} 条流程,保存将新建一条独立流程线(版本从 v1 开始)。继续吗?`,
okText: "继续保存",
cancelText: "取消",
onOk: () => void doSave(),
});
return;
}
}
await doSave(); await doSave();
} }
@@ -533,7 +552,8 @@ async function submitSave(changeLog?: string): Promise<void> {
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑 // code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
await flowApi.defSave({ await flowApi.defSave({
id: editId.value || undefined, id: editId.value || undefined,
code: model.code.trim(), // 编码不手填:新建传 undefined(后端生成稳定唯一码),编辑传既有编码(保持不变)
code: model.code.trim() || undefined,
name: model.name.trim(), name: model.name.trim(),
contentJson: content, contentJson: content,
baseGuid: baseGuid.value || undefined, baseGuid: baseGuid.value || undefined,
@@ -646,9 +666,11 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<Input v-model="model.name" placeholder="流程名称" :maxlength="30" /> <Input v-model="model.name" placeholder="流程名称" :maxlength="30" />
</div> </div>
<div class="ip-fld"> <div class="ip-fld">
<label>编码<span class="ip-mini">升版本态禁用(发布后不可改)</span></label> <label>内部编码<span class="ip-mini">后端生成 · 只读</span></label>
<Input v-model="model.code" placeholder="内部管理键,发布后不可改" <Input :value="model.code" readonly :placeholder="editId ? '' : '保存后由后端生成唯一编码'" />
:disabled="!!editId || !!baseGuid" :maxlength="40" /> <div v-if="!editId && !model.code" class="ip-mini">
编码是内部标识,由服务端生成且不随名称变化;业务方绑定流程请用 flowGuid。
</div>
</div> </div>
<div class="ip-fld"> <div class="ip-fld">
<label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label> <label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label>
@@ -675,6 +697,27 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div> </div>
</section> </section>
<section class="ip-sec">
<h4>变量契约<span class="ip-mini">业务应用发起时提供 · 与表单中心无关</span></h4>
<div class="ip-tip">
条件分支的表达式只能引用这里声明的变量(或内置变量)。不使用条件变量的串行流程可留空。
</div>
<div v-for="(v, i) in model.variables" :key="i" class="var-row">
<Input v-model="v.key" size="small" placeholder="变量 key" class="var-key" :maxlength="64" />
<Select v-model="v.type" size="small" class="var-type">
<Option v-for="t in VARIABLE_TYPES" :key="t" :value="t" :label="t" />
</Select>
<Checkbox v-model="v.required" size="small">必填</Checkbox>
<Input v-model="v.enumText" size="small" class="var-enum" placeholder="值域(逗号分隔,可空)"
v-if="v.type === 'string' || v.type === 'stringArray'" />
<Button size="small" @click="removeVariable(i)">删除</Button>
</div>
<div v-if="!(model.variables && model.variables.length)" class="ip-mini">
未声明业务变量(保存后写入空集合,发布时按"无变量"校验)
</div>
<Button size="small" class="var-add" @click="addVariable">+ 添加变量</Button>
</section>
<section class="ip-sec"> <section class="ip-sec">
<h4>状态与版本<span class="ip-mini">只读</span></h4> <h4>状态与版本<span class="ip-mini">只读</span></h4>
<div class="ip-tags"> <div class="ip-tags">
+75 -4
View File
@@ -9,7 +9,26 @@ import type { InjectionKey } from "vue";
export type Countersign = "ALL" | "ANY"; export type Countersign = "ALL" | "ANY";
/**
* UserBase 新部门目录引用(C1,2026-10-29)。
* 学校范围不落在前端:定义行的 schId 由后端从 CAS 会话派生,前端没有可越界的校域入口。
*/
export interface DeptRef {
/** SYSTEM=组织架构组;CUSTOM=自定义部门。同值 id 在两类目录里不等价,必须带消歧位 */
departmentType: "SYSTEM" | "CUSTOM";
departmentId: number;
/** 仅展示用名称快照(由目录解析回填,不参与关联与保存判据) */
name?: string;
}
/** 审批/抄送主体=部门(UserBase 权威目录;新配置唯一写入口径) */
export interface AssigneeDept { export interface AssigneeDept {
type: "DEPT";
deptRefs: DeptRef[];
}
/** 历史本地科室引用(DEPT_KEY):只读兼容,编辑器不再产生新引用 */
export interface AssigneeLegacyDept {
type: "DEPT_KEY"; type: "DEPT_KEY";
deptKey: string; deptKey: string;
} }
@@ -17,7 +36,10 @@ export interface AssigneeUsers {
type: "USERS"; type: "USERS";
userIds: number[]; userIds: number[];
} }
export type Assignee = AssigneeDept | AssigneeUsers; export type Assignee = AssigneeDept | AssigneeLegacyDept | AssigneeUsers;
/** 节点展示方式(C3):USER=显示人员(缺省)|DEPT=显示部门(独立名称,可手工改) */
export type DisplayMode = "USER" | "DEPT";
/** 发起时指派:mode SINGLE=恰 1 人|MULTI=min~max;required 仅子应用 UI 必选提示 */ /** 发起时指派:mode SINGLE=恰 1 人|MULTI=min~max;required 仅子应用 UI 必选提示 */
export interface AssignPolicy { export interface AssignPolicy {
@@ -42,9 +64,13 @@ export interface ApprovalNode {
outlets?: string[]; outlets?: string[];
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */ /** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
branches?: EditorItem[][]; branches?: EditorItem[][];
/** 节点展示方式(C3):缺省按人员;按部门时用 displayLabel 作独立展示名 */
displayMode?: DisplayMode;
/** 展示名称(按部门时生效;主体是部门时前端默认填入所选部门名,主体是人员时须管理员手工填写) */
displayLabel?: string;
} }
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */ /** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT/DEPT_KEY/USERS) */
export interface NotifyNode { export interface NotifyNode {
uid: string; uid: string;
kind: "notify"; kind: "notify";
@@ -97,11 +123,37 @@ export interface EditorSettings {
urge: UrgeSetting; urge: UrgeSetting;
} }
/**
* 变量契约项(D1,与后端 engine/FlowDefModel.Variable + VariableContract 同口径)。
* 变量由业务应用在发起时提供,FL 只用它做条件判断——不要求绑定 form-center、不读表单 schema。
*/
export interface FlowVariable {
key: string;
/** 后端 VariableContract.TYPES 的取值集 */
type: "string" | "number" | "boolean" | "date" | "datetime" | "money" | "stringArray" | "fileArray" | "userArray";
required?: boolean;
/** 仅 string/stringArray 允许;空数组不写(后端按"无值域"处理) */
enum?: string[];
/**
* 编辑态专用:值域的逗号分隔输入串。只活在浏览器内存里,
* convert.toContentJson 不写这个键(值域以 enum 数组为准)。
*/
enumText?: string;
}
/** 后端 VariableContract.TYPES 的前端镜像;新增类型必须先有服务端支持,不能只改下拉 */
export const VARIABLE_TYPES: FlowVariable["type"][] = [
"string", "number", "boolean", "date", "datetime", "money", "stringArray", "fileArray", "userArray",
];
export interface EditorModel { export interface EditorModel {
code: string; code: string; name: string;
name: string;
list: EditorItem[]; list: EditorItem[];
settings: EditorSettings; settings: EditorSettings;
/**
* 变量契约(undefined=本定义从未声明过,保存时按空集显式写出——后端新发布必须显式声明)。
*/
variables?: FlowVariable[];
/** /**
* START/END 节点的 name:编辑器里端点是隐式固定的(不展示、不可编辑),但载入既有定义时 * START/END 节点的 name:编辑器里端点是隐式固定的(不展示、不可编辑),但载入既有定义时
* 原样保留,生成 JSON 时回写——避免保存一次就静默丢掉平台里已存在的自定义端点名。 * 原样保留,生成 JSON 时回写——避免保存一次就静默丢掉平台里已存在的自定义端点名。
@@ -129,6 +181,10 @@ export interface DefNodeJson {
deadlineHours?: number; deadlineHours?: number;
/** 发起时指派策略(审批节点可选,2026-10-13;关闭态不写字段) */ /** 发起时指派策略(审批节点可选,2026-10-13;关闭态不写字段) */
assignPolicy?: AssignPolicy; assignPolicy?: AssignPolicy;
/** 节点展示方式(C3,可选):缺省=按人员;历史定义无此字段,读取行为不变 */
displayMode?: DisplayMode;
/** 展示名称(displayMode=DEPT 时生效,见后端 FlowDefModel.Node#displayName) */
displayLabel?: string;
} }
export interface DefEdgeJson { export interface DefEdgeJson {
@@ -146,6 +202,8 @@ export interface DefJson {
nodes: DefNodeJson[]; nodes: DefNodeJson[];
edges: DefEdgeJson[]; edges: DefEdgeJson[];
settings: EditorSettings; settings: EditorSettings;
/** 变量契约(D1):新发布版本后端要求显式声明,无业务变量时为 [] */
variables?: FlowVariable[];
} }
// ==================== 常量 ==================== // ==================== 常量 ====================
@@ -264,6 +322,7 @@ export function createEmptyModel(): EditorModel {
name: "", name: "",
list: [createApproval()], list: [createApproval()],
settings: { rejectPolicy: "END_REJECTED", urge: { ...DEFAULT_URGE } }, settings: { rejectPolicy: "END_REJECTED", urge: { ...DEFAULT_URGE } },
variables: [],
}; };
} }
@@ -376,6 +435,7 @@ export interface DeptOption {
/** 审批人摘要文案(卡片第二行;deptOptions 来自科室管理列表,userNames 为 userId→姓名 快照) */ /** 审批人摘要文案(卡片第二行;deptOptions 来自科室管理列表,userNames 为 userId→姓名 快照) */
export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string { export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
if (!a) return "未配置审批人"; if (!a) return "未配置审批人";
if (a.type === "DEPT") return subjectText("审批人", deptRefText(a.deptRefs));
if (a.type === "DEPT_KEY") { if (a.type === "DEPT_KEY") {
if (!a.deptKey) return "审批人:未选择科室"; if (!a.deptKey) return "审批人:未选择科室";
const d = depts.find((x) => x.deptKey === a.deptKey); const d = depts.find((x) => x.deptKey === a.deptKey);
@@ -398,6 +458,7 @@ export function nodeTags(n: ApprovalNode): string[] {
/** 抄送对象摘要文案(抄送卡片第二行;口径同 assigneeText,前缀换"抄送") */ /** 抄送对象摘要文案(抄送卡片第二行;口径同 assigneeText,前缀换"抄送") */
export function receiversText(r: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string { export function receiversText(r: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
if (!r) return "未配置抄送对象"; if (!r) return "未配置抄送对象";
if (r.type === "DEPT") return subjectText("抄送", deptRefText(r.deptRefs));
if (r.type === "DEPT_KEY") { if (r.type === "DEPT_KEY") {
if (!r.deptKey) return "抄送:未选择科室"; if (!r.deptKey) return "抄送:未选择科室";
const d = depts.find((x) => x.deptKey === r.deptKey); const d = depts.find((x) => x.deptKey === r.deptKey);
@@ -409,6 +470,16 @@ export function receiversText(r: Assignee | null, depts: DeptOption[], userNames
return `抄送:${r.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`; return `抄送:${r.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`;
} }
/** 引用键的口径化文本:只报"引用了哪几个部门",名称由部门目录解析,不在前端编造 */
function deptRefText(refs: DeptRef[] | undefined): string {
if (!refs || !refs.length) return "";
return refs.map((r) => `${r.departmentType}:${r.departmentId}`).join("、");
}
function subjectText(prefix: string, body: string): string {
return body ? `${prefix}:部门(UserBase 目录)${body}` : `${prefix}:未选择部门`;
}
// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ==================== // ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ====================
export interface EditorCtx { export interface EditorCtx {
@@ -66,6 +66,15 @@ export function validateModel(
push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`); push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`);
} }
} }
} else if (a.type === "DEPT") {
if (!a.deptRefs || !a.deptRefs.length) {
push(item.uid, "error", path, "未选择部门(UserBase 目录)");
} else if (a.deptRefs.some((r) => !r.departmentType || !r.departmentId)) {
push(item.uid, "error", path, "部门引用缺少 departmentType/departmentId");
}
if (item.displayMode === "DEPT" && !(item.displayLabel || "").trim()) {
push(item.uid, "error", path, "选择「显示部门」时必须填写展示名称,发布会被拦截");
}
} else if (!a.userIds.length) { } else if (!a.userIds.length) {
push(item.uid, "error", path, "指定用户不能为空"); push(item.uid, "error", path, "指定用户不能为空");
} }
@@ -110,6 +119,12 @@ export function validateModel(
push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`); push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`);
} }
} }
} else if (r.type === "DEPT") {
if (!r.deptRefs || !r.deptRefs.length) {
push(item.uid, "error", path, "未选择抄送部门(UserBase 目录)");
} else if (r.deptRefs.some((x) => !x.departmentType || !x.departmentId)) {
push(item.uid, "error", path, "抄送部门引用缺少 departmentType/departmentId");
}
} else if (!r.userIds.length) { } else if (!r.userIds.length) {
push(item.uid, "error", path, "抄送用户不能为空"); push(item.uid, "error", path, "抄送用户不能为空");
} }
+2 -1
View File
@@ -143,7 +143,8 @@ test("已发布版本有本节点:候选人按服务端解析结果展示(
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => { test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
const api = stubApi({ const api = stubApi({
applyPreview: async () => { applyPreview: async () => {
throw new Error("流程线已停发,不可发起新办件"); // 与 alova 层一致:业务信封 code≠0 的拒绝才带 bizCode,安全呈现层据此判定可信原文
throw Object.assign(new Error("流程线已停发,不可发起新办件"), { bizCode: 2 });
}, },
}); });
const out = await resolveNodePreview(cfg(), depts, api, "g1"); const out = await resolveNodePreview(cfg(), depts, api, "g1");
+110 -26
View File
@@ -71,12 +71,13 @@ describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => { it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost(); const wrapper = mountHost();
await openHost(wrapper); await openHost(wrapper);
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || ""; const src = frameEl()!.src;
expect(src.split("#").length - 1).toBe(1); expect(src.split("#").length - 1).toBe(1);
expect(src).toContain("#/assign-preview");
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`); expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain("contextId=");
expect((wrapper.vm as any).contextId).toBeTruthy(); expect((wrapper.vm as any).contextId).toBeTruthy();
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain(`contextId=${(wrapper.vm as any).contextId}`);
}); });
it("缺少 flowGuid 时不开框、不监听", async () => { it("缺少 flowGuid 时不开框、不监听", async () => {
@@ -84,13 +85,22 @@ describe("FlowAssignEmbed 地址与会话", () => {
const wrapper = mountHost({ flowGuid: "" }); const wrapper = mountHost({ flowGuid: "" });
(wrapper.vm as any).open(); (wrapper.vm as any).open();
await nextTick(); await nextTick();
await flushPromises();
expect(frameEl()).toBeNull(); expect(frameEl()).toBeNull();
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0); expect(addSpy.mock.calls.filter((c) => c[0] === "message")).toHaveLength(0);
addSpy.mockRestore(); addSpy.mockRestore();
}); });
}); });
describe("FlowAssignEmbed 消息校验", () => { describe("FlowAssignEmbed 消息校验", () => {
it("换线立即结束旧会话,旧 iframe 不能借新 props 回传选择", async () => {
const wrapper = mountHost(); await openHost(wrapper);
const contextId = (wrapper.vm as any).contextId;
await wrapper.setProps({ flowGuid: 'different-flow' });
await post(wrapper, baseData({ contextId, flowGuid: 'different-flow' }));
expect(wrapper.emitted('confirmed')).toBeUndefined();
expect(wrapper.emitted('cancelled')).toHaveLength(1);
});
it("协议相符的确认消息:回传最终选择与版本锚", async () => { it("协议相符的确认消息:回传最终选择与版本锚", async () => {
const wrapper = mountHost(); const wrapper = mountHost();
await openHost(wrapper); await openHost(wrapper);
@@ -98,25 +108,25 @@ describe("FlowAssignEmbed 消息校验", () => {
await post(wrapper, baseData({ contextId: cid })); await post(wrapper, baseData({ contextId: cid }));
const events = wrapper.emitted("confirmed"); const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns).toEqual([{ nodeId: "u2", userIds: [101] }]); const payload = events![0][0] as any;
expect((events[0][0] as any).defVersion).toBe(3); expect(payload.flowGuid).toBe("g-embed");
expect((events[0][0] as any).flowGuid).toBe("g-embed"); expect(payload.defVersion).toBe(3);
expect(payload.assigns).toEqual([{ nodeId: "u2", userIds: [101] }]);
expect(wrapper.emitted("cancelled")).toBeUndefined();
}); });
it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => { it("异源、异窗口、异 contextId、畸形信封一律丢弃,不影响宿主", async () => {
const wrapper = mountHost(); const wrapper = mountHost();
await openHost(wrapper); await openHost(wrapper);
const cid = (wrapper.vm as any).contextId; const cid = (wrapper.vm as any).contextId;
const fakeOtherWindow = { __other: true }; await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example" });
await post(wrapper, baseData({ contextId: cid }), { source: { __other: true } });
await post(wrapper, baseData({ contextId: cid }), { origin: "https://evil.example.com" }); await post(wrapper, baseData({ contextId: "stale-ctx" }));
await post(wrapper, baseData({ contextId: cid }), { source: fakeOtherWindow }); await post(wrapper, { proto: "fc", v: 1, type: "assign-confirmed", contextId: cid });
await post(wrapper, baseData({ contextId: "stale-from-prev-session" })); await post(wrapper, baseData({ contextId: cid, v: 2 }));
await post(wrapper, { proto: "fc", v: VERSION, type: "assign-confirmed", contextId: cid }); await post(wrapper, baseData({ contextId: cid, type: "assign-mystery" }));
await post(wrapper, { proto: PROTO, v: 99, type: "assign-confirmed", contextId: cid }); await post(wrapper, { nope: true });
await post(wrapper, { proto: PROTO, v: VERSION, type: "not-ours", contextId: cid }); await flushPromises();
await post(wrapper, "plain-string-from-page-script");
expect(wrapper.emitted("confirmed")).toBeUndefined(); expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined(); expect(wrapper.emitted("cancelled")).toBeUndefined();
}); });
@@ -126,6 +136,9 @@ describe("FlowAssignEmbed 消息校验", () => {
await openHost(wrapper); await openHost(wrapper);
const cid = (wrapper.vm as any).contextId; const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, assigns: undefined })); await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
await post(wrapper, baseData({ contextId: cid, defVersion: undefined }));
await post(wrapper, baseData({ contextId: cid, flowGuid: undefined }));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined(); expect(wrapper.emitted("confirmed")).toBeUndefined();
}); });
}); });
@@ -145,36 +158,33 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
await openHost(wrapper); await openHost(wrapper);
const cid = (wrapper.vm as any).contextId; const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid })); await post(wrapper, baseData({ contextId: cid }));
await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [777] }] })); await post(wrapper, baseData({ contextId: cid, assigns: [{ nodeId: "u2", userIds: [999] }] }));
const events = wrapper.emitted("confirmed"); const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1); expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]); expect((events![0][0] as any).assigns[0].userIds).toEqual([101]);
}); });
it("宿主自己关框(未答复):按取消处理并摘监听", async () => { it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost(); const wrapper = mountHost();
await openHost(wrapper); await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
(wrapper.vm as any).close(); (wrapper.vm as any).close();
await nextTick(); await nextTick();
expect(wrapper.emitted("cancelled")).toHaveLength(1); expect(wrapper.emitted("cancelled")).toHaveLength(1);
await post(wrapper, baseData({ contextId: cid })); expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
expect(wrapper.emitted("confirmed")).toBeUndefined(); removeSpy.mockRestore();
}); });
it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => { it("重开换新 contextId:旧会话残消息不影响新一轮,确认以最近一次为准(B 替换 A)", async () => {
const wrapper = mountHost(); const wrapper = mountHost();
await openHost(wrapper); await openHost(wrapper);
const cid1 = (wrapper.vm as any).contextId; const cid1 = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [101] }] })); await post(wrapper, baseData({ contextId: cid1 }));
expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]); expect((wrapper.emitted("confirmed")![0][0] as any).assigns[0].userIds).toEqual([101]);
// 宿主把 A 记在内存里,重开选 B
await openHost(wrapper); await openHost(wrapper);
const cid2 = (wrapper.vm as any).contextId; const cid2 = (wrapper.vm as any).contextId;
expect(cid2).not.toEqual(cid1); expect(cid2).not.toEqual(cid1);
// 上一轮残帧补发一条:按 context 失效
await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] })); await post(wrapper, baseData({ contextId: cid1, assigns: [{ nodeId: "u2", userIds: [999] }] }));
expect(wrapper.emitted("confirmed")).toHaveLength(1); expect(wrapper.emitted("confirmed")).toHaveLength(1);
@@ -202,3 +212,77 @@ describe("FlowAssignEmbed 生命周期与选择语义", () => {
removeSpy.mockRestore(); removeSpy.mockRestore();
}); });
}); });
describe("FlowAssignEmbed 载荷逐字段校验与真实关闭(F03/F07)", () => {
/** F03:宿主必须按打开时锁定的流程与字段类型核载荷,畸形载荷不得覆盖节点配置 */
it.each([
["异流程回传", () => ({ flowGuid: "g-other" })],
["字符串 defVersion", () => ({ defVersion: "3" })],
["零 defVersion", () => ({ defVersion: 0 })],
["小数 defVersion", () => ({ defVersion: 1.5 })],
["空 nodeId", () => ({ assigns: [{ nodeId: "", userIds: [101] }] })],
["字符串型 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: ["101"] }] })],
["空 userIds", () => ({ assigns: [{ nodeId: "u2", userIds: [] }] })],
["assigns 不是数组", () => ({ assigns: "u2=101" })],
])("畸形/异源载荷被丢弃:%s", async (_label, mutate) => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, ...(mutate() as object) }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("缺少可核验的窗口引用时不处理消息(不再是「缺条件就跳过校验」)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }), { source: {} as Window });
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
/** F07:点 X 是真实用户路径,必须走同一 settle/close 并通知宿主取消 */
it("点 Modal 的 X:宿主收到一次 cancelled,原有选择未被覆盖,监听器已摘", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const closeBtn = document.querySelector(".ivu-modal-close");
expect(closeBtn).not.toBeNull();
(closeBtn as HTMLElement).click();
await nextTick();
await flushPromises();
const cancelled = wrapper.emitted("cancelled");
expect(cancelled).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("程序化 close 与 X 走同一路径:重复关闭只 emit 一次 cancelled", async () => {
const wrapper = mountHost();
await openHost(wrapper);
(wrapper.vm as any).close();
(wrapper.vm as any).close();
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toHaveLength(1);
});
it("确认后不得再补发 cancelled(iframe 已答复,Modal 收起只是收尾)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid }));
expect(wrapper.emitted("confirmed")).toHaveLength(1);
const closeBtn = document.querySelector(".ivu-modal-close");
(closeBtn as HTMLElement)?.click();
await nextTick();
await flushPromises();
expect(wrapper.emitted("cancelled")).toBeUndefined();
expect(wrapper.emitted("confirmed")).toHaveLength(1);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import { reactive, nextTick } from 'vue';
import ViewUIPlus from 'view-ui-plus';
import ApprovalDrawer from '@/views/flow/admin/def-editor/components/ApprovalDrawer.vue';
import NotifyDrawer from '@/views/flow/admin/def-editor/components/NotifyDrawer.vue';
import { EDITOR_CTX, createApproval, createNotify } from '@/views/flow/admin/def-editor/model';
const { api } = vi.hoisted(() => ({ api: { deptGroups: vi.fn(), deptResolve: vi.fn(), deptOptions: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api }));
enableAutoUnmount(afterEach);
const ok = (data: unknown) => ({ send: async () => data });
describe('CUSTOM 引用真实抽屉编辑往返', () => {
for (const mode of ['approval', 'notify'] as const) {
it(`${mode}:无 categoryId 的历史解析仍可反查分类,保存重开再保存不丢引用`, async () => {
document.body.innerHTML = '';
const ref = { departmentType: 'CUSTOM', departmentId: 9 };
api.deptGroups.mockImplementation(() => ok([
{ departmentType: 'SYSTEM', name: '系统组织' },
{ departmentType: 'CUSTOM', categoryId: 4, name: '教研组' },
]));
api.deptResolve.mockImplementation(() => ok([{ ...ref, name: '科学组', memberCount: 2, selectable: true }]));
api.deptOptions.mockImplementation(() => ok([{ ...ref, categoryId: 4, name: '科学组', memberCount: 2, selectable: true }]));
const node: any = mode === 'approval' ? createApproval('审批') : createNotify('抄送');
const key = mode === 'approval' ? 'assignee' : 'receivers';
node[key] = { type: 'DEPT', deptRefs: [ref] };
const ctx = { model: reactive({ list: [node] }), depts: [], userNames: {}, routers: [], availableVars: [], issueOf: () => [] };
const wrapper = mount(mode === 'approval' ? ApprovalDrawer : NotifyDrawer, {
props: { node, modelValue: true }, attachTo: document.body,
global: { plugins: [ViewUIPlus], provide: { [EDITOR_CTX as unknown as symbol]: ctx } },
});
await flushPromises(); await nextTick();
expect(document.body.textContent).toContain('科学组');
expect(api.deptOptions).toHaveBeenCalledWith({ type: 'CUSTOM' });
for (let round = 0; round < 2; round++) {
if (round) { await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises(); }
const input = document.querySelector('input.ivu-input') as HTMLInputElement;
expect(input).toBeTruthy(); input.value = `修改名称${round}`; input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
const confirm = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '确定');
expect(confirm).toBeTruthy(); confirm!.click(); await nextTick();
expect(node[key]).toEqual({ type: 'DEPT', deptRefs: [ref] });
expect(node.name).toBe(`修改名称${round}`);
}
});
}
});
@@ -0,0 +1,27 @@
import { it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils';
import ViewUIPlus from 'view-ui-plus';
import TaskHandle from '@/views/flow/TaskHandle.vue';
const { api } = vi.hoisted(() => ({ api: { taskDetail: vi.fn(), approve: vi.fn() } }));
vi.mock('@/api/module/flow', () => ({ flowApi: api, INSTANCE_STATUS: { 10: '审批中' } }));
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { taskId: '1' }, path: '/task/1', query: { embed: '1' } }), useRouter: () => ({ push: vi.fn() }) }));
vi.mock('@/utils/taskEmbed', () => ({ isBareTaskRoute: () => true, postToParent: vi.fn() }));
enableAutoUnmount(afterEach);
const detail = { instanceId: 9, status: 10, myPendingTaskIds: [1], trace: [{ taskId: 1, status: 0 }], outlets: [] };
async function click(text: string) {
const b = Array.from(document.querySelectorAll('button')).find(x => x.textContent?.trim() === text);
expect(b).toBeTruthy(); b!.click(); await flushPromises();
}
for (const lost of [true, false]) it(lost ? 'POST 丢响应不声称未生效' : 'POST 成功但刷新失败保留成功并关闭办理入口', async () => {
document.body.innerHTML = '';
let reads = 0;
api.taskDetail.mockImplementation(() => ({ send: async () => { if (++reads > 1) throw new Error('refresh failed'); return detail; } }));
api.approve.mockImplementation(() => ({ send: async () => { if (lost) throw new TypeError('Failed to fetch'); return {}; } }));
mount(TaskHandle, { global: { plugins: [ViewUIPlus], stubs: { TraceTimeline: true } }, attachTo: document.body });
await flushPromises(); await click('同意'); await click('确定');
const text = document.body.textContent || '';
expect(text).not.toContain('本次办理未生效');
expect(text).not.toContain('任务仍是你的待办');
if (lost) expect(text).toContain('结果待确认');
else { expect(text).toContain('已提交成功'); expect(document.querySelector('.actions')).toBeNull(); }
});
+81 -26
View File
@@ -1,55 +1,110 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError"; import {
classifyFlowError,
describeFlowError,
flowFailTip,
flowReloadFailTip,
flowSubmitFailTip,
isDefiniteBusinessRejection,
FLOW_GENERIC_FALLBACK,
FLOW_NETWORK_FALLBACK,
FLOW_UNKNOWN_FALLBACK,
} from "../src/utils/flowError";
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */ /**
* 错误呈现与结果可信度单测(P0-1 附带项 + Codex 终审 F04/F05,2026-10-29)。
*
* F05 的口径不是"命中黑名单才挡",而是"拿不到可信凭据就不放行":
* 只有带 bizCode 的业务信封原文才可能进 DOM;HTTP 状态文本、异常/SQL/Token 形态、
* 以及无法判定来源的裸文本一律换成通用安全提示。
*
* F04 的口径是"失败 ≠ 未生效":服务端明确拒绝与"请求已发出但结果未知"必须分开说。
*/
test("后端给出通用 code=6 占位文案:换成不误导的兜底话术,不猜原因", () => { function bizError(message: string, bizCode = 2): Error {
assert.equal(describeFlowError("内部服务错误"), FLOW_GENERIC_FALLBACK); return Object.assign(new Error(message), { bizCode });
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 原样透出,不改写不误导", () => { function httpError(message: string, httpStatus = 500): Error {
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049"); return Object.assign(new Error(message), { httpStatus });
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理"); }
test("业务信封明确拒绝:具体业务话原样透出,不改写不误导", () => {
assert.equal(describeFlowError(bizError("流程线不存在:8d07f049")), "流程线不存在:8d07f049");
assert.equal(describeFlowError(bizError("该任务已被他人办理")), "该任务已被他人办理");
assert.equal( assert.equal(
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"), describeFlowError(bizError("该待办缺少流程推进记录,需管理员回填后办理")),
"该待办缺少流程推进记录,需管理员回填后办理", "该待办缺少流程推进记录,需管理员回填后办理",
); );
assert.equal(isDefiniteBusinessRejection(bizError("流程线不存在:g1")), true);
}); });
test("异常/SQL/令牌形态不回显:堆栈与凭据不进页面", () => { test("F05:HTTP 500 的响应原文不回显(旧实现把任意非黑名单文本都当业务话)", () => {
assert.equal(describeFlowError(Object.assign(httpError('private backend text'), { bizCode: 2 })), FLOW_GENERIC_FALLBACK);
assert.equal(isDefiniteBusinessRejection(Object.assign(httpError('private backend text'), { bizCode: 2 })), false);
assert.equal(describeFlowError(bizError('private backend text', 999)), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(bizError('private backend text', 6)), FLOW_GENERIC_FALLBACK);
for (const raw of ["Internal Server Error", "Bad Gateway", ""]) {
const out = describeFlowError(httpError(raw));
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.equal(isDefiniteBusinessRejection(httpError(raw)), false);
}
// 网关超时属网络口径:说"刷新重试"而不是把 "Gateway Timeout" 原文贴进页面
assert.equal(describeFlowError(httpError("Gateway Timeout", 504)), FLOW_NETWORK_FALLBACK);
});
test("F05:无法判定来源的裸文本走通用提示,不做黑名单后原样放行", () => {
// 没有 bizCode 的字符串/Error 不可能是"可信业务文案"
assert.equal(describeFlowError("Something went wrong at node 7"), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(new Error("流程线不存在:g1")), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(undefined), FLOW_GENERIC_FALLBACK);
assert.equal(describeFlowError(null), FLOW_GENERIC_FALLBACK);
});
test("F05:即使来自业务信封,异常/SQL/令牌形态也不回显", () => {
const leaks = [ const leaks = [
"java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required", "java.lang.IllegalStateException: D09 task has no proven activation; audited backfill required",
"at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)", "at com.yunhai.flowcenter.service.FlowTaskService.handle(FlowTaskService.java:100)",
"Caused by: SQLException error code [1064]", "Caused by: SQLException error code [1064]",
"refresh token=Bearer abcdef123456", "refresh token=Bearer abcdef123456",
"<html><body>500 Internal Server Error</body></html>", "<html><body>500 Internal Server Error</body></html>",
"内部服务错误",
]; ];
for (const raw of leaks) { for (const raw of leaks) {
const out = describeFlowError(raw); const out = describeFlowError(bizError(raw, 500));
assert.equal(out, FLOW_GENERIC_FALLBACK, raw); assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out)); assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out), raw);
} }
}); });
test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => { test("网络/登录态失败:按网络口径提示,不与业务失败混说", () => {
assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK); assert.equal(describeFlowError("网络错误"), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("Failed to fetch"), FLOW_NETWORK_FALLBACK); assert.equal(describeFlowError(new Error("Failed to fetch")), FLOW_NETWORK_FALLBACK);
assert.equal(describeFlowError("timeout of 20000ms exceeded"), FLOW_NETWORK_FALLBACK); assert.equal(classifyFlowError(httpError("timeout of 20000ms exceeded", 0)).kind, "network");
}); });
test("Error 对象可直接传入(取 message)", () => { test("F04:明确拒绝说拒绝;超时/丢响应只说结果待确认,不断言未生效", () => {
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1"); const rejected = flowSubmitFailTip("同意", bizError("仅任务办理人本人可办理"));
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK); assert.ok(rejected.startsWith("同意被拒绝:"), rejected);
assert.ok(rejected.includes("仅任务办理人本人可办理"), rejected);
const lost = flowSubmitFailTip("同意", new TypeError("network failure"));
assert.ok(lost.startsWith("同意结果待确认:"), lost);
assert.ok(lost.includes(FLOW_UNKNOWN_FALLBACK) || lost.includes(FLOW_NETWORK_FALLBACK), lost);
assert.ok(!lost.includes("未生效"), lost);
assert.ok(!lost.includes("仍是你的待办"), lost);
}); });
test("页面内联提示带动作名,明确「未生效」而不是成功", () => { test("F04:办理成功但后续刷新失败 → 说明已成功、只是没刷新,不改口成失败", () => {
const tip = flowFailTip("同意", new Error("内部服务错误")); const tip = flowReloadFailTip("同意");
assert.ok(tip.startsWith("同意未完成:")); assert.ok(tip.includes("已提交成功"), tip);
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK)); assert.ok(tip.includes("刷新"), tip);
assert.ok(!tip.includes("已同意")); });
test("只读动作失败的内联提示带动作名,不谎报成功", () => {
const tip = flowFailTip("催办", bizError("已达催办次数上限(3 次)"));
assert.ok(tip.startsWith("催办未完成:"), tip);
assert.ok(tip.includes("已达催办次数上限(3 次)"), tip);
assert.ok(!tip.includes("成功"), tip);
}); });
+209
View File
@@ -0,0 +1,209 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { fromContentJson, toContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createEmptyModel } from "../src/views/flow/admin/def-editor/model";
import { isValidAssignMessage, buildMessage } from "../src/utils/assignEmbed";
/**
* FL 2.0 缺口补足的定义契约往返 + 选人载荷校验单测(2026-10-29)。
*
* 覆盖三件"换皮不能破"的事:
* 1. C1/C3:UserBase 类型化部门引用与节点展示方式在 保存→重新加载→再次保存 中逐字段存活;
* 2. D1:变量契约持久化/回显,且编辑态专用字段(enumText)不进 contentJson;
* 3. F03/F07:选人 iframe 宿主对畸形/异源/异流程载荷一律拒绝。
*
* 注意:并行容器派生节点 id 重编号(Codex F08)是基线既有行为,本文件刻意不把它写成"已修复"。
*/
const ORIGINAL = JSON.stringify({
code: "flow-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
name: "缺口补足线",
start: "start",
nodes: [
{ id: "start", type: "START" },
{
id: "n1",
type: "APPROVAL",
name: "一级审批",
assignee: { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] },
countersign: "ANY",
displayMode: "DEPT",
displayLabel: "校长室",
},
{
id: "n2",
type: "APPROVAL",
name: "二级审批",
assignee: { type: "USERS", userIds: [101, 102] },
countersign: "ALL",
assignPolicy: { mode: "SINGLE", required: true },
},
{
id: "n3",
type: "NOTIFY",
name: "抄送",
receivers: {
type: "DEPT",
deptRefs: [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
],
},
},
{ id: "end", type: "END" },
],
edges: [
{ from: "start", to: "n1" },
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3" },
{ from: "n3", to: "end" },
],
variables: [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
],
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
});
test("部门引用与展示方式:载入→保存→再载入逐字段存活,且不写入名称快照", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
const json = toContentJson({
code: parsed.code,
name: parsed.name,
list: parsed.list,
settings: parsed.settings,
variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.deepEqual(n1.assignee, { type: "DEPT", deptRefs: [{ departmentType: "SYSTEM", departmentId: 42 }] });
assert.equal(n1.displayMode, "DEPT");
assert.equal(n1.displayLabel, "校长室");
assert.equal(n1.countersign, "ANY");
const n3: any = json.nodes.find((n) => n.id === "n3");
assert.deepEqual(n3.receivers.deptRefs, [
{ departmentType: "CUSTOM", departmentId: 7 },
{ departmentType: "SYSTEM", departmentId: 42 },
]);
// 第二次往返不变形(保存-重载-再保存)
const reparsed = fromContentJson(JSON.parse(JSON.stringify(json)));
const again = toContentJson({
code: reparsed.code,
name: reparsed.name,
list: reparsed.list,
settings: reparsed.settings,
variables: reparsed.variables,
});
assert.deepEqual(again.nodes, json.nodes);
});
test("未配置展示方式的历史节点不写出 displayMode(旧定义字节兼容)", () => {
const legacy = JSON.parse(ORIGINAL);
delete legacy.nodes[1].displayMode;
delete legacy.nodes[1].displayLabel;
const parsed = fromContentJson(legacy);
const json = toContentJson({
code: parsed.code, name: parsed.name, list: parsed.list,
settings: parsed.settings, variables: parsed.variables,
});
const n1: any = json.nodes.find((n) => n.id === "n1");
assert.equal("displayMode" in n1, false);
assert.equal("displayLabel" in n1, false);
});
test("D1 变量契约:required/enum 持久化,编辑态 enumText 不进 contentJson", () => {
const parsed = fromContentJson(JSON.parse(ORIGINAL));
assert.deepEqual(parsed.variables?.map((v) => v.key), ["days", "grade"]);
assert.equal(parsed.variables?.[0].required, true);
assert.deepEqual(parsed.variables?.[1].enum, ["g1", "g2"]);
const json = toContentJson({
code: "x", name: "x", list: parsed.list, settings: parsed.settings, variables: parsed.variables,
});
assert.deepEqual((json as any).variables, [
{ key: "days", type: "number", required: true },
{ key: "grade", type: "string", enum: ["g1", "g2"] },
]);
assert.equal("enumText" in ((json as any).variables[1]), false);
});
test("D1 新建流程显式声明空变量集(后端要求新发布版本必须声明)", () => {
const model = createEmptyModel();
const json = toContentJson(model);
assert.deepEqual((json as any).variables, []);
});
test("D1 逗号输入的值域归一为字符串数组,中文逗号同样切开", () => {
const model = createEmptyModel();
model.variables = [{ key: "grade", type: "string", required: false, enumText: "优,良, 中 ," }];
const json = toContentJson(model);
assert.deepEqual((json as any).variables, [{ key: "grade", type: "string", enum: ["优", "良", "中"] }]);
});
// ==================== F03 选人 iframe 载荷校验 ====================
const ORIGIN = "https://notice.example.edu.cn";
const WINDOW = {} as Window;
const CONTEXT = "ctx-1";
const FLOW = "fg-locked";
function frameEvent(data: unknown, origin = ORIGIN, source: Window | null = WINDOW) {
return { origin, source, data } as unknown as MessageEvent;
}
function confirmed(overrides: Record<string, unknown> = {}) {
return {
proto: "flow", v: 1, type: "assign-confirmed", contextId: CONTEXT,
flowGuid: FLOW, defVersion: 3,
assigns: [{ nodeId: "n1", userIds: [101] }],
...overrides,
};
}
function valid(event: MessageEvent) {
return isValidAssignMessage(event, ORIGIN, WINDOW, CONTEXT, FLOW);
}
test("F03:正向消息通过,且逐字段带出", () => {
const msg = valid(frameEvent(confirmed()));
assert.ok(msg);
assert.equal(msg?.flowGuid, FLOW);
assert.equal(msg?.defVersion, 3);
});
test("F03:origin/source/context 任一缺失或不符即丢弃(不再是「缺条件就跳过校验」)", () => {
assert.equal(valid(frameEvent(confirmed(), "https://evil.example")), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), ORIGIN, null, CONTEXT, FLOW), null);
assert.equal(isValidAssignMessage(frameEvent(confirmed()), "", WINDOW, CONTEXT, FLOW), null);
assert.equal(valid(frameEvent(confirmed({ contextId: "stale-ctx" }))), null);
});
test("F03:异流程回传丢弃(消息 flowGuid 必须等于打开时锁定的流程)", () => {
assert.equal(valid(frameEvent(confirmed({ flowGuid: "fg-other" }))), null);
});
test("F03:畸形载荷整条拒绝——字符串版本、空节点、非数字 ID、缺 assigns", () => {
assert.equal(valid(frameEvent(confirmed({ defVersion: "3" }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 0 }))), null);
assert.equal(valid(frameEvent(confirmed({ defVersion: 1.5 }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "", userIds: [101] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: ["101"] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: [{ nodeId: "n1", userIds: [] }] }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: "n1=101" }))), null);
assert.equal(valid(frameEvent(confirmed({ assigns: undefined }))), null);
});
test("F03:协议版本与类型闭集不被放宽", () => {
assert.equal(valid(frameEvent(confirmed({ v: 2 }))), null);
assert.equal(valid(frameEvent(confirmed({ proto: "fc" }))), null);
assert.equal(valid(frameEvent(confirmed({ type: "assign-mystery" }))), null);
assert.equal(valid(frameEvent({ nope: true })), null);
});
test("取消消息只需 context,不携带选择也不被要求带 flowGuid", () => {
const cancelled = { proto: "flow", v: 1, type: "assign-cancelled", contextId: CONTEXT };
const msg = valid(frameEvent(cancelled));
assert.equal(msg?.type, "assign-cancelled");
assert.equal(buildMessage(cancelled as any)?.type, "assign-cancelled");
});