From bed57846f4bab787cd482dd5a15eccc464783329 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 30 Oct 2026 10:15:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E8=90=BD=E5=9C=B0=20FL2.0=20?= =?UTF-8?q?=E5=8E=9F=E5=9E=8B=E2=80=94=E2=80=94=E9=83=A8=E9=97=A8=E9=80=89?= =?UTF-8?q?=E4=BA=BA=E6=8A=BD=E5=B1=89=20/=20=E6=8E=88=E6=9D=83=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=20/=20=E9=80=80=E5=9B=9E=E9=87=8D=E6=8F=90?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88=E5=9F=BA=E7=BA=BF=20origin/main?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 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)与依赖目录 --- package.json | 2 +- src/api/module/flow.ts | 85 ++- src/components/FlowAssignEmbed.vue | 42 +- src/router/routes.ts | 8 + src/utils/alova/alova-core.ts | 23 +- src/utils/alova/alova-iview.ts | 11 +- src/utils/assignEmbed.ts | 58 +- src/utils/flowError.ts | 112 ++- src/views/flow/TaskHandle.vue | 84 ++- src/views/flow/Workbench.vue | 37 +- src/views/flow/admin/AppSetting.vue | 11 +- src/views/flow/admin/Defs.vue | 80 ++- src/views/flow/admin/Instances.vue | 1 + src/views/flow/admin/UsageGrants.vue | 417 +++++++++++ .../flow/admin/def-editor/assignPreview.ts | 83 ++- .../def-editor/components/ApprovalDrawer.vue | 674 +++++++++++++++++- .../components/AssigneePreviewModal.vue | 2 + .../def-editor/components/NotifyDrawer.vue | 575 ++++++++++++++- src/views/flow/admin/def-editor/convert.ts | 64 +- src/views/flow/admin/def-editor/index.vue | 89 ++- src/views/flow/admin/def-editor/model.ts | 79 +- src/views/flow/admin/def-editor/validate.ts | 15 + tests/assign-preview.test.ts | 3 +- .../components/assign-embed.component.test.ts | 136 +++- .../department-roundtrip.component.test.ts | 48 ++ .../components/task-result.component.test.ts | 27 + tests/flow-error.test.ts | 107 ++- tests/flow2-contract.test.ts | 209 ++++++ 28 files changed, 2829 insertions(+), 253 deletions(-) create mode 100644 src/views/flow/admin/UsageGrants.vue create mode 100644 tests/components/department-roundtrip.component.test.ts create mode 100644 tests/components/task-result.component.test.ts create mode 100644 tests/flow2-contract.test.ts diff --git a/package.json b/package.json index a730b61..9c096aa 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index cc700fd..4ab37f7 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -18,12 +18,13 @@ function post(url: string, body?: any, config?: Record): M return new Method("POST", alovaInstance, url, config || {}, body); } -/** 状态枚举(后端 FlwEnums 口径) */ +/** 状态枚举(后端 FlwEnums 口径;50=D14 退回待修改,仍是在途非终态) */ export const INSTANCE_STATUS: Record = { 10: "审批中", 20: "已完成", 30: "已拒绝", 40: "已撤回", + 50: "退回待修改", }; /** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */ @@ -32,6 +33,7 @@ export const INSTANCE_STATUS_COLOR: Record = { 20: "green", 30: "red", 40: "gray", + 50: "orange", }; export const TASK_STATUS: Record = { @@ -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) { + 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) { + 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) { + return post(`${ROOT}/task/${taskId}/returnToSubmitter`, { opinion }, config); + } + /** 发起人重提(仅 50 退回待修改状态、仅发起人本人;同实例同轮次) */ + resubmit(instanceId: number | string, config?: Record) { + 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) { + 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) { + return post(`${ROOT}/task/${taskId}/reject`, { opinion }, config); } /** 撤回(提交人;仅尚无任务被办理时) */ - withdraw(instanceId: number | string) { - return post(`${ROOT}/instance/${instanceId}/withdraw`); + withdraw(instanceId: number | string, config?: Record) { + return post(`${ROOT}/instance/${instanceId}/withdraw`, {}, config); } /** 催办(提交人/管理员) */ - urge(instanceId: number | string) { - return post(`${ROOT}/instance/${instanceId}/urge`); + urge(instanceId: number | string, config?: Record) { + 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(`${ROOT}/admin/department/resolve`, { type, departmentId }); + } + /** 部门成员(只读预览;scope=select 新指派严档 / history 既有实例宽档) */ + deptDirectoryMembers(type: string, departmentId: number, scope?: "select" | "history") { + return get(`${ROOT}/admin/department/members`, { type, departmentId, scope }); + } + + // ==================== 管理面:应用使用许可(D02/D09/D13 独立授权) ==================== + /** 某流程线的授权清单(含已撤销行,审计可见) */ + grantList(flowGuid: string) { + return get(`${ROOT}/admin/usage-grant/list`, { flowGuid }); + } + /** 本校全部授权行(校域由管理员会话派生,前端不传 schId) */ + grantSchoolList() { + return get(`${ROOT}/admin/usage-grant/schoolList`); + } + /** 流程线 → 生效授权应用数(列表页授权摘要列) */ + grantSummary() { + return get>(`${ROOT}/admin/usage-grant/summary`); + } + /** 授予/重申使用许可(appKey 由后端解析成稳定主体;学校来自管理员会话) */ + grantSet(body: { flowGuid: string; appKey: string; remark?: string }, config?: Record) { + return post(`${ROOT}/admin/usage-grant/grant`, body, config); + } + /** 撤销使用许可(enabled=0 行保留;只拦新发起,在途与已受理回执不受影响) */ + grantRevoke(body: { flowGuid: string; appId: number; remark?: string }, config?: Record) { + return post(`${ROOT}/admin/usage-grant/revoke`, body, config); + } + // ==================== 管理面:科室管理(2026-10-10,取代旧 /map/* 映射 CRUD) ==================== /** 科室列表(deptKey=自增 id 字符串 + 启用成员数;编辑器 ctx.depts 同结构) */ deptList() { diff --git a/src/components/FlowAssignEmbed.vue b/src/components/FlowAssignEmbed.vue index 8e82f04..33fa1b5 100644 --- a/src/components/FlowAssignEmbed.vue +++ b/src/components/FlowAssignEmbed.vue @@ -41,9 +41,12 @@ const contextId = ref(""); const frameEl = ref(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 });