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