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 });