Compare commits

...
68 Commits
Author SHA1 Message Date
chenxing c93313cdb7 feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
build / build (push) Successful in 1m28s
- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
2026-10-28 03:18:54 +08:00
chenxing f2841e2740 release: FL 2.0 前端候选(流程线停发/发起页指派预览/测试夹具)
build / build (push) Successful in 1m33s
配套 service 候选 500a9a2:
- flow.ts 增加 admin/def/offlineLine、enableLine(T03 线级停发/恢复)
- Defs.vue 接线级停发开关与状态展示;lineOffline.ts 收敛判定与文案
- AssignPreview.vue + assignEmbed.ts 发起时指派预览(T02 契约)
- def-editor 表单坐标绑定微调
- 新增 vitest 组件测试与 line-offline 契约测试(npm test 可跑)
2026-10-27 12:07:40 +08:00
chenxing cdea9c559b t1: 定义编辑器接表单中心绑定(表单选择与版本坐标)
build / build (push) Successful in 1m36s
2026-10-22 10:03:34 +08:00
ops 5534cd107a fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:26 +00:00
ops 1e852c78be fix(ci): 构建容器名唯一化(避免并发构建互相 rm -f 致 exit 137) 2026-10-21 02:26:47 +00:00
ops 8392635571 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:57 +00:00
chenxing 4849b53541 [fix] 办理页隐藏「业务表单数据」:formDataJson 是托管模式条件路由入参,只留管理面办件详情
build / build (push) Successful in 3m59s
- TaskHandle 移除该区块与 formRows 计算(保留 formValueText 供业务信息回退行复用)
- 管理面 InstanceBody full 不变
2026-10-19 20:33:50 +08:00
chenxing 8853c0faa3 [fix] 办理页联调修复:业务信息只认 summaryFields / 业务详情用 bizUrlAbs / bare 态不叠遮罩层
build / build (push) Successful in 3m50s
- 业务摘要 → 业务信息:只渲染 bizSnapshot.summaryFields(label+value,口径同 InstanceBody),
  不再平铺英文键(summaryFields 曾渲染成 [object Object]);老数据仅回退 标题/摘要/附件
- 查看业务详情:独立打开改用后端解析的 bizUrlAbs(裸路径不再落到本应用 origin),拿不到不渲染
- bare 态(embed=1):同意改行内确认条、退回/移交改行内面板(Modal 在 iframe 内会与父页面弹窗套娃)
- bare 态流程轨迹默认折叠(父页面已有审批进度,同一份 trace 不同屏铺开两次)
2026-10-19 20:09:41 +08:00
chenxing ba448fc7a0 [v2.1] A 去壳任务办理:App 增 bare 任务形态(/task/*?embed=1 保 CAS 去布局),TaskHandle 办理成功/查看业务/高度变化 postMessage 回父页,bare 不跳工作台
build / build (push) Successful in 2m30s
2026-10-19 16:50:49 +08:00
chenxing 04fd54e945 [audit] P6.6 F6 前端指派选择器共享组件(纯新增备用件:collect/validate/watch 重拉 + applyPreview API 预置;未接入页面)
build / build (push) Successful in 7m44s
2026-10-18 08:24:08 +08:00
chenxing c7e7a7aa85 [audit] P6.3 F5-4 重跑确认框改 Modal+复选「按当前已发布流程定义重跑(救僵尸办件)」 2026-10-18 08:18:28 +08:00
chenxing 33500e23d6 [audit] P6.1 F11-4 台账状态筛选增「路由挂起」伪选项 + 行状态列 routeHung 红标 2026-10-18 08:06:10 +08:00
chenxing 650665224c [ysch-flow] S2.1 修向导④条件查看按钮(P0,Vue3 render 形态)
Onboarding 向导④流程清单的「查看条件」入口断裂两处:Button 未从 view-ui-plus 导入,
且 render 用了 Vue2 的 { props: {...}, on: { click } } 形态——Vue3 h() 把它们当普通
prop 名传入,点击不绑、type/size 全丢,变量契约自查入口因此不可用(实测报告缺陷#2)。
改为 h(Button, { type, size, onClick }, () => 文本);同仓 AppSetting「查推送日志」列
同一写法残留一并修正。npm run build 绿,diff 仅涉事 2 文件 4 行。
2026-10-17 23:06:57 +08:00
chenxing 98ea069691 feat(onboarding): 接入向导五步页 + 配置生成器(议题二 P1)
build / build (push) Successful in 1m58s
把子应用接入从「人背文档 + 逐字对齐字符串」变成「向导引导 + 机器生成正确配置 + 每步可自检」。

- Onboarding.vue:五步(应用档案 / 工程接入 / 回调注册 / 流程编排 / 联调验收),步骤条可任意跳转不强制线性
- STEP② 配置生成器为核心:按「端点形态(平台验签式/X-Flw 式)× 接入场景(新接入/存量升级)× ① 的 context-path/bizType」实时生成 yh.flow-callback 块、Maven 依赖、FlowCallbackHandler 骨架。X-Flw 式追加「端点第二段非 oapi、必须显式 callback-url 直连」警示;无 ctx 时 base-path 整行注释。对齐规范 §7.2 推导式
- 前置条件清单把散落文档里的前置变成可勾选待办;Redis 公共组件与 trust-registry 两条高危静默断点红底强调
- STEP③ 登记检查:私网/开发机地址、base_path 漏填、与声明的 target 不一致逐条判定给修复建议;「发一条 flow.ping」复用 callbackProbe
- STEP④ 变量契约显性化:解析 contentJson 取 CONDITION 节点出边表达式,与已知变量(近 50 条办件 formData 聚合 + 内置 8 个,口径同编辑器)核对,引用从未出现的键即警示「运行时会挂起」
- STEP⑤ 六项自检 + 一键生成 markdown 自检报告(纯前端拼装不落库)

数据源全部复用已有接口(appList/regList/callbackProbe/defList/defGet/callbackLogs/adminInstances),零新增表、零新增写接口。路由 /admin/onboarding(modId 108,需 UserBase 配 app_module)。vite build 通过,产物已构建进 front/dist/。
2026-10-15 15:50:08 +08:00
chenxing 1369b103ee feat(condition): 条件路由变量契约收口 + 子应用对接健康度面板(议题一 P0/P1 + 议题二 P2)
build / build (push) Successful in 2m51s
议题一(条件路由体验收口):
- 编辑器动作入口上移顶栏与「保存草稿」同排,右栏「快捷入口」整块删除(流程图痛点 7)
- 发布期变量引用静态校验:validateModel 增加 knownVars 参数,出边表达式引用未在已知变量中的标识符给 warn 级提示。只提示不阻断——拼错与「运行时才有的动态变量」在静态期无法区分,宁可放过
- CONDITION 判断方式文案业务语言化:「服务(外部判断接口)/表达式」→「到达该节点时才能确定/提交时就已确定」,各补一行说明;syncFromNode 取值归一化逻辑未动,零兼容风险
- 内置 8 变量与表达式变量提取抽到 def-editor/model.ts 单一来源(EXPR_BUILTIN_VARS/extractExprVars/unknownExprVars),ConditionBox 与 ExprEvalModal 改为消费,去掉两份本地重复实现

议题二(子应用接入健康度):
- AppSetting.vue 新增 tab3「对接健康度」:每应用一行(评分色块/结论/六项检查 chips/结论说明),问题应用 bad>warn>ok 置顶;首次切入该 tab 才请求,避免进页面就多打一次聚合查询
- Callbacks.vue 新增 appKey 筛选框 + query.appKey 预填,承接健康度面板「查推送日志」跳转
- api 层新增 callbackHealth()

数据来源为已有资产(flw_callback_reg / flw_callback_log),零新增表。对齐《子应用接入流程中心规范.md》§4.2/§7 与《条件路由与子应用接入-痛点调研-20261014.md》。vite build --mode base 通过,产物已构建进 front/dist/。
2026-10-15 14:51:04 +08:00
chenxing 0542279636 feat(condition): 条件表达式内联变量 chips(议题一 P0)
编辑器出口表达式下展示可用变量(近期 50 条办件 formData 顶层键聚合去重截 40 + 内置,口径同调试弹窗),点击插入光标处;未知标识符内联提示疑似拼错(仅提示不拦截,去字符串/函数名/属性访问后比对)。EditorCtx 增 availableVars,index.vue 载入一次(拉取失败退化为仅内置,不阻塞编辑)。vite build:base 通过。
2026-10-14 18:18:42 +08:00
chenxing fbd428b913 chore: 实施前基线快照(条件路由/接入改造前)
表格加载去图形资产:删除 public/assets 下 loading.gif,.table-spin-load 改用 .ysch-spin(CSS 通道,随 --brand),TablePage/Alova/AlovaNew 三处同步。
2026-10-14 18:12:32 +08:00
chenxing 859210bf07 fix(def/ui): 「生效/在途」列 190→240——列内有 render(Tag 组件)时,用 innerText 克隆估宽会漏掉 Tag 自身的 padding/间距,实测两徽标并排真占 94+16+77=187px,+单元格内边距 36 需 ≥223,190 会换行成两行
build / build (push) Successful in 2m26s
2026-10-14 16:57:03 +08:00
chenxing 6998152c88 chore(ci): 空提交触发前端重建——上一次构建(Gitea task 1491, commit 26d9314)在 npm ci 阶段被并发任务杀死(exit 137):本仓与 aca-syllabu-front 的 build workflow 都用固定容器名 frontbuild,对方 08:33:39 的 docker rm -f frontbuild 把我的容器干掉(日志时间戳对齐)。代码零改动,仅重跑构建
build / build (push) Successful in 2m9s
2026-10-14 16:50:04 +08:00
chenxing 26d93148dc fix(def/ui): 子表列宽再校正——创建时间 150→160(实测该单元行盒=2 仍折行;同文案在主表「发布时间」160 下已证单行)、创建人 100→110(留长登录名余量);VERSIONS_TABLE_WIDTH 880→890
build / build (push) Failing after 39s
2026-10-14 16:32:48 +08:00
chenxing 070f2f5ee8 fix(def/ui): 列宽按实测校正(上线后复验发现 200 不够,guid 仍折行)——GUID 200→320(32 位 guid 单行实测最宽 265px + 单元格左右内边距 36px = 301)、编码 160→200(实测 155+36=191)、子表创建时间 140→150(实测 101+36=137)、生效/在途 230→190(实测 134+36=170,收紧);平铺态同步 GUID 320 / 编码 200
build / build (push) Successful in 2m27s
2026-10-14 16:24:08 +08:00
chenxing 8962dac7d2 fix(def/ui): 分组态列表六项样式——①固定列层容器 pointer-events:none(根因:fixed:right 固定层在展开行只渲染 visibility:hidden 占位格,命中测试落到其 <table> 上,吞掉子表「更多」等控件的 hover/click,表现为看得见点不动);②标题列定宽 260 + ellipsis + Tooltip(列上写 render 时 iView 内置 column.tooltip 不生效,故手挂 Tooltip;并补 .ivu-table-cell-ellipsis 内 Tooltip 两层包装降级 inline——否则省略号变硬裁切,无头 Edge 实测);③当前生效/在途 两徽标拆出独立列「生效/在途」(230);④GUID 列 130→200,32 位 guid 全显不再截 8 位;⑤主操作列 140→190(140 装不下「版本历史+编辑信息」138px 内容);⑥展开仅响应左侧箭头(Table on-expand,行内点击不再切换);另:子表「改动说明」压到 260+Tooltip,并给内层 Table 传 width=870,避免 iView 把展开行余宽摊回该列(顺带让子表右端不再压到固定条带下方)
build / build (push) Successful in 2m59s
2026-10-14 16:13:50 +08:00
chenxing 0a060ab22a feat: 审批节点发起时指派配置(开关/单选多选/必选)与转换无损
build / build (push) Successful in 5m34s
2026-10-13 12:05:22 +08:00
chenxing ec810afd14 fix(trace): 科室视图抄送行遮蔽——新增 utils/flowTrace.ts(collapseCcRows 按 nodeId#round 折叠 CC 行记 ccCount、ccActorText 科室显 deptName‖短码/其余显"抄送对象"、不回落姓名;user/缺省档原引用直返);TraceTimeline/InstanceBody 三视图接折叠与 CC 分支(时间线由组件内折叠、节点/列表走 displayTrace 防二次折叠);EmbedInstance 头部补流程 nodeSummary。真机三视图走查并入全流程重走 P2-E
build / build (push) Successful in 1m57s
2026-10-12 20:45:41 +08:00
chenxing e01ef87380 fix(def/ui): 验收审计补丁——调试弹窗内置变量口径与 chips 区分;分组态空 flowGuid 兜底
build / build (push) Successful in 1m56s
- ExprEvalModal:内置变量条 submitterId 改显真实 id(原用 submitterName 冒充,用户照抄进
  样例 JSON 会填错类型);可用变量 chips 区分内置变量(灰底,对齐原型 .chip.bt)并让手动模式
  同样提供内置键(原型 S5「内置变量同款可插」);chips 去重,避免 formData 键与内置键同名重复。
- Defs:分组视图空 flowGuid 行不再被整体丢弃,各自单列一行;openMap 改用合成 key(__noguid__{id})
  防止与其他行共用 "" 键互相牵连;GUID 列空值显示「—」。
- 验证:npm run build 通过(1m16s,产物含 Defs / ExprEvalModal chunk);
  npx tsc --noEmit 48 条与既有基线完全一致、本次改动文件 0 条。

来源:流程中心改造-验收审计报告-B2-B6-20261012.md §三.3c / §三.5
2026-10-12 18:03:34 +08:00
chenxing 94f263d489 feat(condition): 新建条件分支缺省翻转为表达式(EXPRESSION 主路径)——抽屉初值+createCondition 默认;SERVICE 分支补提示文案(仅实时业务数据场景使用,端点与登记由流程中心接入件 starter 代办);来源列注释订正 starter 名 2026-10-12 17:13:21 +08:00
chenxing adcdd1217e B4 补充:办件下拉给 Option 显式 label(#id 标题),filterable 搜索与选中回显不依赖槽内富文本 2026-10-12 17:09:33 +08:00
chenxing b96d3d6451 B4+B5 表达式调试三模式弹窗(共享组件)+ 编辑器出口内联试算
- 新增 views/flow/components/ExprEvalModal.vue(原型 S1-S5,宽 560→700):
  1) 从办件导入:adminInstances(page1,size50) 的 filterable Select(#id·bizType/bizId·标题·提交人·时间+状态Tag)、
     bizType distinct chips 筛选、选中即 formData 解析缩进填充(解析失败填原文+红字)、内置变量只读条
     (instanceId/instanceVersion/bizType/bizId/submitterId;nodeId/nodeName 提示按调试目标自填;schId 不展示)
  2) 从路由日志导入:detail(instanceId) 取 kind=ROUTE 行,表格 节点名/注册键/命中出口(空=未命中)/attempt/时间
     + 每行 rawResponse <pre>(>200 字截断、title 全量、无留痕显示「(无留痕)」);
     「载入该办件变量并复现」= 填 formData + 保留表达式再求值;无 ROUTE 行给空态文案
  3) 手动编写:原 textarea 语义保留 + 最近 50 条办件 formData 键聚合 chips(点击插光标处)+ 引导文案
  可用变量 chips 点击插入表达式光标(selectionStart/End,无焦点退化追加);求值仍走 /admin/router/eval,
  结果区四态文案(true/false/error/未求值)含线上行为说明与「宁挂起不误放行」口径
- Routers.vue:删旧内联求值弹窗(evalForm/evalResult/doEval),改挂 <ExprEvalModal v-model="exprVisible">
- ConditionBox.vue(B5/S6):EXPRESSION 模式出口表达式行右侧加「试算」小钮(@click.stop 不触发容器交互),
  带该出口表达式打开同一弹窗

注意:本提交连带纳入了并发会话在 Routers.vue 的 6 行「来源」列改动(regAppKey 自注册标识,
属 SERVICE 收口工作),非本批次内容,review 时请单独留意
2026-10-12 16:35:26 +08:00
chenxing dda7336323 B3-2 编辑器顶栏精简 + 底部状态栏退役(配 B3-1 面板,两步提交可单独回滚)
- 顶栏只留 返回/「流程编辑器」/状态 Tag + 右侧「保存草稿」(名称/编码/应用三个输入框从 hd-mid 迁走,
  双向同源仍是 model.name/model.code/appKey 同一 ref)
- 删 footer.ed-status:错误/警告汇总进右面板分区3,「保存仅生成草稿;发布校验以后端为准」并入分区2 提示行
- 顺带删 .hd-mid/.ed-status/.st-* 死样式;.ed-canvas 加 min-width:0(1360px 档不出横向滚动)

验证:npm run build 通过
2026-10-12 16:21:54 +08:00
chenxing f0ec500348 B3-1 编辑器右信息面板 + 校验清单定位(先加面板,保存链路不动)
- index.vue:画布与面板包进 .ed-body(flex row),新增 aside.ed-info 五分区——
  流程信息(名称/编码/归属应用,与画布同源同一 ref,编码沿用 !!editId||!!baseGuid 禁用)/
  状态与版本(状态 Tag + v 版本号 + flowGuid 等宽 + 复制,新建态显示「保存后生成」)/
  校验问题清单(错误在前警告在后,项内 path+msg)/ 结构统计(walkItems 递归四类计数)/
  快捷入口(流程设置/源码/图示预览,:disabled=!editId 同现状)
- sourceFlowGuid ref:load() 里 defGet 后取 d.flowGuid,新建态空串
- locateIssue:清单项点击 scrollIntoView 居中 + .nd-locate 高亮 2.8s;三张卡片根元素加 :id="'nd-'+node.uid",
  uid 为空的清单项 class=fix 不可点;高亮样式走 :deep(卡片在子组件内)
- 画布左对齐:.ed-canvas align-items center→flex-start

验证:npm run build 通过;npx tsc --noEmit 48 条=基线无新增;convert 往返 ALL PASS
2026-10-12 16:19:51 +08:00
chenxing 5cf1ce8d1f B2 议题二前端:版本历史补全 + 升版本改动说明 + 列表分组 + 在途可见
- Defs.vue 版本历史弹窗 640→820,补 创建人/创建时间/改动说明 三列(存量说明显示「(未填写)」)
- def-editor 升版本保存弹「本次改动说明(可跳过)」:doSave 入口设闸(!editId&&baseGuid 或
  editId&&sourceStatus!==0),主体抽成 submitSave(changeLog) 防递归;常用类型 chips 分号追加,
  500 字上限;跳过与填写两条路径都放行并按说明有无区分 toast;草稿直改不弹不传
- Defs.vue 列表默认分组态(同 flowGuid 折叠一行,代表行=已发布最高版,无已发布标「无生效版」),
  展开行复用 rowActions;可切「显示全部版本」回现状平铺
- 在途(议题二④):defList 落位后批量取 distinct flowGuid → 新接口 defInflight,
  分组行/平铺 GUID 旁展示「在途 N 件」(0 也展示,接口失败降级「—」);停用确认 N>0 追加在途文案
- flow.ts:defSave 加 changeLog 入参、新增 defInflight(未跑 api:gen,按方案手工改)

验证:npm run build 通过;vue-tsc 1.8.27 与 TS 5.9.3 不兼容无法作门禁(另记)
2026-10-12 16:14:32 +08:00
chenxing ecd082bb99 fix(def-editor): 空出口分支(直接通过)往返支持——解析不再造无审批人占位卡(曾致打开即报"未选择科室映射"且保存凭空加节点改语义);序列化空分支=起点→汇合点命名+条件直连边(Tail 携带出口属性);校验空分支降为 warn(并行分支保持 error:后端禁 SPLIT 直连 JOIN);ConditionBox 空分支渲染"直达汇合点"提示。convert 往返 64/64(新增空分支/空泳道 6 例+修正 1 处过期负例文案)
build / build (push) Successful in 1m53s
2026-10-12 11:29:07 +08:00
chenxing 2a1ec48b25 feat(workbench): 四页签加「按应用筛选」(appKey)+页签重排(我提交的→3、抄送我的→4)
build / build (push) Successful in 1m52s
列宽/展示:待办办件标题 minWidth 220→160、四页签时间列 170→190、已办意见列
ellipsis+tooltip(长意见单行截断 hover 看全文);应用管理停用态 Tag 改用 iView
默认样式(原 gray 非预设色)。应用筛选下拉数据源同流程定义页(flw_app enabled=1)。

门禁:vite build ✓;vue-tsc 本机不可用(TS 5.9.3 与 vue-tsc 1.8.27 不兼容)
2026-10-12 11:25:58 +08:00
chenxing 955c1e55f6 feat(def): 归属应用必填化——编辑器保存校验+必选标记,列表未归属标记
build / build (push) Successful in 2m33s
配合后端 save 必填化(2026-10-12 拍板):
- def-editor:保存前校验归属应用非空(新建必选;升版本/存量草稿继承源行,
  源行未归属必须补选),头部 Select 改必选样式
- Defs.vue:应用列存量 NULL 行显示「未归属」橙色 Tag;「编辑信息」弹窗
  归属应用改必填(原地补填通道)
2026-10-12 10:44:17 +08:00
chenxing 9f80630db3 fix(workbench): 待办列「提交人」显示真实提交人
build / build (push) Successful in 1m59s
- 列头本为「提交人」,但 render 回落 assigneeName ⇒ 每行显示的都是当前办理人(=用户自己)
- 去掉错误兜底改取 submitterName(后端本次补齐该字段),空值显示 "-"
2026-10-12 10:27:39 +08:00
chenxing fdc661f509 feat(workbench): 我的办件并入工作台为「我提交的」页签
build / build (push) Successful in 1m58s
- Workbench.vue 新增第4个页签「我提交的」,迁移原 MyInstances 列表(状态筛选/业务类型/详情·撤回·催办),惰性加载,默认页签仍为我的待办
- 删除独立路由 /my (modId 101) 及 MyInstances.vue
- 提示:UserBase 侧 app_module 的 modId=101(我的办件)需另行清理
2026-10-12 10:13:31 +08:00
chenxing ff1c50931a feat(callback/app): 回调管理增强——注册操作列加「探测」(width 140→240 修复挤压溢出)+探测结果弹窗;推送日志加详情抽屉(完整 payload pretty JSON+响应摘要+重试链);应用管理加搜索框+回调注册数列+停用二次确认
build / build (push) Successful in 2m1s
2026-10-11 22:40:56 +08:00
chenxing 8506ad80d7 feat(detail): 列表详情改为抽屉弹出+业务信息只认 summaryFields
build / build (push) Successful in 3m11s
- 新增 InstanceDrawer.vue(Drawer 740px + InstanceBody full 口径,非 iframe)
- Workbench/MyInstances/admin.Instances 详情按钮改为打开抽屉
- InstanceBody 移除 snapshotRows 回落,业务信息只显示子应用传递的
  summaryFields(中文 label),无值整块隐藏不再平铺英文 key
2026-10-11 12:01:43 +08:00
chenxing ecd4b9f4ea refactor(detail): 办件详情与嵌入页整合共用 InstanceBody——详情页升级三视图+流程信息五项+表单数据,嵌入页表现不变
build / build (push) Successful in 2m55s
2026-10-11 11:24:09 +08:00
chenxing a998e455d2 fix(ui): 用户验收反馈 R2——单卡片骨架+实测高度对齐 Notice、应用管理启用/停用、Tag 真组件、科室行 deptKey、成员多选、顶栏去房子图标钮
build / build (push) Successful in 4m4s
2026-10-10 22:42:58 +08:00
chenxing affb1bdeb7 fix(ui): tab 改线型对齐 Notice;操作列 when→disabledWhen 置灰保位(用户验收反馈 R1)
build / build (push) Successful in 2m8s
- 工作台/回调管理两页 Tabs 去 type=card,改通道线型 .tabs-panel(对齐 Notice)
- 7 页 14 处按行隐藏动作改 disabledWhen 置灰保位(互斥钮恒一灰一活),
  规范 §5.2 新增按钮位置稳定性红线:按行变化一律 disabledWhen,when 仅限行无关全局模式
2026-10-10 21:47:30 +08:00
chenxing 43753f4f59 refactor(theme): A 类主题通道接入+本地样式回收、操作列统一 actionColumn、顶栏三态头像
build / build (push) Successful in 2m9s
- P1 通道:view-ui-plus 锁 1.3.24(A 类 loader + vite /static dev 代理核验,五档/断源降级走查)
- P2 回收:删本地 layout.css/icons.css(+bak) 并清 main.ts import;骨架六类对齐 + 品牌色 var(--brand) 化;index CSS 779→393kB
- P3 操作列:新增 utils/tableActions.ts(同构 UserBase,7 函数+2 表,无 AUTO_MODE);7 页迁 actionColumn text 形态;旧 operatingHandle/operatingTextHandle 薄封装保留、签名不变
- P4 外观:接入三态头像 avatar.ts+UserAvatar.vue(同步自 YschTheme),顶栏裸 img 替换;外观三键红线零写
- 失效注释订正(layout.vue/def-editor)
2026-10-10 21:24:39 +08:00
chenxing ed5f818c1a feat(dept): 应用设置-科室管理 tab(组+成员维护+一键拉取);办理人映射页下线
build / build (push) Successful in 2m3s
- AppSetting tab2:科室表格(成员维护 Modal 复用 UserSelectInput/改名/停启用/删除)+ 拉取预览确认导入
- 编辑器科室下拉/校验数据源切 deptList(停用科室不进下拉);Maps.vue 与 104 路由删除
- flow.ts:dept 系列 API;map 系列 CRUD 删除(保留 mapLookupUser 选人检索)
2026-10-10 19:48:00 +08:00
chenxing f835965e29 feat(def): 流程定义列表增「编辑信息」弹窗(原地改名称/归属应用)+ 编辑器非草稿进入提示
- 弹窗限 name+appKey(当前 appKey 不在启用集时兜底选项提示);保存后 version 不变
- 编辑器打开已发布/停用时提示:结构改动将升版本,仅改属性请用「编辑信息」
2026-10-10 19:23:45 +08:00
chenxing e8f9af534b feat(app): 应用设置页(107)+ 流程定义/编辑器应用下拉改读白名单显示应用名
- AppSetting.vue:tab1 应用管理(学校池同步说明+开关 i-switch);tab2 科室管理占位(Part E)
- Defs.vue 筛选下拉与表格应用列显示 appName;编辑器归属应用下拉去 allow-create、限启用集合
- flow.ts 增 appList/appEnable(defAppKeys 保留 deprecated)
2026-10-10 19:17:39 +08:00
chenxing acbf601e7a feat(condition): 编辑器支持表达式判断——判断方式切换 + 出口条件就地编辑 + 表达式调试
- ConditionNode 增 judgeMode/conditions(SERVICE 缺省,旧定义天然兼容);DefEdgeJson/DefNodeJson 透传 condition/judgeMode
- convert.ts 双向透传:CONDITION 分支首边带 condition,序列化顺序=分支顺序(求值优先级保序)
- ConditionDrawer 判断方式 Radio(服务/表达式);ConditionBox 表达式模式分支头部条件输入(序号即优先级)
- validate.ts 镜像校验:表达式模式每出口条件必填(语法由后端发布期拦)
- Routers 页加「表达式调试」Modal(/admin/router/eval,与线上同一求值核心)
- addBranch/removeBranch 同步维护 conditions 占位
2026-10-10 18:52:54 +08:00
chenxing a4cc8ea2fa fix(flow-embed): 口径切换器改「人员视图/科室视图」,与视图切换器同排常驻
build / build (push) Successful in 1m57s
- 文案:按人员/按科室 → 人员视图/科室视图
- 排列(方案 A):口径切换器移入 .embed-sec-h 标题行、紧跟视图切换器
  (.embed-mode-seg margin-left:14px,与父级 10px gap 叠加为 24px),不再独立成行右对齐
- 常驻修复:原在 <template v-if="viewMode==='node'"> 内 ⇒ 切到时间线/列表即消失,
  但口径对三视图都生效(时间线走 :mode、列表办理人列走 recordActor)⇒ 移出模板、三视图常驻
- CSS:删除已无引用的 .embed-mode-row;新增 .embed-mode-seg 并显式 font-weight:normal
  (同 .embed-view-seg:落在 .embed-sec-h(600) 内会继承粗体)
- 版式宽 740 下实测:标题 68 + 视图组 187 + 口径组 156 + 间隔 24 = 435 < 可用 700,不溢出
2026-10-10 17:56:14 +08:00
chenxing 8bd8fcc57a fix(flow-embed): 视图切换器字重对齐——不再继承 .embed-sec-h 的粗体
build / build (push) Successful in 1m53s
- 根因:.embed-sec-h{font-size:14px;font-weight:600} 会被其内的 RadioGroup 继承,
  「按节点/时间线/列表」呈粗体;而下方「按人员/按科室」在 .embed-mode-row 内为常规 ⇒ 两处
  同为 iView small button group 却看着不一致。管理面同款控件早有此写法(InstanceDetail.vue:183、
  TaskHandle.vue:307 的 .trace-mode{font-weight:normal}),嵌入页漏了这条覆盖
- 改法:.embed-view-seg 显式 font-weight:normal,按「按人员/按科室」那档统一
- 像素取证(用户截图 867x466 @DPR1.25):两盒几何一致(「按节点」与「按人员」同宽 66.4 CSS、
  高 30px),配色一致(活动态边框+文字 #0d9488、非活动文字 #515a6e、非活动边框 #dcdee2);
  唯一差异是文字像素密度 2.1~3.1 倍(按节点 249 : 按人员 79;时间线 245 : 按科室 117)⇒ 纯字重差异
2026-10-10 17:39:30 +08:00
chenxing 32b01f9bf3 fix(flow-embed): 嵌入页旁路全局 body min-width——740 抽屉里整体右偏/右侧被裁
build / build (push) Successful in 2m0s
- App.vue:/embed/** 路由下给 body 置内联 min-width:0(内联优先级高于样式表),
  离开嵌入路由 removeProperty 回落到原值;不动全局样式表,管理面零影响
- 根因:isEmbed 只旁路了组件壳(layout / base-wrap-main),管不到全局 CSS——
  assets/styles/base/_base.scss:13 的 body{min-width:1200px} 让 740px 的 iframe
  仍按 ≥1200px 排版,表现为可见区里所有元素整体右偏、右侧被裁
- 实测(截图 1910x873,DPR 1.25):.diagram-img 外框左边 1540px=1232 CSS,
  按 1200 布局反推 1237;开始按钮中心 1749px=1399 CSS,按 1200 反推 1400;
  按 740 布局应为 1000 / 370 —— 两条独立测算都落在 6px 内,1200 布局成立
2026-10-10 16:23:41 +08:00
chenxing c189ad70a7 feat(flow-embed): 办件进度嵌入页 v2.4 改版——三视图 + 折叠块 + 业务摘要
build / build (push) Successful in 2m11s
- 「办理进度」三视图(按节点默认 / 时间线 / 列表),节点内办理人保持后端顺序
- 「流程示意图」「业务信息」各为折叠块、默认收缩;业务信息只渲染 bizSnapshot.summaryFields,无值整块不渲染
- 删「业务表单数据」Card 与 bizSnapshot/formDataJson 平铺
- 办理人行动作用页面内展示层映射(已提交/已同意/待审核…),不改全站共用的 TASK_STATUS
- TraceTimeline 增可选 statusText prop(不传=原行为,管理面零影响)
2026-10-10 14:56:37 +08:00
chenxing 570eec74ff feat(def): 流程定义按应用筛选——列表应用列+筛选下拉、编辑器归属应用选择
build / build (push) Successful in 5m37s
- Defs.vue:工具栏"按应用筛选"下拉(/def/appkeys 数据源,clearable/filterable)+ 应用列(空显示 -)
- 编辑器:头部"应用"Select(clearable/filterable/allow-create,可手输新值),载入回显、保存透传 appKey
- defList 支持 appKey 可选参数、新增 defAppKeys;新参数全部可选,与后端部署顺序无依赖
2026-10-10 14:34:33 +08:00
chenxing 1a07c2f39a fix(diagram): 嵌入页补滚动容器(裸渲染页被全局 body overflow:hidden 裁剪无滚动条)+ 流程图 PNG 按逻辑尺寸显示(SCALE=2 图按自然尺寸渲染整体放大一倍,四处 PNG 消费点统一走 fitDiagram)
build / build (push) Successful in 3m46s
2026-10-09 22:09:48 +08:00
chenxing 053eb0047b feat(diagram): 前端竖排镜像——edgePath/标签锚点方向自适应(发布顺序无关)、名单 2 行 tspan+等N人、补 instance 图例、auto-fit 与 375px 适配
build / build (push) Successful in 3m30s
2026-10-09 19:42:51 +08:00
chenxing 8b02f05494 revert(顶栏): 移除 LOGO 文字显示(用户反馈子应用不展示,仅门户展示)(LOGO文字与主题JSON化增补)
build / build (push) Successful in 1m58s
2026-10-09 12:29:44 +08:00
chenxing ec5f73a81b fix(顶栏): LOGO 文字字号对齐应用名层级(19px/700)(LOGO文字与主题JSON化增补)
build / build (push) Successful in 2m51s
2026-10-09 11:56:46 +08:00
chenxing ae39eed665 feat(顶栏): LOGO 徽标旁显示平台文字(yh-logo-text 键接入)(LOGO文字与主题JSON化)
build / build (push) Failing after 58s
2026-10-09 10:36:26 +08:00
chenxing dadfabe036 外观偏好异步补齐C:接 A 类主题通道 loader + 顶栏云海 logo 徽标 + 平台键 yh-logo-url
build / build (push) Successful in 2m7s
- index.html env-config 引导后插入 A 类免插件 loader(svc-hall cf11ef8 样板:staticHost > baseHost > %VITE_THEME_HOST% 仅 dev > 同源 /static,document.write parser-blocking,纯叠加)
- vite.config 补 /static dev 代理(YH_THEME_PROXY_TARGET 可覆盖,默认线上通道)
- header-container 首位插入 .logo-badge(结构与 Notice 对齐,皮肤由通道 base.layout.css 提供);拷贝平台 yunhai-logo.svg 资产兜底
- localStorage["yh-logo-url"] 优先,@error 回落兜底;mounted 读一次 + 监听 yh-appearance-updated 即时切换
2026-10-08 18:53:04 +08:00
chenxing 5b83fd7144 feat(embed): 嵌入页新增审批记录完整流水表
build / build (push) Successful in 3m42s
2026-10-03 23:52:11 +08:00
chenxing d3a2b08ba2 feat(embed): 新增办件进度嵌入页与无鉴权旁路 2026-10-03 16:51:29 +08:00
chenxing 79866a05e1 fix(flow-editor): NodeList 插入的 OutletLanes 独立 v-if,修被截断的 NotifyCard 条件链(浏览器冒烟发现:无出口审批落进 ConditionBox v-else 分支致 vnode 崩溃) 2026-10-03 16:12:00 +08:00
chenxing 138cb36800 chore(flow-editor)!: 移除 bpmn-js 依赖与画布文件 2026-10-03 15:13:35 +08:00
chenxing ffa196954e refactor(flow-editor)!: 删除画布模式,卡片树成为唯一编辑形态 2026-10-03 14:13:37 +08:00
chenxing 656c0d1bf5 feat(flow-editor): 多出口泳道 UI(抽屉开关+出口路由容器+分支增删/恢复单出口) 2026-10-03 14:09:02 +08:00
chenxing 16717a0867 feat(flow-editor): 多出口审批实时校验(非空唯一/数量上限/泳道递归) 2026-10-03 14:06:19 +08:00
chenxing 2dd7135753 feat(flow-editor): convert 双向支持多出口审批(命名出边⇄出口泳道) 2026-10-03 14:05:39 +08:00
chenxing e057e3bd8b feat(flow-editor): ApprovalNode 增多出口 outlets/branches,遍历链路打通 2026-10-03 13:59:20 +08:00
chenxing 678cb96801 feat(flow-editor): 默认卡片模式,画布降级为高级入口 2026-10-03 13:58:35 +08:00
chenxing 2c65e5adc9 feat(flow): 图示页砍画布视图档,布局改后端 positions 单源 2026-10-03 13:57:49 +08:00
85 changed files with 10186 additions and 3623 deletions
+19 -8
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,21 +41,29 @@ jobs:
- name: 构建前端(node 容器)
run: |
set -e
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild -w /build node:20 sleep 3600
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e
cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci
npm run build
'
echo "=== dist 产物 ==="
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist
docker rm -f frontbuild
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
+12
View File
@@ -29,6 +29,18 @@
}
})();
</script>
<!-- 主题通道引导(A 类免插件接入,YschTheme 方案 §11):origin 零写死,优先级
__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(仅 dev) > 同源相对 /static。
document.write 保证 parser-blocking;挂载/FOUC/CSS 排序由通道 init 承担。 -->
<script>
(function () {
var c = window.__APP_CONFIG__ || {};
var themeHost = "%VITE_THEME_HOST%";
if (themeHost.charAt(0) === "%") themeHost = "";
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
})();
</script>
<title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
+1565 -367
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -12,7 +12,12 @@
"build:ld": "vite build --mode ld",
"build:prod": "vite build --mode prod",
"preview": "vite preview",
"api:gen": "alova gen -f"
"api:gen": "alova gen -f",
"typecheck": "vue-tsc --noEmit",
"test:line-offline": "esbuild tests/line-offline.test.ts --bundle --platform=node --format=esm --target=node20 --external:node:* --outfile=node_modules/.cache/line-offline.test.mjs && node node_modules/.cache/line-offline.test.mjs",
"test: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:component": "vitest run",
"test": "npm run test:unit && npm run test:component"
},
"dependencies": {
"@open-file-viewer/core": "^0.1.43",
@@ -22,14 +27,13 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"alova": "^3.4.0",
"axios": "^0.24.0",
"bpmn-js": "^17.11.1",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"mockjs": "^1.1.0",
"pdfjs-dist": "^4.10.38",
"pinia": "^2.1.4",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "1.3.24",
"vite-plugin-mock": "2.9.6",
"vue": "^3.4.19",
"vue-router": "4",
@@ -41,13 +45,16 @@
"@unocss/preset-rem-to-px": "^66.6.0",
"@unocss/preset-uno": "^66.6.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/test-utils": "2.4.6",
"fast-glob": "^3.3.2",
"jsdom": "25.0.1",
"typescript": "^5.2.2",
"unocss": "^66.6.0",
"unplugin-auto-import": "0.18.3",
"vite": "^5.1.4",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^1.8.27"
"vitest": "2.1.9",
"vue-tsc": "3.3.13"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+40 -3
View File
@@ -1,14 +1,35 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { ref, reactive, computed, onMounted, watch, onBeforeUnmount } from "vue";
import layout from "./components/layout.vue";
import { useRouter } from "vue-router";
import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/base";
import { resolveMenuPage } from "@/utils/menuRoute";
import { isBareTaskRoute } from "@/utils/taskEmbed";
import { Message } from "view-ui-plus";
const router = useRouter();
const isLogin = ref(false);
// 嵌入页旁路(/embed/**,iframe 免签嵌入):子应用 Drawer 里打开,带不了本应用 CAS 会话,
// 也不该出现菜单壳。判定用路由前缀而非 query 标记——刷新/直链/分享均稳定
const isTokenEmbed = computed(() => router.currentRoute.value.path.startsWith("/embed/"));
// 去壳任务页(/task/{taskId}?embed=1,v2.1 完整办理内嵌):只去菜单壳,CAS 一律保留——
// 办理是写操作,旁路鉴权等于把审批动作挂在免签 URL 上。与 token 页共享"无壳"渲染,
// 但鉴权分支只走 isTokenEmbed
const isBareTask = computed(() =>
isBareTaskRoute(router.currentRoute.value.path, router.currentRoute.value.query.embed));
const isShellless = computed(() => isTokenEmbed.value || isBareTask.value);
// 嵌入页必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
// 上面 isShellless 只旁路了组件壳,管不到全局 CSS,740px 的 iframe 里页面仍按 ≥1200px 排版,
// 表现为整体右偏 + 右侧被裁。用内联样式覆盖(优先级高于样式表),非嵌入态移除内联属性回落到原值
function syncEmbedLayout(embed: boolean) {
if (embed) document.body.style.minWidth = "0";
else document.body.style.removeProperty("min-width");
}
watch(isShellless, syncEmbedLayout, { immediate: true });
onBeforeUnmount(() => syncEmbedLayout(false));
const layoutData = reactive({
appName: "流程中心",
baseHost: config.baseHost,
@@ -20,6 +41,15 @@ const layoutData = reactive({
});
onMounted(async () => {
// 首次导航异步完成:mount 时 currentRoute 可能仍是初始 "/",先等路由就绪再判嵌入前缀,
// 否则直链 /#/embed/* 会在旁路前误发鉴权请求
await router.isReady();
// 嵌入页:跳过两个鉴权请求(失败会弹错误 Message 且渲染带菜单壳的 layout,嵌入场景均不可接受)。
// 去壳任务页不走这个分支——它是写面,getUserInfo/getUserMod 正是触发 CAS 的地方
if (isTokenEmbed.value) {
isLogin.value = true;
return;
}
try {
// 1. 获取当前用户信息
const user: any = await baseApi.getUserInfo().send();
@@ -75,12 +105,19 @@ onMounted(async () => {
</script>
<template>
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<!-- 嵌入页:裸渲染,不套 layout 与 base-wrap-main(嵌入页自己管理样式与滚动)。
token 页维持"不等鉴权先渲染"(它本来就不发鉴权请求);去壳任务页要等 CAS 那一路走完,
否则未登录时 iframe 里先闪一个能点的办理面板 -->
<router-view v-if="isTokenEmbed" />
<router-view v-else-if="isBareTask && isLogin" />
<template v-else-if="isLogin">
<layout :layoutData="layoutData" />
<div class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<router-view />
</div>
</div>
</template>
</template>
<style lang="scss">
+131 -32
View File
@@ -13,8 +13,9 @@ function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
// alova 的请求体是 Method 构造的第 5 个参数(instance.data),不是 config 上的键:
// 写进 config 的 body/data 都会被适配器忽略,POST 将发出空 body。
function post<T = any>(url: string, body?: any): Method<T> {
return new Method<T>("POST", alovaInstance, url, {}, body);
// 第 4 个参数 config 可带 silentBizToast:调用方自己承担错误提示时,不再叠加全局 toast。
function post<T = any>(url: string, body?: any, config?: Record<string, any>): Method<T> {
return new Method<T>("POST", alovaInstance, url, config || {}, body);
}
/** 状态枚举(后端 FlwEnums 口径) */
@@ -59,20 +60,20 @@ export const TASK_STATUS_COLOR: Record<number, string> = {
class FlowApi {
// ==================== 用户面 ====================
/** 我的待办 */
todo(params: { page?: number; size?: number }) {
/** 我的待办(appKey=按应用筛选,可空) */
todo(params: { page?: number; size?: number; appKey?: string }) {
return get(`${ROOT}/todo`, params);
}
/** 我的已办 */
done(params: { page?: number; size?: number }) {
/** 我的已办(appKey=按应用筛选,可空) */
done(params: { page?: number; size?: number; appKey?: string }) {
return get(`${ROOT}/done`, params);
}
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
ccMy(params: { page?: number; size?: number }) {
ccMy(params: { page?: number; size?: number; appKey?: string }) {
return get(`${ROOT}/cc/my`, params);
}
/** 我提交的办件 */
my(params: { status?: number; page?: number; size?: number }) {
my(params: { status?: number; page?: number; size?: number; appKey?: string }) {
return get(`${ROOT}/my`, params);
}
/** 办件详情(提交人/办理人/本校管理员可见) */
@@ -119,15 +120,60 @@ class FlowApi {
}
// ==================== 管理面:流程定义 ====================
defList() {
return get(`${ROOT}/admin/def/list`);
defList(params?: { status?: number; appKey?: string }) {
return get(`${ROOT}/admin/def/list`, params);
}
/** 应用清单("按应用筛选"下拉):本校 flw_def 已标注 ∪ 已注册回调的应用 */
defAppKeys() {
return get(`${ROOT}/admin/def/appkeys`);
}
// ==================== 管理面:应用设置(2026-10-10) ====================
/** 应用管理列表(flw_app 白名单;附带学校池静默同步,UserBase 不可达降级本地行) */
appList() {
return get(`${ROOT}/admin/app/list`);
}
/** 开关某应用是否对接流程(停用后 defSave 归属应用校验拦截新引用) */
appEnable(id: number | string, enabled: boolean) {
return post(`${ROOT}/admin/app/enable`, { id, enabled });
}
/** 应用辅助统计:appKey → 回调注册数(本校) */
appStats() {
return get(`${ROOT}/admin/app/stats`);
}
defGet(id: number | string) {
return get(`${ROOT}/admin/def/get`, { id });
}
defSave(body: { id?: number; code: string; name: string; contentJson: string; baseGuid?: string }) {
/**
* 表单中心目录(v3/TASK_1):编辑器"关联表单"选择器的数据源。
* 走本应用同源代理,浏览器不持 appKey/secret、也不出现 form-center 地址;
* **没有 schId 入参**——管理域由后端从 CAS 会话派生,这是有意的,别"顺手加一个"。
*/
formCenterForms(params: { keyword?: string; page?: number; pageSize?: number } = {}) {
return get<{ items: any[]; total: number }>(`${ROOT}/admin/form-center/forms`, params);
}
/**
* 保存定义。`formUuid`/`formVersion`(v3)是**行字段**、不进 contentJson:
* 成对提交(后端单传一个即拒);`formUuid=""`=取消关联并同时清空版本;不传=保持原关联。
*/
defSave(body: {
id?: number;
code: string;
name: string;
contentJson: string;
baseGuid?: string;
appKey?: string;
changeLog?: string;
formUuid?: string;
formVersion?: number;
}) {
return post(`${ROOT}/admin/def/save`, body);
}
/** 元数据原地编辑(2026-10-10 议题2):只改 name/appKey 不升版本;已发布亦可 */
defUpdateMeta(body: { id: number; name: string; appKey?: string }) {
return post(`${ROOT}/admin/def/updateMeta`, body);
}
defPublish(id: number | string) {
return post(`${ROOT}/admin/def/publish/${id}`);
}
@@ -138,28 +184,65 @@ class FlowApi {
defEnable(id: number | string) {
return post(`${ROOT}/admin/def/enable/${id}`);
}
/**
* 流程线级停发(T03):整条 flowGuid 停止新发起/预览,在途实例按原版本跑完;
* 与版本级 status 正交,返回受影响版本行数。
*/
defOfflineLine(flowGuid: string, config?: Record<string, any>) {
return post<number>(`${ROOT}/admin/def/offlineLine`, { flowGuid }, config);
}
/** 流程线级恢复(T03):只恢复线可用性,不改写任何版本状态或发布内容;返回受影响版本行数 */
defEnableLine(flowGuid: string, config?: Record<string, any>) {
return post<number>(`${ROOT}/admin/def/enableLine`, { flowGuid }, config);
}
defVersions(flowGuid: string) {
return get(`${ROOT}/admin/def/versions`, { flowGuid });
}
/** 在途办件数(议题二④):flowGuid → 审批中办件数,无在途的流程线也返回 0 键 */
defInflight(flowGuids: string[]) {
return get(`${ROOT}/admin/def/inflight`, { flowGuids: flowGuids.join(",") });
}
/** 流程定义示意图(管理面:defId 任意版本,草稿可预览) */
adminDefDiagram(params: { id: number | string; mode?: string }) {
return get(`${ROOT}/admin/def/diagram`, params);
}
// ==================== 管理面:办理人映射 ====================
mapList(deptKey?: string) {
return get(`${ROOT}/admin/map/list`, deptKey ? { deptKey } : undefined);
// ==================== 管理面:科室管理(2026-10-10,取代旧 /map/* 映射 CRUD) ====================
/** 科室列表(deptKey=自增 id 字符串 + 启用成员数;编辑器 ctx.depts 同结构) */
deptList() {
return get(`${ROOT}/admin/dept/list`);
}
mapSave(body: { deptKey: string; deptName?: string; userId: number; userName?: string }) {
return post(`${ROOT}/admin/map/save`, body);
deptCreate(deptName: string) {
return post(`${ROOT}/admin/dept/create`, { deptName });
}
mapDisable(id: number | string) {
return post(`${ROOT}/admin/map/disable/${id}`);
deptRename(id: number | string, deptName: string) {
return post(`${ROOT}/admin/dept/rename`, { id, deptName });
}
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
mapEnable(id: number | string) {
return post(`${ROOT}/admin/map/enable/${id}`);
deptStatus(id: number | string, enabled: boolean) {
return post(`${ROOT}/admin/dept/status`, { id, enabled });
}
deptDelete(id: number | string) {
return post(`${ROOT}/admin/dept/delete`, { id });
}
deptMembers(deptId: number | string) {
return get(`${ROOT}/admin/dept/members`, { deptId });
}
/** 批量加成员(返回新增数) */
deptMemberAdd(deptId: number | string, userIds: number[]) {
return post(`${ROOT}/admin/dept/memberAdd`, { deptId, userIds });
}
deptMemberRemove(memberId: number | string) {
return post(`${ROOT}/admin/dept/memberRemove`, { memberId });
}
/** 一键拉取教师组·预览(不落库,确认后导入) */
deptGroupPreview() {
return get(`${ROOT}/admin/dept/groupPreview`);
}
/** 一键拉取教师组·导入(source_group_id 幂等;重复拉取=刷新组名与成员集) */
deptImportGroups() {
return post(`${ROOT}/admin/dept/importGroups`);
}
/** 成员检索(UserBase byId;编辑器选人/姓名补全共用,原 /map/lookupUser 通道保留) */
mapLookupUser(userId: number) {
return get(`${ROOT}/admin/map/lookupUser`, { userId });
}
@@ -171,14 +254,6 @@ class FlowApi {
adminTeacherTree() {
return get(`${ROOT}/admin/user/teacherTree`);
}
/** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */
mapGroupPreview() {
return get(`${ROOT}/admin/map/groupPreview`);
}
/** 一键拉取教师组·导入(批量落映射,已存在跳过) */
mapImportGroups() {
return post(`${ROOT}/admin/map/importGroups`);
}
// ==================== 管理面:回调注册/日志/死信 ====================
regList() {
@@ -190,6 +265,10 @@ class FlowApi {
regResetSecret(id: number | string) {
return post(`${ROOT}/admin/callback/resetSecret/${id}`);
}
/** 回调探测:对该注册同步推一条 flow.ping(未知事件码),返回 {success,httpStatus,elapsedMs,respSnippet,error} */
callbackProbe(id: number | string) {
return post(`${ROOT}/admin/callback/probe/${id}`);
}
callbackLogs(params: { status?: number; event?: string; instanceId?: number; page?: number; size?: number }) {
return get(`${ROOT}/admin/callback/logs`, params);
}
@@ -207,6 +286,10 @@ class FlowApi {
callbackRepush(ids: number[]) {
return post(`${ROOT}/admin/callback/repush`, ids);
}
/** 对接健康度体检(2026-10-15 P2):每应用一行六项检查 + 综合评分,零新增表 */
callbackHealth() {
return get(`${ROOT}/admin/callback/health`);
}
// ==================== 管理面:条件路由注册 / 重跑 ====================
@@ -228,15 +311,31 @@ class FlowApi {
routerStatus(id: number | string, enabled: boolean) {
return post(`${ROOT}/admin/router/status/${id}?enabled=${enabled}`);
}
/** 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走) */
instanceReroute(instanceId: number | string) {
return post(`${ROOT}/admin/instance/${instanceId}/reroute`);
/**
* 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走)。
* useLatestDef=true(F5-4 僵尸逃生门):按该流程当前已发布最高版本重跑并刷新办件定义快照。
*/
instanceReroute(instanceId: number | string, useLatestDef?: boolean) {
const suffix = useLatestDef ? "?useLatestDef=true" : "";
return post(`${ROOT}/admin/instance/${instanceId}/reroute${suffix}`);
}
/** 表达式调试求值(2026-10-10:与运行时同一求值核心;不落库不走流程) */
routerEval(body: { expression: string; variablesJson?: string }) {
return post(`${ROOT}/admin/router/eval`, body);
}
// ==================== 管理面:办件查询 ====================
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
return get(`${ROOT}/admin/instance/page`, params);
}
/**
* 发起时指派预览(F6 共享选择器数据源;与 oapi /oapi/flow/apply/preview 同形:
* {flowGuid, defVersion, nodes:[{nodeId,nodeName,countersign,selection,candidates}]})。
* 纯预置:后端 /api 代理端点属 F6 正式接入批次,接线时同批点亮(FlowAssignSelector 当前为备用组件)。
*/
applyPreview(flowGuid: string) {
return get(`${ROOT}/apply/preview`, { flowGuid });
}
}
export const flowApi = new FlowApi();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+85 -5
View File
@@ -98,20 +98,100 @@ h4.subtitle {
}
}
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
.table-spin-load {
position: relative;
width: 60px;
height: 90px;
padding-top: 60px;
background-image: url(../image/loading.gif);
background-size: 60px 60px;
background-position: top center;
background-repeat: no-repeat;
border: none;
color: #577afb;
color: var(--brand);
margin: 0 auto;
text-align: center;
}
.table-spin-load .ysch-spin {
position: absolute;
left: 50%;
top: 0;
width: 60px;
height: 60px;
margin-left: -30px;
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
}
.ivu-table-cell-tree {
margin-right: 4px;
margin-bottom: 2px;
}
/* ================= 表格操作列(utils/tableActions.ts)=================
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
.op-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
.operating-link {
padding-right: 0;
white-space: nowrap;
}
}
/* ================= 固定列层不吞下层事件 =================
根因(2026-10-14 生产实测):iView 分组表外层「操作」列 fixed:right 时,固定层是全表覆盖的
独立副本;展开行处它只渲染一个 visibility:hidden 的占位单元格(用来对齐行高)。该处命中
测试落到固定层的 <table> 上(占位 td 因 visibility:hidden 不可命中),于是展开子表落在
固定条带内的「更多」等控件 hover/click 全部失效——表现为「看得见、点不动」。
固定层只做视觉覆盖,容器层 pointer-events:none、真实单元格恢复 auto,空白处(含展开行
占位)事件自然穿透到下层;滚轮/行 hover 靠事件冒泡与 vue 侧 scroll 同步,不受影响。 */
.ivu-table-fixed,
.ivu-table-fixed-right {
pointer-events: none;
th,
td,
.ivu-table-cell {
pointer-events: auto;
}
}
/* ================= 省略号单元格内的 Tooltip =================
iView 的 Tooltip 根节点与 .ivu-tooltip-rel 都是 inline-block(见 tooltip.less),
放进 column.ellipsis 单元格时会把 text-overflow:ellipsis 变成**硬裁切**(省略号不渲染,
2026-10-14 无头 Edge 实测 A/B 对照:裸文本有「…」,两层 inline-block 包裹则无)。
故仅在省略号单元格内把它们降级为 inline —— 保持省略号,popper 定位仍按 rect 计算不受影响。 */
.ivu-table-cell-ellipsis {
.ivu-tooltip,
.ivu-tooltip-rel {
display: inline;
width: auto;
}
}
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
.op-more {
.op-more-label {
display: inline-flex;
align-items: center;
gap: 2px;
}
.op-more-arrow {
font-size: 12px;
}
}
/* 「更多」菜单内危险项(红色 + hover 红底,与 text-error 同源 $color-error) */
.ivu-dropdown-item.op-item-danger {
color: $color-error !important;
&:hover {
color: $color-error !important;
background-color: rgba(232, 104, 74, 0.08);
}
}
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
/**
* FL 侧选人 iframe 宿主(验收 P2-2 协议收口)。
*
* 用法(业务页,不是 FL 自己的产品入口):
* <FlowAssignEmbed ref="picker" :flow-guid="guid" @confirmed="onPicked" @cancelled="onCancel" />
* picker.value.open()
*
* 语义(与既定架构一致):
* - 确认=把 iframe 内选择的最终结果交回宿主页面内存;宿主何时提交仍由业务调 FL 正式接口决定,
* 前端选择结果不是授权凭据,最终资格由 FL 服务端复核。
* - 取消/直接关框=只通知宿主,不改宿主已有的旧选择。
* - 重新打开后确认=以最近一次为准(B 替换 A)。
* - 收消息四道校验:event.origin、event.source(必须是本组件那个 iframe 的窗口)、
* 协议信封(proto/v/type)、本次 contextId;任一不过直接丢弃(旧会话迟到消息不会串台)。
* - 关闭即摘监听,重开换新 contextId。
*/
import { onBeforeUnmount, ref, watch } from "vue";
import { Modal } from "view-ui-plus";
import { isValidAssignMessage } from "@/utils/assignEmbed";
import type { AssignEmbedMessage } from "@/utils/assignEmbed";
import { toAppHashUrl } from "@/utils/appUrl";
const props = defineProps<{
/** 要预览/选择的流程线 */
flowGuid: string;
/** 弹框标题 */
title?: string;
}>();
const emit = defineEmits<{
/** 用户在 iframe 内点了确认:assigns 为最终选择,defVersion 为预览时的版本锚 */
confirmed: [payload: { flowGuid: string; defVersion?: number; assigns: { nodeId: string; userIds: number[] }[] }];
/** 取消或宿主关框:宿主应保持原有选择,不做任何覆盖 */
cancelled: [];
}>();
const visible = ref(false);
const frameUrl = ref("");
const contextId = ref("");
const frameEl = ref<HTMLIFrameElement | null>(null);
/** 本次打开锁定的父↔子通道条件;非响应式:只在 open 时刷新 */
let expectedOrigin = "";
let listening = false;
/** 本次会话是否已经由 iframe 明确答复过(确认/取消),用于区分"宿主自己关框" */
let settled = false;
function newContextId(): string {
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function closeListener(): void {
if (listening) {
window.removeEventListener("message", onMessage);
listening = false;
}
}
function onMessage(ev: MessageEvent): void {
const msg: AssignEmbedMessage | null = isValidAssignMessage(
ev,
expectedOrigin,
frameEl.value?.contentWindow ?? null,
contextId.value,
);
if (!msg) return;
settled = true;
closeListener();
visible.value = false;
if (msg.type === "assign-cancelled") {
emit("cancelled");
return;
}
emit("confirmed", {
flowGuid: msg.flowGuid || props.flowGuid,
defVersion: msg.defVersion,
assigns: msg.assigns ?? [],
});
}
function open(): void {
if (!props.flowGuid) return;
// 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效
closeListener();
settled = false;
contextId.value = newContextId();
const query = `flowGuid=${encodeURIComponent(props.flowGuid)}&contextId=${encodeURIComponent(contextId.value)}`;
frameUrl.value = toAppHashUrl(`#/assign-preview?${query}`);
expectedOrigin = new URL(frameUrl.value, window.location.href).origin;
visible.value = true;
window.addEventListener("message", onMessage);
listening = true;
}
/** 宿主主动收起(等价于取消):同样摘监听,避免后续消息继续影响本页 */
function close(): void {
closeListener();
if (visible.value && !settled) emit("cancelled");
visible.value = false;
}
// 点遮罩/X 关闭走这里;已 settled 的关闭由 iframe 自己回过话,不再补发 cancelled
watch(visible, (val) => {
if (!val) closeListener();
});
onBeforeUnmount(closeListener);
defineExpose({ open, close, contextId });
</script>
<template>
<Modal v-model="visible" :title="title || '选择办理人'" :width="760" :footer-hide="true" class="assign-embed-modal">
<iframe
v-if="frameUrl"
ref="frameEl"
:src="frameUrl"
class="assign-embed-frame"
frameborder="0"
title="选择办理人"
/>
</Modal>
</template>
<style scoped>
.assign-embed-frame {
width: 100%;
height: 460px;
border: 0;
display: block;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* F6 发起时指派·共享选择器(纯备用组件,2026-10-18 P6.6;未接入任何页面)。
*
* 用法(未来接入发起页时):
* <FlowAssignSelector ref="assignSelector" :flow-guid="boundFlowGuid" />
* 提交前:const assigns = assignSelector.value?.collect(); // [{nodeId, userIds}],中心 ApplyBody.assigns 同构
* 校验:const err = assignSelector.value?.validate(); // 非空字符串=未通过(必填/数量约束)
* 重提/换线场景:flowGuid 变化自动重拉(watch),已选清空并提示重选。
*
* 数据源:api/module/flow.ts 的 applyPreview(后端 /api 代理端点属 F6 正式接入批次,
* 当前组件与 API 方法均为预置,页面接线时后端同批补端点即可点亮)。
* 渲染规则:只渲染开启指派的节点(preview.nodes 已过滤);countersign/selection.mode
* 决定单选(SINGLE)/多选(MULTI);required 必填标 *;min/max 数量约束在 validate() 收口。
*/
import { ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
const props = defineProps<{ flowGuid: string }>();
interface PreviewUser { userId: number; userName: string }
interface PreviewSelection { mode: string; min?: number; max?: number; required?: boolean }
interface PreviewNode {
nodeId: string;
nodeName?: string;
countersign?: string;
selection?: PreviewSelection;
candidates: PreviewUser[];
}
const loading = ref(false);
const nodes = ref<PreviewNode[]>([]);
/** 已选:nodeId → userIds(单选也用数组,collect 统一输出) */
const selected = ref<Record<string, number[]>>({});
async function reload() {
if (!props.flowGuid) {
nodes.value = [];
selected.value = {};
return;
}
loading.value = true;
const prev = selected.value;
try {
const res: any = await flowApi.applyPreview(props.flowGuid).send();
nodes.value = res?.nodes || [];
selected.value = {};
if (Object.keys(prev).length > 0) {
Message.info("流程定义已切换,指派选择已重置,请重新选择审批人");
}
} finally {
loading.value = false;
}
}
watch(() => props.flowGuid, reload, { immediate: true });
/** 输出标准 assigns:[{nodeId, userIds}](只含有选择的节点;无指派节点=空数组) */
function collect(): { nodeId: string; userIds: number[] }[] {
return nodes.value
.filter((n) => (selected.value[n.nodeId] || []).length > 0)
.map((n) => ({ nodeId: n.nodeId, userIds: [...selected.value[n.nodeId]] }));
}
/** 校验:返回错误文案;空串=通过。必填与 min/max 数量约束在此收口 */
function validate(): string {
for (const n of nodes.value) {
const picked = selected.value[n.nodeId] || [];
const required = n.selection?.required;
const min = n.selection?.min;
const max = n.selection?.max;
if (required && picked.length === 0) {
return `「${n.nodeName || n.nodeId}」为必选节点,请选择审批人`;
}
if (min != null && picked.length > 0 && picked.length < min) {
return `「${n.nodeName || n.nodeId}」至少选择 ${min} 人`;
}
if (max != null && picked.length > max) {
return `「${n.nodeName || n.nodeId}」最多选择 ${max} 人`;
}
}
return "";
}
defineExpose({ collect, validate, reload });
</script>
<template>
<div class="flow-assign-selector">
<p v-if="loading" style="color: #808695">正在加载指派节点…</p>
<p v-else-if="nodes.length === 0" style="color: #808695">
该流程未开启发起时指派,无需选择审批人
</p>
<div v-for="node in nodes" :key="node.nodeId" class="assign-node">
<label class="assign-label">
{{ node.nodeName || node.nodeId }}
<span v-if="node.selection?.required" style="color: #ed4014">*</span>
<span style="color: #808695; font-weight: normal">
({{ node.countersign === "ANY" ? "或签" : "会签" }}{{ node.selection?.mode === "SINGLE" ? "·单选" : "·多选" }})
</span>
</label>
<Select v-if="node.selection?.mode === 'SINGLE'" :model-value="selected[node.nodeId]?.[0]"
clearable placeholder="选择审批人" style="width: 260px"
@on-change="(v: number) => (selected[node.nodeId] = v == null ? [] : [v])">
<Option v-for="c in node.candidates" :key="c.userId" :value="c.userId" :label="c.userName" />
</Select>
<CheckboxGroup v-else :model-value="selected[node.nodeId]"
@on-change="(v: number[]) => (selected[node.nodeId] = v || [])">
<Checkbox v-for="c in node.candidates" :key="c.userId" :label="c.userId">
{{ c.userName }}
</Checkbox>
</CheckboxGroup>
</div>
</div>
</template>
<style scoped>
.assign-node { margin-bottom: 12px; }
.assign-label { display: block; margin-bottom: 4px; font-weight: 600; }
</style>
+1 -1
View File
@@ -116,7 +116,7 @@ defineExpose({
no-data-text="暂无数据"
>
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
</Table>
<div class="page-panel">
+1 -1
View File
@@ -3,7 +3,7 @@
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
</Table>
+8 -3
View File
@@ -1,13 +1,15 @@
<template>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle">
@@ -22,7 +24,7 @@
<script setup lang="ts">
import { computed, ref, useSlots, watch } from 'vue'
import Utils from '@/utils'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>()
const slots = useSlots()
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
const regionRef = ref<HTMLElement | null>(null)
const measuredH = useElementHeight(regionRef)
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([])
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+2 -1
View File
@@ -46,7 +46,7 @@
>
<path
d="M138.935 256.078v511.844h63.42V256.078h-63.42z m126.842 0v511.844h127.215V256.078H265.777zM885.064 512L583.254 64.323v191.755H456.414v511.844h126.842v191.755L885.064 512z"
fill="#2d8cf0"
fill="currentColor"
></path>
</svg>
</div>
@@ -488,6 +488,7 @@ export default {
position: relative;
top: 50%;
margin-top: -17px;
color: var(--brand);
}
.auto-box {
width: 200px;
-95
View File
@@ -1,95 +0,0 @@
<template>
<!-- 单选用户输入(参照 Notice 自定义组选人范式):readonly Input + 选择按钮 + 清空;
v-model=用户 id;display-name 回显姓名快照(调用方已知姓名时随值下传) -->
<div class="sel-user-box">
<Input
type="text"
v-model="nameText"
readonly
placeholder="点击「选择」指定用户"
class="input"
></Input>
<Button icon="md-add" @click="openModal">选择</Button>
<Button icon="md-trash" v-if="modelValue" @click="reset"></Button>
<UserCheckModal
ref="userCheckModal"
:sources="sources"
:multiple="false"
@on-submit="onSubmit"
/>
</div>
</template>
<script lang="ts">
import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow";
export default {
name: "UserSelectInput",
components: { UserCheckModal },
props: {
modelValue: {
type: Number,
default: null,
},
/** 当前 id 的姓名快照(编辑回显/选中即得,避免 #id 兜底) */
displayName: {
type: String,
default: "",
},
},
emits: ["update:modelValue", "update:displayName", "on-change"],
data() {
return {
nameText: this.displayName,
sources: [
{
name: "基础平台",
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
},
],
};
},
methods: {
openModal() {
(this.$refs.userCheckModal as any).show();
},
/** 单选弹窗提交:数组恒为 1 条(多选复用同组件) */
onSubmit(arr: any[]) {
if (!arr || !arr.length) return;
const u = arr[0];
this.nameText = u.name + "(" + u.id + ")";
this.$emit("update:modelValue", u.id);
this.$emit("update:displayName", u.name);
this.$emit("on-change", u);
},
reset() {
this.nameText = "";
this.$emit("update:modelValue", null);
this.$emit("update:displayName", "");
this.$emit("on-change", null);
},
},
watch: {
modelValue(val: number) {
// 外部清空/重置时同步清姓名
if (!val) this.nameText = "";
else if (!this.nameText) this.nameText = "#" + val;
},
displayName(val: string) {
this.nameText = val;
},
},
};
</script>
<style lang="scss" scoped>
.sel-user-box {
display: flex;
align-items: center;
gap: 8px;
.input {
flex: 1;
}
}
</style>
+46 -15
View File
@@ -1,13 +1,36 @@
<script setup lang="ts">
import { ref, onBeforeMount, watch, computed } from "vue";
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import defaultLogo from "@/assets/image/yunhai-logo.svg";
import UserAvatar from "@/components/UserAvatar.vue";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
const router = useRouter();
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
const logoUrl = ref<string>(defaultLogo);
const readLogoUrl = () => {
let v: string | null = null;
try {
v = localStorage.getItem("yh-logo-url");
} catch {
v = null;
}
logoUrl.value = v || defaultLogo;
};
const onLogoError = () => {
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
};
onMounted(() => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
const isPanel = ref(false);
const currPath = ref<string>("");
const navPath = ref<any[]>([]);
@@ -53,10 +76,15 @@ const routeChange = (to: any) => {
const navArr: any[] = [];
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
// 匹配不到再退回 modId 对照。
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
// 不相干的「流程编辑器」(验收报告 P3)。
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
menuArr.value.find((x: any) => x.modId === modId);
(modId !== undefined && modId !== null
? menuArr.value.find((x: any) => x.modId === modId)
: null);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
@@ -86,7 +114,10 @@ const routeChange = (to: any) => {
}
}
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
// 同上:modId 缺失时不做这条兜底,否则又是"第一条无 modId 路由"中奖
const r = (modId === undefined || modId === null)
? undefined
: router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
@@ -94,7 +125,10 @@ const routeChange = (to: any) => {
}
currPath.value = "";
navPath.value = [];
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
navPath.value = title ? [{ name: title }] : [];
};
const linkMenu = (item: any) => {
@@ -132,7 +166,7 @@ const childChange = (index: number) => {
};
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
* 兜底保留旧版 modId 对照。图标为通道 iconfont 掩码类(A 类接入后由 YschTheme 发版 icons.css 提供,本地副本已回收) */
const getMenuIcon = (item?: any): string => {
const page: string = item?.page || "";
if (page === "/list/new") return "icon-xiugaijl";
@@ -166,16 +200,13 @@ function linkHomePage() {
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="首页" class="icon-btn" @click="linkHomePage">
<i class="iconfont icon-shouye"></i>
</div>
</div>
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
@@ -267,14 +298,14 @@ function linkHomePage() {
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
/* 仅覆盖/补充必要的动态样式,其余样式由主题通道 base.layout.css 提供 */
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
/* 面包屑「首页」垂直居中:通道 base.layout.css 的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内) */
这里用 inline-flex 真居中并关掉该补偿(不改通道产物,覆盖收在本组件内) */
.crumb-home {
display: inline-flex;
align-items: center;
-4
View File
@@ -13,10 +13,6 @@ import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
import "@/assets/styles/layout.css";
// 规范 iconfont 图标本地实现(icon-browse / icon-edit / icon-ashbin 等,平台主题不含字体)
import "@/assets/styles/icons.css";
//导入自定义指令
import directive from "@/utils/directive/index";
+34 -13
View File
@@ -9,7 +9,7 @@ import type { RouteRecordRaw } from "vue-router";
* 办理页 /task/:taskId 为 UserMsg jumpPath 落地页(不挂菜单)。
* <p>
* 详情/深链页(不挂菜单)通过 meta.parentId 关联父级菜单(UserBase 同款范式):左侧菜单保持
* 父级高亮、面包屑补全"父页>本页"。同一详情页可能被多个列表进入(如办件详情既可从"我的办件"
* 父级高亮、面包屑补全"父页>本页"。同一详情页可能被多个列表进入(如办件详情既可从"我提交的"
* 也可从"办件记录"),此时列表页跳转带 ?pid=<父菜单modId> 动态覆盖 meta.parentId 静态默认。
*/
const routes: RouteRecordRaw[] = [
@@ -19,12 +19,6 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/Workbench.vue"),
meta: { title: "工作台", modId: 100 },
},
{
path: "/my",
name: "flow_my",
component: () => import("@/views/flow/MyInstances.vue"),
meta: { title: "我的办件", modId: 101 },
},
{
path: "/task/:taskId",
name: "flow_task_handle",
@@ -38,6 +32,13 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/InstanceDetail.vue"),
meta: { title: "办件详情", parentId: 102 },
},
{
// T07 托管选人 iframe(F6 接线):业务页 iframe 内嵌,CAS 会话预览候选并回传最终选择
path: "/assign-preview",
name: "flow_assign_preview",
component: () => import("@/views/flow/AssignPreview.vue"),
meta: { title: "选择办理人" },
},
{
// 流程示意图深链页(不挂菜单,同 /task/:taskId 模式);跨应用入口 window.open 整页跳转
path: "/diagram",
@@ -66,12 +67,7 @@ const routes: RouteRecordRaw[] = [
meta: { title: "流程编辑器", parentId: 103 },
},
{
path: "/admin/maps",
name: "flow_admin_maps",
component: () => import("@/views/flow/admin/Maps.vue"),
meta: { title: "办理人映射", modId: 104 },
},
{
// 回调管理(原 104 办理人映射已于 2026-10-10 下线,由 应用设置-科室管理 承载)
path: "/admin/callbacks",
name: "flow_admin_callbacks",
component: () => import("@/views/flow/admin/Callbacks.vue"),
@@ -84,6 +80,31 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/admin/Routers.vue"),
meta: { title: "条件路由", modId: 106 },
},
{
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单,学校池同步);
// tab2 科室管理(Part E 填充)。菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=107)
path: "/admin/app-setting",
name: "flow_admin_app_setting",
component: () => import("@/views/flow/admin/AppSetting.vue"),
meta: { title: "应用设置", modId: 107 },
},
{
// 接入向导(2026-10-15 议题二 P1):五步引导 + 配置生成器(源头消灭配置形态静默断点)。
// 与「应用设置-对接健康度」互补:向导管接入时一次引导,健康度管对接后持续体检。
// 菜单需在 UserBase 侧配置同 path 的 app_module(custom_id=108)
path: "/admin/onboarding",
name: "flow_admin_onboarding",
component: () => import("@/views/flow/admin/Onboarding.vue"),
meta: { title: "接入向导", modId: 108 },
},
{
// 办件进度嵌入页(iframe 免签嵌入,App.vue 对 /embed/* 前缀旁路 CAS 与 layout):
// 不挂菜单、无 parentId(子应用 Drawer 内打开,非管理面导航);query.token 必填
path: "/embed/instance",
name: "flow_embed_instance",
component: () => import("@/views/flow/EmbedInstance.vue"),
meta: { title: "办件进度" },
},
{ path: "/", redirect: "/workbench" },
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
+19 -7
View File
@@ -34,6 +34,13 @@ const defHooks: Required<RequestHooks> = {
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
/** 抛错时把契约里的码/状态挂到 Error 上:调用方可按码分支,而不是回头猜文案 */
function fail(message: string, extra: Record<string, unknown>): never {
const err = new Error(message) as Error & Record<string, unknown>;
Object.assign(err, extra);
throw err;
}
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
@@ -48,7 +55,7 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
default:
msg = res?.msg || "内部服务错误!";
}
throw new Error(msg);
fail(msg, { httpStatus: status });
}
// 业务层错误
@@ -64,9 +71,9 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
throw Object.assign(new Error(res.msg || "参数错误!"), { bizCode: 4 });
default:
throw new Error(res.msg || "业务错误");
throw Object.assign(new Error(res.msg || "业务错误"), { bizCode: res.code });
}
}
@@ -108,7 +115,12 @@ export function createBschAlova(
}
/* ---- 响应拦截 ---- */
async function onSuccess(response: Response) {
/** 调用方自带错误面(如线级启停要报"停发失败:xxx")时,用它跳过全局 toast,避免同一失败弹两条 */
function isSilentBizToast(method?: Method<any>): boolean {
return !!(method as unknown as { config?: Record<string, any> })?.config?.silentBizToast;
}
async function onSuccess(response: Response, method?: Method<any>) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
@@ -120,18 +132,18 @@ export function createBschAlova(
} catch (e: any) {
console.log(e);
onBizError(e.message);
if (!isSilentBizToast(method)) onBizError(e.message);
return Promise.reject(e);
}
}
async function onError(err: any) {
async function onError(err: any, method?: Method<any>) {
reqCount = 0;
clearTimeout(timer);
onLoadingEnd();
const msg = err?.message || "网络请求出错";
onHttpError(msg);
if (!isSilentBizToast(method)) onHttpError(msg);
return Promise.reject(err);
}
+5 -2
View File
@@ -2,6 +2,7 @@
import { createBschAlova } from "./alova-core";
import { Message } from "view-ui-plus";
import conf from "@/assets/config/host.config";
import { describeFlowError } from "@/utils/flowError";
/* 全局 loading 遮罩实例 */
let spinInstance: any = null;
@@ -30,11 +31,13 @@ export const alovaInstance = createBschAlova(baseUrl, {
spinInstance = Message.loading("加载中...");
},
onLoadingEnd: closeLoading,
/* 全局兜底提示:具体业务 msg 原样透出,通用/异常形态换成不误导的用户话术(不泄露堆栈/SQL/令牌) */
onBizError(msg: string) {
if (!!msg) Message.error(msg);
const text = describeFlowError(msg);
if (!!text) Message.error(text);
},
onHttpError(msg: string) {
Message.error({ title: "网络错误", content: msg });
Message.error({ title: "请求未完成", content: describeFlowError(msg) });
},
});
+13
View File
@@ -0,0 +1,13 @@
/**
* 整页跳转用的应用内地址(hash 路由)。
*
* 线上缺陷(FL上线独立验收报告 P1-3):入口把 `router.resolve(...).href`(自带前导 `#`)当成
* path 再拼一次,得到 `index.html#/task/339#/diagram?…` 双 hash——浏览器只认第一个 `#`,
* 页面停在原路由且无任何报错;另一处用 `origin + BASE_URL` 拼基址,BASE_URL 构建期是 "./",
* 拼出 `https://host./#/diagram?…`(域名尾点)。
* 统一口径:先丢掉当前地址里已有的 hash 段,再接上目标路由解析出的唯一 hash。
*/
export function toAppHashUrl(hashHref: string, currentHref: string = window.location.href): string {
const hash = hashHref.startsWith("#") ? hashHref : `#${hashHref}`;
return `${currentHref.split("#")[0]}${hash}`;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 托管选人 iframe 消息协议(2026-10-25 T07)。
*
* 传输层 = 现有原生 postMessage(与 taskEmbed.ts 同栈);独立 yschmessage 库未定位,
* 不假称存在——本模块是 flow 域的最小通信适配(传输与窗口绑定),业务语义(选人确认/取消)
* 由 flow 自己定义,不与 FC 等中心合并。
*
* 安全四件套(父子双方各自校验):
* 1. 精确 origin(发往 referrer origin;收到消息先核 event.origin);
* 2. event.source === 窗口引用(父页核 iframe.contentWindow,本页核 window.parent);
* 3. 协议形状(proto/v/type/contextId 闭集校验);
* 4. 本次 contextId——关闭/重开/换线后旧 frame 的迟到消息按 contextId 失效。
*/
export const ASSIGN_EMBED_PROTO = 'flow';
export const ASSIGN_EMBED_VERSION = 1;
export type AssignEmbedType = 'assign-confirmed' | 'assign-cancelled';
export interface AssignEmbedMessage {
proto: typeof ASSIGN_EMBED_PROTO;
v: number;
type: AssignEmbedType;
/** 本次打开的关联标识:迟到/异 context 消息据此丢弃,不落库不参与幂等 */
contextId: string;
flowGuid?: string;
/** 版本锚:预览时返回的定义版本,正式提交回带 expectedDefVersion */
defVersion?: number;
assigns?: { nodeId: string; userIds: number[] }[];
}
/** 从 query/referrer 解析父页 origin(iframe 自身的锁定目标) */
export function parentOrigin(): string {
try {
if (document.referrer && document.referrer !== location.href) {
return new URL(document.referrer).origin;
}
} catch {
/* referrer 不可解析=无锁定目标,只收不发 */
}
return '';
}
/** 发送前形状自检:确认消息必须带 flowGuid/defVersion/assigns;取消只带 contextId */
export function buildMessage(msg: AssignEmbedMessage): AssignEmbedMessage | null {
if (!msg || msg.proto !== ASSIGN_EMBED_PROTO || msg.v !== ASSIGN_EMBED_VERSION) {
return null;
}
if (!msg.contextId || !msg.type) {
return null;
}
if (msg.type === 'assign-confirmed') {
if (!msg.flowGuid || !msg.defVersion || !Array.isArray(msg.assigns)) {
return null;
}
}
return msg;
}
/** 接收侧(父页)校验:origin/source/形状/context 逐项核,任一不过=丢弃(不告警不打扰) */
export function isValidAssignMessage(
ev: MessageEvent,
expectedOrigin: string,
expectedSource: Window | null,
expectedContextId: string,
): AssignEmbedMessage | null {
if (expectedOrigin && ev.origin !== expectedOrigin) {
return null;
}
if (expectedSource && ev.source !== expectedSource) {
return null;
}
const data = ev.data as Partial<AssignEmbedMessage> | undefined;
if (!data || data.proto !== ASSIGN_EMBED_PROTO || data.v !== ASSIGN_EMBED_VERSION) {
return null;
}
if (data.type !== 'assign-confirmed' && data.type !== 'assign-cancelled') {
return null;
}
if (!data.contextId || data.contextId !== expectedContextId) {
// 旧 frame / 换线后的迟到消息:按 context 失效
return null;
}
return buildMessage(data as AssignEmbedMessage);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
* > localStorage["yh-avatar"](用户自选,纯字符串)
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
* > "text"(系统默认)
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
*/
export type AvatarStyle = "text" | "silhouette";
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
removeItem(k: string): void;
}
export const AVATAR_STORAGE_KEY = "yh-avatar";
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
return raw === "text" || raw === "silhouette" ? raw : null;
}
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
function readAvatarOverride(): AvatarStyle | null {
try {
const search = (globalThis.location && globalThis.location.search) || "";
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
return null;
}
}
/**
* 生效样式合成(与 resolveEffectiveThemeState 同构):
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
* > DEFAULT_AVATAR_STYLE。
*/
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
let style: AvatarStyle | null = null;
if (storage) {
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
}
if (!style) style = DEFAULT_AVATAR_STYLE;
return readAvatarOverride() || style;
}
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(AVATAR_STORAGE_KEY, style);
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.removeItem(AVATAR_STORAGE_KEY);
} catch {
/* 隐私模式静默 */
}
}
/**
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
*/
export function pickAvatarText(name: string | null | undefined): string {
let s = String(name ?? "");
const at = s.indexOf("@");
if (at > -1) s = s.slice(0, at);
const ch = [...s.trim()][0];
if (ch === undefined) return "";
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
if (/^[0-9]$/.test(ch)) return "";
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
return ch;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* 流程示意图 PNG 显示口径(2026-10-10 用户反馈:嵌入页图过大):服务端 DiagramImageService
* 按 SCALE=2 出图(视网膜清晰度),页面若按自然尺寸渲染等于整体放大一倍。
* 须按逻辑尺寸(自然宽一半)显示;>3 分支等超宽长图再收缩到容器宽。
* 用法:<img ref="diagramEl" :src="..." @load="onDiagramLoad">,onMounted 里对 complete 的图补调一次。
*/
export function fitDiagram(el: HTMLImageElement | null | undefined): void {
if (!el || !el.naturalWidth) return;
const want = el.naturalWidth / 2;
const cap = (el.parentElement?.clientWidth || 0) - 8; // Card body 左右衬
el.style.width = Math.round(Math.min(want, cap > 120 ? cap : want)) + "px";
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 服务端错误文案的可读化(FL上线独立验收报告 P0-1 附带项:审批失败只甩「内部服务错误」)。
*
* 边界:前端只做**文案呈现**,不猜原因、不绕校验、不把失败说成成功。
* 后端把某类失败收敛成具体业务码 + 人话 msg 后(如 D09「无激活凭据」),这些 msg 原样透出即可;
* 仍然只给通用占位文案(code=6 内部服务错误)或异常/SQL/令牌形态的原文时,一律换成不误导的
* 通用提示,原文不进 DOM——堆栈、SQL、Token 不外泄。
*/
/** 后端/网关的通用占位文案:出现即说明没有可用诊断信息,需要换成不误导的用户话术 */
const GENERIC_HINTS = [
"内部服务错误",
"服务器错误",
"系统错误",
"系统异常",
"业务错误",
"未知错误",
"服务异常",
"请求失败",
"null",
];
/** 明显是异常原文而不是用户文案的形态:关键字命中即按通用提示处理,不回显原文 */
const INTERNAL_SHAPES = [
/exception/i,
/\bat\s+[a-z]+\.[a-z]+\./i,
/java\./i,
/sql/i,
/error code \[?\d+/i,
/caused by/i,
/stack/i,
/token|bearer|authorization|secret|password/i,
/<\s*(html|!doctype)/i,
/^\s*\{[\s\S]*["']?(status|error|traceback)["']?\s*[:=]/i,
];
/** 网络/会话层:alova 封装把非 200 与 fetch 失败都收敛成这几句 */
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));
}
function looksNetwork(raw: string): boolean {
return NETWORK_SHAPES.some((re) => re.test(raw.trim()));
}
/**
* 业务失败 → 用户可读文案。
* 已在契约里的具体业务 msg(如「流程线不存在:xxx」「该任务已被办理」)原样返回,不做改写;
* 通用/异常形态返回兜底话术。
*/
export function describeFlowError(raw: unknown): string {
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();
}
/** 页面内联提示用:动作名 + 可读原因 + 下一步(不重复弹全局 toast) */
export function flowFailTip(action: string, raw: unknown): string {
return `${action}未完成:${describeFlowError(raw)}`;
}
+47 -39
View File
@@ -1,23 +1,24 @@
/**
* 流程图自动布局(从 FlowDiagram.vue 提取的设计器/查看器共用实现):
* Kahn 拓扑分层 depth(to)=max(depth(from))+1,从左到右布列、同列按定义顺序纵排。
* 设计器(contentJson 无 layout 坐标时算初始坐标)与查看器(无坐标回退)共用同一口径。
* 流程图自动布局(查看器在后端未下发 positions 时的灰度兜底):Kahn 拓扑分层
* depth(to)=max(depth(from))+1,自上而下布层、同层按定义顺序横排、整层块对竖直中线水平居中。
* 与后端 DiagramLayoutService 同规则同常量(2026-10-09 竖排改造镜像);设计器已是钉钉式
* 竖向卡片流,不再消费本模块。
*/
export const SLOT_W = 200; // 节点槽位宽
export const SLOT_H = 88; // 节点槽位高
export const COL_GAP = 72; // 列间距(留给正交折线走线)
export const ROW_GAP = 24; // 行间距
export const SLOT_W = 280; // 分支槽宽(盒型在槽内水平居中;与后端一致)
export const SLOT_H = 100; // 层槽高(盒型在槽内垂直居中;与后端一致)
export const GAP_X = 44; // 分支列间距(分支间无需走线空间)
export const GAP_Y = 56; // 层间距(留给竖直走线 + 多出口文案)
export const PAD = 24;
export const NODE_BOX: Record<string, { w: number; h: number }> = {
START: { w: 104, h: 40 },
END: { w: 104, h: 40 },
APPROVAL: { w: 184, h: 64 },
NOTIFY: { w: 184, h: 64 },
PARALLEL_SPLIT: { w: 128, h: 72 },
PARALLEL_JOIN: { w: 128, h: 72 },
CONDITION: { w: 128, h: 72 },
START: { w: 116, h: 44 },
END: { w: 116, h: 44 },
APPROVAL: { w: 264, h: 76 },
NOTIFY: { w: 264, h: 76 },
PARALLEL_SPLIT: { w: 132, h: 78 },
PARALLEL_JOIN: { w: 132, h: 78 },
CONDITION: { w: 132, h: 78 },
};
export interface Pos {
@@ -36,7 +37,7 @@ export function layout(data: any): Record<string, Pos> {
return pos;
}
/** Kahn 分层 + 同列按定义顺序纵排(基础网格,保证无重叠) */
/** Kahn 分层 + 同层按定义顺序横排(基础网格,保证无重叠) */
function layered(data: any): Record<string, Pos> {
const nodes: any[] = data?.nodes || [];
const edges: any[] = data?.edges || [];
@@ -56,31 +57,38 @@ function layered(data: any): Record<string, Pos> {
if (--inDeg[e.to] === 0) order.push(e.to);
});
}
const rowsIn: Record<number, number> = {};
const rowsIn: Record<number, any[]> = {};
const pos: Record<string, Pos> = {};
nodes.forEach((n) => {
const d = depth[n.id] ?? 0;
depth[n.id] = d;
const row = rowsIn[d] ?? 0;
rowsIn[d] = row + 1;
const row = (rowsIn[d] = rowsIn[d] || []).length;
rowsIn[d].push(n);
const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL;
pos[n.id] = {
x: PAD + d * (SLOT_W + COL_GAP) + (SLOT_W - box.w) / 2,
y: PAD + row * (SLOT_H + ROW_GAP) + (SLOT_H - box.h) / 2,
x: PAD + row * (SLOT_W + GAP_X) + (SLOT_W - box.w) / 2,
y: PAD + d * (SLOT_H + GAP_Y) + (SLOT_H - box.h) / 2,
w: box.w,
h: box.h,
depth: d,
row,
};
});
// 整层块对竖直中线水平居中(镜像后端:按实际占宽算块宽,不含尾随 GAP_X)
const maxCols = Math.max(...Object.values(rowsIn).map((a) => a.length));
const axisW = (maxCols - 1) * (SLOT_W + GAP_X) + SLOT_W;
Object.values(rowsIn).forEach((arr) => {
const blockW = (arr.length - 1) * (SLOT_W + GAP_X) + SLOT_W;
const dx = (axisW - blockW) / 2;
arr.forEach((n) => { pos[n.id].x += dx; });
});
return pos;
}
/**
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开
* (与钉钉/参考稿一致)。SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的
* PARALLEL_JOIN),组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 PAD。
* 并行分支组水平居中:分支不再从 SPLIT 行向右堆叠,而是以 SPLIT 竖直中线为轴左右对称展开
* (2026-10-09 竖排镜像)。SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的
* PARALLEL_JOIN),组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 水平中点吸附回 SPLIT 中线。
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最左列推回 PAD。
*/
function centerParallelGroups(data: any, pos: Record<string, Pos>): void {
const nodes: any[] = data?.nodes || [];
@@ -144,23 +152,23 @@ function centerParallelGroups(data: any, pos: Record<string, Pos>): void {
- Math.max(...a.members.map((m: string) => pos[m].depth)));
groups.forEach((g: any) => {
const axisY = pos[g.split].y + pos[g.split].h / 2;
let top = Infinity;
let bottom = -Infinity;
const axisX = pos[g.split].x + pos[g.split].w / 2;
let left = Infinity;
let right = -Infinity;
g.members.forEach((id: string) => {
top = Math.min(top, pos[id].y);
bottom = Math.max(bottom, pos[id].y + pos[id].h);
left = Math.min(left, pos[id].x);
right = Math.max(right, pos[id].x + pos[id].w);
});
const delta = axisY - (top + bottom) / 2;
g.members.forEach((id: string) => { pos[id].y += delta; });
pos[g.join].y += axisY - (pos[g.join].y + pos[g.join].h / 2);
const delta = axisX - (left + right) / 2;
g.members.forEach((id: string) => { pos[id].x += delta; });
pos[g.join].x += axisX - (pos[g.join].x + pos[g.join].w / 2);
});
// 上移出界的组把整图平移回 PAD(同 delta 平移不破坏各组居中)
let minY = Infinity;
Object.values(pos).forEach((p) => { minY = Math.min(minY, p.y); });
if (minY < PAD) {
const lift = PAD - minY;
Object.values(pos).forEach((p) => { p.y += lift; });
// 左移出界的组把整图平移回 PAD(同 delta 平移不破坏各组居中)
let minX = Infinity;
Object.values(pos).forEach((p) => { minX = Math.min(minX, p.x); });
if (minX < PAD) {
const lift = PAD - minX;
Object.values(pos).forEach((p) => { p.x += lift; });
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 轨迹口径工具(三视图 + 嵌入页共用,2026-10-12):
* 抄送(CC)在库里与轨迹里是"逐接收人一条"(FlowEngine.notifyReceivers 逐人落 flw_cc)。
* 人员视图下正需如此(能看出抄送给了谁);但科室视图不列姓名,N 个接收人若逐行渲染就是
* N 行一模一样的文案,故 dept 档按「节点 + 轮次」折叠为一行并记人数——口径对齐
* /oapi/flow/progress 的 ccSteps(本就按 nodeId 聚合)。
*/
/** dept 档折叠抄送行:同 nodeId + 同轮次的 CC 行合并为一行(ccCount 记人数);其余行原样、顺序不变。 */
export function collapseCcRows(trace: any[], mode?: string): any[] {
if (mode !== "dept") return trace;
const out: any[] = [];
const at: Record<string, number> = {};
for (const r of trace) {
if (r.kind !== "CC") {
out.push(r);
continue;
}
const key = `${r.nodeId}#${r.round || 1}`;
const i = at[key];
if (i === undefined) {
at[key] = out.length;
out.push({ ...r, ccCount: 1 });
} else {
out[i].ccCount += 1;
}
}
return out;
}
/** dept 档抄送行角色词:科室抄送显科室名(解析不出退短码)、指定人抄送显"抄送对象",多人加人数后缀。
* ⚠️ 刻意不回落 assigneeName——本函数存在的意义就是不出现人名。 */
export function ccActorText(row: any): string {
const base = row.assigneeType === "DEPT_KEY"
? (row.deptName || row.deptKey || "抄送对象")
: "抄送对象";
return (row.ccCount || 1) > 1 ? `${base} ${row.ccCount} 人` : base;
}
+36
View File
@@ -0,0 +1,36 @@
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
/**
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
*
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
* 都不再需要同步改任何魔法数字。
*
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
*/
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
const height = ref(0);
let ro: ResizeObserver | null = null;
const measure = () => {
if (el.value) height.value = Math.floor(el.value.clientHeight);
};
const observe = () => {
ro?.disconnect();
if (!el.value || typeof ResizeObserver === "undefined") return;
measure();
ro = new ResizeObserver(measure);
ro.observe(el.value);
};
onMounted(observe);
// v-if / v-show 切换导致容器重建时,重新挂观察
watch(el, observe);
onBeforeUnmount(() => ro?.disconnect());
return height;
}
+12 -33
View File
@@ -1,5 +1,5 @@
import { IObj, UseTableSetting } from "@/types/Basics";
import { resolveComponent } from "vue";
import { actionIcon, actionText } from "@/utils/tableActions";
export default class Utils {
/**
* 格式化日期
@@ -76,7 +76,8 @@ export default class Utils {
}
/**
* iview Tooltip封装
* iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
*/
public static operatingHandle(
h: any,
@@ -85,30 +86,10 @@ export default class Utils {
callback: Function,
disabled: boolean = false,
) {
if (disabled) iconClass += " disabled";
return h(
resolveComponent("Tooltip"),
{
content: info,
placement: "bottom",
transfer: true,
},
{
default: () => [
h(
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
},
null,
),
],
},
);
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
}
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
public static operatingTextHandle(
h: any,
info: string,
@@ -116,15 +97,13 @@ export default class Utils {
callback: Function,
disabled: boolean = false,
) {
if (disabled) className += " disabled";
return h(
"div",
{
class: `operating-link ${className}`,
onClick: disabled ? null : callback,
},
info,
);
return actionText(h, {
key: info,
label: info,
danger: (className || "").includes("text-error"),
on: () => callback(),
disabled,
});
}
/**
+53
View File
@@ -0,0 +1,53 @@
/**
* 流程线级停发/恢复的请求编排(T03)。
* 与版本级 status 正交:所有操作按 flowGuid 下发,后端批量更新该线全部版本行。
*/
export type LineAction = "offline" | "enable";
export type LineSkipReason = "no-guid" | "already-offline" | "not-offline";
export interface LineActionResult {
kind: "skipped" | "updated" | "empty";
reason?: LineSkipReason;
affected: number;
/** 操作已成功但列表刷新失败:调用方须提示“成功但未刷新”,不能报整体失败 */
reloadFailed: boolean;
}
export interface LineActionOptions {
flowGuid: string;
/** 行上的 lineOffline 原值(后端 Integer,1=已停发) */
lineOffline: number;
action: LineAction;
request: (flowGuid: string) => Promise<number>;
reload: () => Promise<void>;
}
/** 把 alova/后端错误统一成可读文案;HTTP 401/403 在当前 alova 封装里落到“网络错误” */
export function lineActionErrorDetail(raw: string): string {
const networkLike = !raw || raw === "网络错误" || /failed to fetch|fetch failed|network|timeout/i.test(raw);
return networkLike ? "网络异常,或登录状态/流程管理权限校验未通过,请刷新后重试" : raw;
}
export async function runLineAction(opts: LineActionOptions): Promise<LineActionResult> {
const flowGuid = (opts.flowGuid || "").trim();
if (!flowGuid) {
return { kind: "skipped", reason: "no-guid", affected: 0, reloadFailed: false };
}
const offline = opts.action === "offline";
if (offline && opts.lineOffline === 1) {
return { kind: "skipped", reason: "already-offline", affected: 0, reloadFailed: false };
}
if (!offline && opts.lineOffline !== 1) {
return { kind: "skipped", reason: "not-offline", affected: 0, reloadFailed: false };
}
const affected = Number(await opts.request(flowGuid)) || 0;
let reloadFailed = false;
try {
await opts.reload();
} catch {
reloadFailed = true;
}
return { kind: affected > 0 ? "updated" : "empty", affected, reloadFailed };
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
*
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
*
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
*/
import { h, resolveComponent } from "vue";
export interface ActionItem {
/** 稳定标识:溢出分桶按 key 去重,必填 */
key: string;
label: string;
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
on: (row: any) => void;
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
icon?: string;
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
danger?: boolean;
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
when?: (row: any) => boolean;
/** 直接置灰 */
disabled?: boolean;
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
disabledWhen?: (row: any) => boolean;
}
export interface ActionCellOpts {
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
mode?: "icon" | "text";
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
collapsible?: boolean;
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
maxInline?: number;
/** 列宽;缺省 icon = n*40+40 / text = 180 */
width?: number;
align?: "center" | "left" | "right";
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
fixed?: "right" | "left" | false;
}
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
export function actionIcon(h: any, item: ActionItem) {
return h(
resolveComponent("Tooltip"),
{ content: item.label, placement: "bottom", transfer: true },
{
default: () => [
h("div", {
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
}),
],
},
);
}
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
export function actionText(h: any, item: ActionItem) {
return h(
"div",
{
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
},
item.label,
);
}
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
export function actionMore(h: any, items: ActionItem[]) {
return h(
resolveComponent("Dropdown"),
{ class: "op-more", placement: "bottom-end", transfer: true },
{
default: () => [
h("div", { class: "operating-link op-more-label" }, [
"更多",
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
]),
],
list: () => [
h(resolveComponent("DropdownMenu"), {}, {
default: () => items.map((it) =>
h(
resolveComponent("DropdownItem"),
{
key: it.key,
divided: !!(it.danger && items.length > 1),
disabled: !!it.disabled,
class: it.danger ? "op-item-danger" : "",
onClick: () => it.on(),
},
{ default: () => it.label },
),
),
}),
],
},
);
}
/* ============ 轴 2:归一化(纯数据) ============ */
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
return items
.filter((it) => (it.when ? it.when(row) : true))
.map((it) => ({
...it,
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
on: () => it.on(row),
}))
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
}
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
const inlineKeys = new Set(inline.map((it) => it.key));
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
}
/* ============ 组装层 ============ */
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
const { inline, overflow } = (opts.collapsible ?? true)
? splitActions(items, opts.maxInline ?? 2)
: { inline: items, overflow: [] };
const render = RENDERERS[mode] || actionText;
const children = inline.map((it) => render(h, it));
if (overflow.length) children.push(actionMore(h, overflow));
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
}
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
return {
title: "操作",
key: "action",
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
align: opts.align ?? "center",
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 去壳任务页(/task/{taskId}?embed=1)与父应用之间的 postMessage 协议(v2.1 应用内审批·完整办理)。
*
* 协议常量集中在此,父侧(Notice/svc-hall 的 FlowTaskEmbed)按同样字面量校验;跨应用不发包,
* 改动须双端同步。
*
* origin 纪律:子页只向 document.referrer 的 origin 发(父文档地址),拿不到就不发——
* 禁止 `*`,否则任意宿主页面嵌我们任务页都能收到办理结果。父侧必须同样校验 event.origin。
*/
export const FLOW_EMBED_PROTO = "flow";
export const FLOW_EMBED_VERSION = 1;
export type FlowEmbedAction = "approve" | "reject" | "return" | "transfer";
export interface FlowEmbedMessage {
proto: typeof FLOW_EMBED_PROTO;
v: typeof FLOW_EMBED_VERSION;
type: "handled" | "open-biz" | "height";
taskId?: string;
action?: FlowEmbedAction;
bizId?: string | null;
bizUrl?: string;
height?: number;
}
/** 本是否作为"去壳任务页"运行:路径在 /task/ 下且 embed=1(与 App.vue 的壳判定同一条规则) */
export function isBareTaskRoute(path: string, embed: unknown): boolean {
return path.startsWith("/task/") && String(embed ?? "") === "1";
}
/** 当前是否运行在 iframe 内(顶层 window 与 parent 不同) */
export function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch {
// 跨域访问 window.top 本身会抛——抛了说明就在 iframe 里
return true;
}
}
/**
* 父页面 origin:iframe 的 document.referrer 即宿主文档地址,且在整个 iframe 生命周期内不变
* (SPA 内部路由不影响它)。直接输地址进 iframe(无 referrer)时返回 null——宁可不发,
* 也不猜一个 targetOrigin。
*/
export function parentOrigin(): string | null {
const ref = document.referrer;
if (!ref) return null;
try {
const origin = new URL(ref).origin;
return origin && origin !== "null" ? origin : null;
} catch {
return null;
}
}
/** 向父页面发一条协议消息;非 iframe 或父 origin 未知时静默跳过(独立直开任务页即现状行为) */
export function postToParent(msg: Omit<FlowEmbedMessage, "proto" | "v">): void {
if (!isInIframe()) return;
const target = parentOrigin();
if (!target) return;
window.parent.postMessage({ proto: FLOW_EMBED_PROTO, v: FLOW_EMBED_VERSION, ...msg }, target);
}
/** 父侧校验:只认本协议的消息体(origin 白名单由调用方自己按 iframe 地址判,这里管信封) */
export function isFlowEmbedMessage(data: unknown): data is FlowEmbedMessage {
const d = data as FlowEmbedMessage | null;
return !!d && d.proto === FLOW_EMBED_PROTO && d.v === FLOW_EMBED_VERSION && !!d.type;
}
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="assign-preview-page">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-else-if="!flowGuid" type="warning" show-icon message="缺少 flowGuid 查询参数" />
<template v-else>
<FlowAssignSelector ref="selectorRef" :flow-guid="flowGuid" />
<div class="actions">
<Button type="primary" :loading="submitting" @click="onConfirm">确认选人</Button>
<Button @click="onCancel">取消</Button>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
/**
* 托管选人 iframe 页(2026-10-25 T07,F6 正式接线):
* 业务页面以 iframe 打开本页(query: flowGuid & contextId),本页 CAS 会话预览候选;
* 确认=只向父页回传最终选择+版本锚(postMessage,精确 origin),<b>不调用 apply、
* 不建实例、不建临时流程</b>;取消=只通知关闭。正式提交由父页所在业务按 apply 终审。
* 重新确认以最近一次为准(父页内存覆盖;本页每次确认即时回传)。
*/
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Button } from 'view-ui-plus';
import FlowAssignSelector from '@/components/FlowAssignSelector.vue';
import { flowApi } from '@/api/module/flow';
import { AssignEmbedMessage, buildMessage, parentOrigin, ASSIGN_EMBED_PROTO, ASSIGN_EMBED_VERSION } from '@/utils/assignEmbed';
const route = useRoute();
const flowGuid = computed(() => String(route.query.flowGuid || ''));
/** 本次打开的关联标识(父页生成);迟到/异 context 消息按它失效 */
const contextId = computed(() => String(route.query.contextId || ''));
const lockedOrigin = parentOrigin();
const selectorRef = ref();
const error = ref('');
const submitting = ref(false);
onMounted(() => {
if (!contextId.value) {
error.value = '缺少 contextId(父页必须生成本次打开的关联标识)';
}
});
async function onConfirm(): Promise<void> {
const selector = selectorRef.value;
if (!selector) {
return;
}
submitting.value = true;
try {
const invalid = selector.validate();
// validate() 返回的是错误文案字符串(空串=通过):早先按数组取值会把提示截成第一个字符
if (invalid) {
error.value = invalid;
return;
}
const preview = await flowApi.applyPreview(flowGuid.value).send();
const assigns = selector.collect();
const msg = buildMessage({
proto: ASSIGN_EMBED_PROTO,
v: ASSIGN_EMBED_VERSION,
type: 'assign-confirmed',
contextId: contextId.value,
flowGuid: flowGuid.value,
defVersion: preview?.defVersion,
assigns,
});
if (!msg || !lockedOrigin) {
error.value = !msg ? '消息构造失败(形状不完整)' : '无法锁定父页 origin(referrer 缺失),拒绝回传';
return;
}
// 精确 origin 回传(同 taskEmbed.postToParent 纪律);source 由父页按 contentWindow 核
window.parent.postMessage(msg, lockedOrigin);
} finally {
submitting.value = false;
}
}
function onCancel(): void {
if (!lockedOrigin || !contextId.value) {
return; // 无法安全回传就静默(父页有关闭超时兜底)
}
const msg: AssignEmbedMessage = {
proto: ASSIGN_EMBED_PROTO,
v: ASSIGN_EMBED_VERSION,
type: 'assign-cancelled',
contextId: contextId.value,
};
window.parent.postMessage(msg, lockedOrigin);
}
</script>
<style scoped>
.assign-preview-page {
padding: 16px;
}
.actions {
margin-top: 16px;
display: flex;
gap: 12px;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
// 办件进度嵌入页(iframe 免签嵌入,2026-10-03 方案):子应用 Drawer 里以 iframe 打开本页,
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
// 2026-10-11 整合:正文(基本信息/办理进度三视图/流程示意图折叠/业务信息摘要)抽至共用组件
// InstanceBody.vue,与管理面办件详情 /instance/:instanceId 同源同款;本页只留 token 取数、
// 标题吸顶壳与空态——嵌入口径(只认 summaryFields、无流程信息/表单数据)由组件缺省 variant 保证。
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { Tag } from "view-ui-plus";
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
const detail = ref<any>(null);
const loading = ref(true);
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
const expired = ref(false);
const token = computed(() => String(route.query.token || ""));
/** 示意图直嵌:同一 token 第二处消费(换图端点零改动即认领嵌入 token) */
const diagramSrc = computed(
() => `${config.serviceHost}/open/flow/diagram/img?token=${token.value}`,
);
function statusName(s: number) {
return INSTANCE_STATUS[s] || s;
}
function statusColor(s: number) {
return INSTANCE_STATUS_COLOR[s] || "gray";
}
// 免签端点不带任何会话,alova 拦截器(token 头/信封解析)一概用不上,原生 fetch 直取
onMounted(async () => {
if (!token.value) {
expired.value = true;
loading.value = false;
return;
}
try {
const res = await fetch(`${config.serviceHost}/open/flow/embed/data?token=${token.value}`);
if (!res.ok) {
// 404=过期/伪造,与缺 token 同空态(不区分,不诱导探测)
expired.value = true;
return;
}
detail.value = await res.json();
} catch {
expired.value = true;
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="embed-page" v-if="detail">
<div class="head">
<div class="head-row">
<span class="title">{{ detail.bizTitle }}</span>
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
</div>
<!-- 流程简略信息(detail.nodeSummary=APPROVAL 节点名串联,后端早已下发):
不展开"流程示意图"也能一眼看清走向,口径同 Notice 详情页的"流程:"摘要行 -->
<div class="head-flow" v-if="detail.nodeSummary">流程:{{ detail.nodeSummary }}</div>
</div>
<InstanceBody :detail="detail" :diagram-src="diagramSrc" />
</div>
<div v-else-if="loading" class="embed-page muted">加载中…</div>
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
</template>
<style scoped>
/* 根类不用 .page:UnoCSS 原子类同名会以更高优先级盖掉 scoped 的 padding(裸渲染无 layout 壳时暴露) */
/* 嵌入页自身即滚动容器:全局 html/body overflow:hidden(App.vue 壳用),裸渲染页无 layout 滚动壳,
不在此定高滚动的话内容超一屏被裁且无滚动条(2026-10-10 用户反馈) */
.embed-page { height: 100vh; overflow-y: auto; padding: 0 20px 28px; box-sizing: border-box; }
/* 标题区吸顶(贴合设计基线):左右负外边距让白色背景铺满滚动容器,滚动时内容不从两侧留白露出 */
.head {
position: sticky; top: 0; z-index: 5; background: #fff;
margin: 0 -20px 16px; padding: 14px 20px 12px; border-bottom: 1px solid #f2f4f7;
}
.head-row { display: flex; align-items: center; gap: 8px; }
.head-flow { margin-top: 6px; font-size: 12px; line-height: 1.5; color: #8a94a6; word-break: break-all; }
.title { font-size: 16px; font-weight: 600; flex: 1; min-width: 0; }
.muted { color: #999; }
.expired { padding: 48px 16px; text-align: center; }
</style>
+158 -137
View File
@@ -1,20 +1,13 @@
<script setup lang="ts">
// 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user,缺省 user)。
// 三视图(2026-09-17):auto=示意图(手写 SVG 自动布局,零图形库)|canvas=画布视图(bpmn-js 只读渲染,
// 复用设计器的 contentJson→BPMN 通道,与示意图视觉区分)|img=进度图(服务端 PNG 流)。
// 出图形态两档(2026-10-03 画布视图退役):auto=示意图(后端 positions 单源,手写 SVG)|img=进度图(服务端 PNG 流)。
// 显示口径(2026-09-18):只两档 dept=科室/user=人员,不传=人员;与办件详情/办理页的轨迹口径同参数。
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
import config from "@/assets/config/host.config";
import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
import { toBpmnXml, CanvasJson } from "@/views/flow/admin/def-editor/bpmn/serialize";
// bpmn-js 渲染样式(设计器 custom.ts 已全局引过,此处再引由打包器去重;漏引连线/图标异常)
import "diagram-js/assets/diagram-js.css";
import "bpmn-js/dist/assets/bpmn-js.css";
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
const route = useRoute();
const router = useRouter();
@@ -29,10 +22,9 @@ const sourceText = ref("");
const sourceTitle = ref("");
const sourceLoading = ref(false);
/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(bpmn-js 只读)|img=进度图(服务端 PNG 流) */
const chartStyle = ref<string>((route.query.chart as string) || "auto");
/** 出图形态两档(2026-10-03 砍画布视图):auto=示意图|img=进度图;旧深链 chart=canvas 归一到 auto */
const chartStyle = ref<string>(route.query.chart === "img" ? "img" : "auto");
const showImg = computed(() => chartStyle.value === "img");
const showCanvas = computed(() => chartStyle.value === "canvas");
/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */
const pngSrc = computed(() => {
@@ -46,37 +38,89 @@ const pngSrc = computed(() => {
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用;并行分支组垂直居中)
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(后端 positions 单源,此处仅灰度兜底;竖排镜像同常量)
import { layout as autoLayout, PAD, Pos } from "@/utils/flowLayout";
/** 布局:后端 positions 单源(内容区坐标);旧后端未下发时回退本地自动布局(灰度兜底) */
const positions = computed<Record<string, Pos>>(() => {
return autoLayout({ nodes: vo.value?.nodes || [], edges: vo.value?.edges || [] });
const raw = (vo.value as any)?.positions as Record<string, { x: number; y: number; w: number; h: number }> | undefined;
const nodes: any[] = vo.value?.nodes || [];
if (raw && nodes.length && nodes.every((n) => raw[String(n.id)])) {
const out: Record<string, Pos> = {};
Object.entries(raw).forEach(([id, p]) => {
out[id] = { x: p.x, y: p.y, w: p.w, h: p.h, depth: 0, row: 0 };
});
return out;
}
return autoLayout({ nodes, edges: vo.value?.edges || [] });
});
const hasState = computed(() => (vo.value?.nodes || []).some((n: any) => n.state));
const vbSize = computed(() => {
const ps = Object.values(positions.value);
if (!ps.length) return { w: 480, h: 160 };
let maxX = 0;
let maxY = 0;
ps.forEach((p) => { maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); });
return { w: maxX + PAD, h: maxY + PAD };
// instance 口径补图例带(与 PNG 出图同口径)
return { w: maxX + PAD, h: maxY + PAD + (hasState.value ? 44 : 0) };
});
/** 图例(instance 口径四态,与 PNG 图例同口径同顺序),画布底部左侧 */
const LEGEND_STATES = [
{ state: "active", label: "在办", stroke: "#ed4014" },
{ state: "done", label: "已办", stroke: "#19be6b" },
{ state: "pending", label: "未到", stroke: "#dcdee2" },
{ state: "rejected", label: "被拒绝", stroke: "#ed4014" },
];
const legendItems = computed(() => {
if (!hasState.value) return [];
const y = vbSize.value.h - 22;
let x = PAD;
return LEGEND_STATES.map((it) => {
const item = { ...it, x, y };
x += 24 + it.label.length * 12 + 24;
return item;
});
});
/** auto-fit:宽撑满容器、上限自然宽(>3 分支等比缩放;<0.55 转 min-width+横向滚动兜底,方案 §2.6) */
const svgStyle = computed(() => ({
width: "100%",
maxWidth: `${vbSize.value.w}px`,
minWidth: `${Math.round(vbSize.value.w * 0.55)}px`,
height: "auto",
}));
function pos(n: any): Pos {
return positions.value[n.id];
}
/** 正交折线:源右侧中点 → 中线 → 目标左侧中点 */
/** 正交折线(方向自适应,前后端独立部署/发布顺序无关):目标在源下方走竖排折线
* (底中→竖直段→水平折→顶中,箭头 orient=auto 自动朝下);目标在源右侧走横排折线
* (兼容旧 positions);对角/异常回退直线。 */
function edgePath(e: any): string {
const a = positions.value[e.from];
const b = positions.value[e.to];
if (!a || !b) return "";
if (b.y >= a.y + a.h) {
const x1 = a.x + a.w / 2;
const y1 = a.y + a.h;
const x2 = b.x + b.w / 2;
const y2 = b.y;
const mid = (y1 + y2) / 2;
return `M ${x1} ${y1} L ${x1} ${mid} L ${x2} ${mid} L ${x2} ${y2}`;
}
if (b.x >= a.x + a.w) {
const x1 = a.x + a.w;
const y1 = a.y + a.h / 2;
const x2 = b.x;
const y2 = b.y + b.h / 2;
const mid = (x1 + x2) / 2;
return `M ${x1} ${y1} L ${mid} ${y1} L ${mid} ${y2} L ${x2} ${y2}`;
}
return `M ${a.x + a.w / 2} ${a.y + a.h / 2} L ${b.x + b.w / 2} ${b.y + b.h / 2}`;
}
function diamondPoints(n: any): string {
@@ -98,11 +142,19 @@ const STATE_STROKE: Record<string, string> = {
rejected: "#ed4014",
};
/** 边标签锚点(与 edgePath 同口径):竖排挂折线中点——同源多出口共享下方竖直段,
* 挂 (x1+x2)/2 折线中点才不会互相叠字;横排保持旧中点。 */
function edgeLabelAnchor(e: any) {
const a = positions.value[e.from];
const b = positions.value[e.to];
if (!a || !b) return null;
if (b.y >= a.y + a.h) {
return { x: (a.x + a.w / 2 + b.x + b.w / 2) / 2, y: (a.y + a.h + b.y) / 2 };
}
if (b.x >= a.x + a.w) {
return { x: (a.x + a.w + b.x) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 };
}
return { x: (a.x + a.w / 2 + b.x + b.w / 2) / 2, y: (a.y + a.h / 2 + b.y + b.h / 2) / 2 };
}
function trunc(s: string | null, n: number): string {
@@ -110,6 +162,67 @@ function trunc(s: string | null, n: number): string {
return s.length > n ? s.slice(0, n) + "…" : s;
}
/**
* 名单换行(≤2 行,与后端 PNG wrapNameList 同规则):按「、」整名装箱换行;放不下时两行装满、
* 第 2 行尾追加「…等 N 人」(N=未展示人数,第 2 行预留尾缀宽度)。前端无 FontMetrics,
* 按 CJK≈1em / ASCII≈0.56em 估宽(subLabel 字号 11px)。
*/
function wrapNames(s: string | null | undefined, boxW: number): string[] {
if (!s) return [];
const em = 11;
const width = (t: string): number => {
let w = 0;
for (const ch of t) w += ch.charCodeAt(0) > 0x2e7f ? em : em * 0.56;
return w;
};
const inner = boxW - 20;
const tok = (t: string): string => {
if (width(t) <= inner) return t;
let out = "";
for (const ch of t) {
if (width(out + ch + "…") > inner) break;
out += ch;
}
return out + "…";
};
const people = s.split("、");
// 全量贪心装箱:先判断是否真的放得下
const packed: string[] = [];
let cur = "";
for (const raw of people) {
const p = tok(raw);
const cand = cur === "" ? p : cur + "、" + p;
if (width(cand) <= inner) cur = cand;
else { packed.push(cur); cur = p; }
}
packed.push(cur);
if (packed.length <= 2) return packed;
// 放不下:两行装满 + 「…等 N 人」尾缀(第 2 行装箱预留尾缀宽度)
const n = people.length;
let l1 = "";
let shown = 0;
while (shown < n) {
const cand = l1 === "" ? tok(people[shown]) : l1 + "、" + tok(people[shown]);
if (width(cand) <= inner) { l1 = cand; shown++; } else break;
}
let l2 = "";
while (shown < n) {
const restAfter = n - shown - 1;
const tail = restAfter > 0 ? `…等 ${restAfter} 人` : "";
const cand = l2 === "" ? tok(people[shown]) : l2 + "、" + tok(people[shown]);
if (width(cand) + (tail ? width(tail) : 0) <= inner) { l2 = cand; shown++; } else break;
}
const rest = n - shown;
if (l2 === "") return [l1, `…等 ${rest} 人`];
return [l1, l2 + (rest > 0 ? `…等 ${rest} 人` : "")];
}
/** 名单卡换行结果(标题/名单基线随行数切换:2 行 25/44/61,1 行 27/50,与 PNG 同口径) */
function nameLines(n: any): string[] {
const p = pos(n);
return wrapNames(n.subLabel, p ? p.w : 264);
}
/** 出图形态切换;进出画布/进度图无需重拉(数据 vo 共用),只换渲染形态 */
function switchChartStyle(val: string) {
chartStyle.value = val;
@@ -178,94 +291,10 @@ async function load() {
}
}
// ==================== 画布视图(bpmn-js 只读渲染,2026-09-17 与示意图区分) ====================
const bpmnHost = ref<HTMLDivElement | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let bpmnViewer: any = null;
/** DiagramVo → 画布视图数据(layout 为后端透传的设计器中心坐标;缺省回退共享自动布局) */
const bpmnDef = computed<CanvasJson | null>(() => {
const nodes: any[] = vo.value?.nodes || [];
if (!nodes.length) return null;
return {
code: "",
name: vo.value?.name || "",
start: "start",
nodes: nodes.map((n) => ({
id: String(n.id),
type: n.type,
name: n.subLabel ? `${n.label}\n${n.subLabel}` : n.label,
})),
edges: ((vo.value?.edges || []) as any[]).map((e) => ({
from: String(e.from), to: String(e.to), name: e.name,
})),
settings: {},
layout: vo.value?.layout as CanvasJson["layout"],
};
});
async function initBpmn() {
const host = bpmnHost.value;
const def = bpmnDef.value;
if (!host || !def) return;
bpmnViewer?.destroy();
bpmnViewer = new NavigatedViewer({ container: host });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (bpmnViewer as any).importXML(toBpmnXml(def));
} catch (e: any) {
Message.error(`画布视图载入失败:${e?.message || e}`);
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canvas = bpmnViewer.get("canvas") as any;
canvas.zoom("fit-viewport", "auto");
// 运行时状态着色(instance 口径):marker 类 + <style> 里 .fc-* 规则
nodesWithState().forEach(([id, state]) => canvas.addMarker(id, `fc-${state}`));
}
function nodesWithState(): Array<[string, string]> {
return ((vo.value?.nodes || []) as any[])
.filter((n) => n.state)
.map((n) => [String(n.id), String(n.state)]);
}
watch([showCanvas, bpmnDef], ([on]) => {
if (on) void nextTick(initBpmn);
});
onUnmounted(() => {
bpmnViewer?.destroy();
bpmnViewer = null;
});
function bpmnZoom(delta: number) {
if (!bpmnViewer) return;
const c = bpmnViewer.get("canvas");
c.zoom(c.zoom() + delta);
}
function bpmnFit() {
bpmnViewer?.get("canvas").zoom("fit-viewport", "auto");
}
// ==================== 导出 PNG(方案 §4.4) ====================
/** 当前形态的 SVG 源:示意图取 DOM,画布视图取 bpmn saveSVG(补 width/height 供 Image 取自然尺寸) */
/** 当前形态的 SVG 源:示意图取 DOM */
async function currentSvg(): Promise<string | null> {
if (chartStyle.value === "canvas") {
if (!bpmnViewer) return null;
const { svg } = await bpmnViewer.saveSVG();
if (!/width="/.test(svg)) {
const m = svg.match(/viewBox="([\d.\s-]+)"/);
if (m) {
const parts = m[1].trim().split(/\s+/);
return svg.replace(/<svg /, `<svg width="${parts[2]}" height="${parts[3]}" `);
}
}
return svg;
}
if (!svgRef.value) return null;
return new XMLSerializer().serializeToString(svgRef.value);
}
@@ -330,7 +359,6 @@ watch(() => route.query, () => load());
{{ DEF_STATUS[vo.status] || vo.status }}</Tag>
<RadioGroup :model-value="chartStyle" size="small" type="button" @on-change="switchChartStyle">
<Radio label="auto">示意图</Radio>
<Radio label="canvas">画布视图</Radio>
<Radio label="img">进度图</Radio>
</RadioGroup>
<RadioGroup v-if="!showImg" :model-value="viewMode" size="small" @on-change="switchMode">
@@ -346,19 +374,10 @@ watch(() => route.query, () => load());
<div class="canvas img-box" v-if="vo && showImg">
<img :src="pngSrc" alt="流程示意图" />
</div>
<!-- 画布视图:bpmn-js 只读渲染(bpmn.io 水印为协议要求,勿删) -->
<div class="canvas bpmn-box" v-else-if="vo && showCanvas">
<div ref="bpmnHost" class="bpmn-host"></div>
<div class="bpmn-ops">
<Button size="small" @click="bpmnZoom(-0.1)">−</Button>
<Button size="small" @click="bpmnFit">复位</Button>
<Button size="small" @click="bpmnZoom(0.1)">+</Button>
</div>
</div>
<!-- 示意图:手写 SVG 自动布局 -->
<div class="canvas" v-else-if="vo">
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
xmlns="http://www.w3.org/2000/svg" ref="svgRef"
xmlns="http://www.w3.org/2000/svg" ref="svgRef" :style="svgStyle"
style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif">
<defs>
<marker id="flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
@@ -421,13 +440,15 @@ watch(() => route.query, () => load());
fill="#f0f9ff"
:stroke="STATE_STROKE[nodeState(n) || ''] || '#13a8a8'"
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
<text :x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 25 : 27)"
text-anchor="middle" fill="#13a8a8" font-size="13" font-weight="600">
{{ trunc(n.label, 10) }}
{{ trunc(n.label, 18) }}
</text>
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
text-anchor="middle" fill="#808695" font-size="11">
{{ trunc(n.subLabel, 15) }}
<text v-if="n.subLabel" text-anchor="middle" fill="#808695" font-size="11">
<tspan v-for="(ln, i) in nameLines(n)" :key="i"
:x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 44 + i * 17 : 50)">{{ ln }}</tspan>
</text>
</g>
<!-- APPROVAL:圆角卡片两行文本 -->
@@ -437,18 +458,29 @@ watch(() => route.query, () => load());
rx="8"
:class="{ 'node-active': nodeState(n) === 'active' }"
:fill="nodeState(n) === 'rejected' ? '#fff1f0' : '#fff'"
:stroke="STATE_STROKE[nodeState(n) || ''] || '#2d8cf0'"
:style="{ stroke: STATE_STROKE[nodeState(n) || ''] || 'var(--brand)' }"
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
<text :x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 25 : 27)"
text-anchor="middle" fill="#17233d" font-size="13" font-weight="600">
{{ trunc(n.label, 10) }}
{{ trunc(n.label, 18) }}
</text>
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
text-anchor="middle" fill="#808695" font-size="11">
{{ trunc(n.subLabel, 15) }}
<text v-if="n.subLabel" text-anchor="middle" fill="#808695" font-size="11">
<tspan v-for="(ln, i) in nameLines(n)" :key="i"
:x="pos(n).x + pos(n).w / 2"
:y="pos(n).y + (nameLines(n).length >= 2 ? 44 + i * 17 : 50)">{{ ln }}</tspan>
</text>
</g>
</template>
<!-- 图例(instance 口径四态,与 PNG 图例同口径同配色,画布底部左侧) -->
<g v-if="legendItems.length">
<g v-for="it in legendItems" :key="'legend-' + it.state">
<rect :x="it.x" :y="it.y - 10" width="18" height="12" rx="6"
fill="#fff" :stroke="it.stroke" stroke-width="1.4" />
<text :x="it.x + 24" :y="it.y + 2" fill="#17233d" font-size="12">{{ it.label }}</text>
</g>
</g>
</svg>
</div>
<div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div>
@@ -463,7 +495,9 @@ watch(() => route.query, () => load());
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.title { font-size: 16px; font-weight: 600; }
.muted { color: #999; font-size: 12px; }
.canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
/* 纵向滚动为主(竖排长图);>3 分支缩放 <0.55 时 svg min-width 兜底转横向滚动(方案 §2.6) */
.canvas { overflow: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
.canvas svg { display: block; margin: 0 auto; }
.img-box { display: flex; justify-content: center; padding: 12px; }
.img-box img { max-width: 100%; height: auto; }
.empty { padding: 40px; text-align: center; }
@@ -473,17 +507,4 @@ watch(() => route.query, () => load());
0%, 100% { filter: none; }
50% { filter: drop-shadow(0 0 6px rgba(237, 64, 20, 0.8)); }
}
/* 画布视图 */
.bpmn-box { position: relative; }
.bpmn-host { height: 560px; }
.bpmn-ops { position: absolute; top: 10px; right: 12px; display: flex; gap: 6px; z-index: 5; }
/* bpmn.io 水印按协议保留 */
.bpmn-box :deep(.bjs-powered-by) { display: block; }
/* 运行时状态着色(instance 口径 marker:done/active/rejected/pending) */
.bpmn-box :deep(.fc-done > .djs-visual > :nth-child(1)) { stroke: #19be6b !important; }
.bpmn-box :deep(.fc-active > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; stroke-width: 2.5px !important; }
.bpmn-box :deep(.fc-rejected > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; fill: #fff1f0 !important; }
.bpmn-box :deep(.fc-pending > .djs-visual > :nth-child(1)) { stroke: #b7bcc4 !important; }
.bpmn-box :deep(.fc-done > .djs-visual > .djs-label) { fill: #4f5b66; }
</style>
+19 -99
View File
@@ -1,11 +1,16 @@
<script setup lang="ts">
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
// 办件详情(管理面,2026-10-11 整合):正文升级为与嵌入页 /embed/instance 同源同款——共用
// InstanceBody.vue(full 口径):流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号,
// 仅管理面显示、嵌入页不外露)+ 基本信息(含业务详情链接)+ 办理进度三视图 + 折叠块
// (流程示意图/业务信息/业务表单数据)。动作文案统一为展示层映射(已同意/待审核…),
// 全局 TASK_STATUS 码表不动。
// 轨迹两档显示(2026-09-18):mode=user(人员,缺省)|dept(科室),写进 query 便于分享/刷新保持,
// 并随"流程示意图"跳转带给图示页(两边口径一致)。
// 并随"流程示意图"跳转带给图示页(两边口径一致)——口径切换由组件 emit,本页落 URL。
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import { Tag } from "view-ui-plus";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
@@ -13,7 +18,7 @@ const router = useRouter();
const loading = ref(false);
const detail = ref<any>(null);
/** 显示口径:dept=按科室 / user=按人员(不传=人员) */
/** 显示口径:dept=按科室 / user=按人员(不传=人员);组件 emit → 本页写 query 持久化 */
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
function switchMode(val: string) {
@@ -21,43 +26,6 @@ function switchMode(val: string) {
router.replace({ query: { ...route.query, mode: val } });
}
const snapshotRows = computed(() => {
const raw = detail.value?.bizSnapshot;
if (!raw) return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ k: "摘要", v: String(raw) }];
}
if (obj && typeof obj === "object") {
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
}
return [];
});
/** 业务表单数据(§3.4 只读渲染;解析失败降级一行原文;无 formData 时整块隐藏) */
const formRows = computed(() => {
const raw = detail.value?.formDataJson;
if (raw === null || raw === undefined || raw === "") return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ k: "表单原文", v: String(raw) }];
}
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return Object.entries(obj).map(([k, v]) => ({
k,
v: v == null ? "-"
: Array.isArray(v)
? (v.map((item: any) => item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item)).join("、") || "-")
: (typeof v === "object" ? JSON.stringify(v) : String(v)),
}));
}
return [{ k: "表单原文", v: String(raw) }];
});
const branchText = computed(() => {
const branches = detail.value?.branches || [];
if (!branches.length) return "";
@@ -67,7 +35,7 @@ const branchText = computed(() => {
/** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。
* img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下
* 裸 /api 会落到网关根导致 404。mode 跟随本页选择,缩略图与轨迹口径一致。 */
* 裸 /api 会落到网关根导致 404。mode 跟随本页口径,缩略图与轨迹一致(src 随口径切换重新 load)。 */
const diagramSrc = computed(
() => `${config.serviceHost}/api/flow/diagram.png?instanceId=${route.params.instanceId}&mode=${mode.value}`,
);
@@ -82,7 +50,7 @@ function statusName(s: number) {
}
function statusColor(s: number) {
return s === 10 ? "blue" : s === 20 ? "green" : s === 30 ? "red" : "gray";
return INSTANCE_STATUS_COLOR[s] || "gray";
}
onMounted(async () => {
@@ -104,54 +72,12 @@ onMounted(async () => {
<span class="muted" v-if="branchText">{{ branchText }}</span>
</div>
<Card class="block" :bordered="false" dis-hover>
<template #title>基本信息</template>
<div class="grid">
<div class="row"><span class="k">流程</span><span>{{ detail.defCode }} v{{ detail.defVersion }}</span></div>
<div class="row"><span class="k">提交人</span><span>{{ detail.submitterName }}</span></div>
<div class="row"><span class="k">提交时间</span><span>{{ (detail.submitTime || "").replace("T", " ") }}</span></div>
<div class="row"><span class="k">办结时间</span><span>{{ (detail.finishTime || "").replace("T", " ") || "-" }}</span></div>
<div class="row" v-if="detail.bizUrl"><span class="k">业务详情</span>
<a :href="detail.bizUrl" target="_blank">查看业务详情</a></div>
</div>
<template v-if="snapshotRows.length">
<Divider size="small" />
<div class="snapshot">
<div v-for="row in snapshotRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
<InstanceBody full :detail="detail" :diagram-src="diagramSrc" :initial-mode="mode" @mode-change="switchMode">
<!-- 折叠块 summary 内的入口:stop 防止点链接同时触发 details 折叠 -->
<template #diagram-link>
<a class="fold-link" @click.stop.prevent="toDiagram()">查看大图</a>
</template>
<template v-if="formRows.length">
<Divider size="small">业务表单数据</Divider>
<div class="snapshot">
<div v-for="row in formRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
</template>
</Card>
<Card class="block" :bordered="false" dis-hover>
<template #title>
流程示意图
<a class="diagram-more" @click="toDiagram()">查看大图</a>
</template>
<img class="diagram-img" :src="diagramSrc" alt="流程示意图" />
</Card>
<Card class="block" :bordered="false" dis-hover>
<template #title>
流程轨迹
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
<Radio label="user">按人员</Radio>
<Radio label="dept">按科室</Radio>
</RadioGroup>
</template>
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
</Card>
</InstanceBody>
</div>
<div v-else class="page muted">加载中…</div>
</template>
@@ -161,13 +87,7 @@ onMounted(async () => {
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.head a { cursor: pointer; }
.title { font-size: 16px; font-weight: 600; }
.block { margin-bottom: 12px; }
.grid .row { display: flex; padding: 3px 0; }
.k { width: 120px; color: #888; flex-shrink: 0; }
.v { white-space: pre-wrap; word-break: break-all; }
.muted { color: #999; }
.diagram-box { display: flex; justify-content: center; }
.diagram-img { max-width: 100%; }
.diagram-more { float: right; font-size: 12px; font-weight: normal; margin-right: 16px; }
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
/* 折叠块 summary 右侧入口:summary 本体 600 粗体,链接回常规 */
.fold-link { font-size: 12px; font-weight: normal; }
</style>
-85
View File
@@ -1,85 +0,0 @@
<script setup lang="ts">
// 我的办件(提交人视角):列表 + 撤回/催办/详情
import { ref } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { OperatorEnum } from "@/types";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import Utils from "@/utils";
const router = useRouter();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
const dataParams = ref<{ status?: number }>({ status: undefined });
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "业务类型", key: "bizType", width: 110 },
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
{ title: "当前节点", key: "currentNodes", width: 150 },
{ title: "提交时间", width: 170, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
{ title: "办结时间", width: 170, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 180, align: "center", fixed: "right", render: (h: any, p: any) => {
const btns = [Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=101`))];
if (p.row.status === 10) {
btns.push(Utils.operatingHandle(h, "撤回", "icon-yaohao", () => onWithdraw(p.row)));
btns.push(Utils.operatingHandle(h, "催办", "icon-fasong", () => onUrge(p.row)));
}
return btns;
} },
];
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
const tableApi = (p: { pageIndex: number; pageSize: number }) =>
flowApi.my({ status: dataParams.value.status || undefined, page: p.pageIndex, size: p.pageSize });
function onSearch() {
tableRef.value?.refreshData();
}
async function onWithdraw(row: any) {
await MyConfirm({
title: "撤回",
msg: `确定撤回办件「${row.bizTitle}」吗?`,
info: "撤回后流程终止,不可恢复",
async onOk() {
try {
await flowApi.withdraw(row.instanceId).send();
Message.success("已撤回");
tableRef.value?.reloadData(OperatorEnum.edit);
} catch (e: any) {
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
}
},
});
}
async function onUrge(row: any) {
try {
await flowApi.urge(row.instanceId).send();
Message.success("已催办");
} catch (e: any) {
Message.error(e?.message || "催办失败");
}
}
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="search-wrap fix">
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
<Option :value="10" label="审批中" />
<Option :value="20" label="已完成" />
<Option :value="30" label="已拒绝" />
<Option :value="40" label="已撤回" />
</Select>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
</div>
</div>
</template>
+260 -53
View File
@@ -1,17 +1,32 @@
<script setup lang="ts">
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
// 办理页(概览 §9.4):biz_snapshot 业务信息区块(只认 summaryFields)+ "查看业务详情"跳 bizUrl
// + 历史意见 + 同意/拒绝/退回/移交。业务表单数据(formDataJson)不在办理页渲染——托管模式下它是
// 条件路由入参,只进管理面办件详情。
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
import { ref, computed, onMounted } from "vue";
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
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";
const route = useRoute();
const router = useRouter();
const taskId = computed(() => String(route.params.taskId || ""));
/**
* 去壳态(embed=1,v2.1 完整办理内嵌):同一个 /task/:taskId 组件、同一套 CAS 用户面接口,
* 只把"办完后跳工作台""跳流程图""看业务详情"这三个会离开当前壳的动作为父页面接管——
* iframe 里跳出工作台等于把 flow-center 菜单塞进子应用弹窗。
*/
const isBare = computed(() => isBareTaskRoute(route.path, route.query.embed));
/** bare 下办理成功后的结果条文案(不跳走,父页面自己决定关不关) */
const doneTip = ref("");
/** 办理失败的内联提示:全局 toast 只说服务端给的那句,这里补「哪个动作没生效 + 现在该怎么办」 */
const failTip = ref("");
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
@@ -37,6 +52,18 @@ const showTransfer = ref(false);
const transferUser = ref("");
const transferName = ref("");
/**
* 业务信息行:唯一内容 = 约定键 bizSnapshot.summaryFields(子应用决定展示哪些字段、用什么中文 label,
* 口径同 InstanceBody「业务信息」)。不再逐字平铺 bizSnapshot 原始键——那会把 title/summaryFields 这类
* 英文 key 直接摆给办理人看,对象值还会渲染成 [object Object]。
* 无 summaryFields(老数据/未接入约定的子应用)只回退已知的人类可读键,未知键一律不外露。
*/
const SUMMARY_FALLBACK_LABELS: Array<[string, string]> = [
["title", "标题"],
["summary", "摘要"],
["attachmentNames", "附件"],
];
const snapshotRows = computed(() => {
const raw = detail.value?.bizSnapshot;
if (!raw) return [];
@@ -46,28 +73,25 @@ const snapshotRows = computed(() => {
} catch {
return [{ k: "摘要", v: String(raw) }];
}
if (obj && typeof obj === "object") {
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
if (!obj || typeof obj !== "object") return [];
const sf = obj.summaryFields;
if (Array.isArray(sf) && sf.length) {
return sf
.filter((x: any) => x && x.label !== null && x.label !== undefined)
.slice(0, 6)
.map((x: any) => ({
k: String(x.label),
v: Array.isArray(x.value)
? (x.value.join("、") || "-")
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
}));
}
return [];
});
/** 业务表单数据(§3.4):formDataJson 逐键值只读渲染;数组值按文件名/项连接;解析失败降级一行原文 */
const formRows = computed(() => {
const raw = detail.value?.formDataJson;
if (raw === null || raw === undefined || raw === "") return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ k: "表单原文", v: String(raw) }];
}
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return Object.entries(obj).map(([k, v]) => ({ k, v: formValueText(v) }));
}
return [{ k: "表单原文", v: String(raw) }];
return SUMMARY_FALLBACK_LABELS
.filter(([key]) => obj[key] !== null && obj[key] !== undefined && obj[key] !== "")
.map(([key, label]) => ({ k: label, v: formValueText(obj[key]) }));
});
/** 值 → 展示文本(业务信息回退行共用):数组按文件名/项连接,对象降级 JSON */
function formValueText(v: any): string {
if (v === null || v === undefined) return "-";
if (Array.isArray(v)) {
@@ -90,10 +114,53 @@ async function load() {
}
}
/** 业务详情绝对地址(后端按 bizUrl + applyAppCode 查应用注册表 pc_url 拼出)。
* bizUrl 本身是裸相对路径,流内直开时没有宿主页面可借基址,直接 window.open 会落到本应用 origin 上。 */
const bizUrlAbs = computed(() => detail.value?.bizUrlAbs || "");
/** 入口可见性:bare 由父页面按自己路由接管(原样传裸路径),独立打开必须拿到绝对地址 */
const canOpenBiz = computed(() => !!detail.value?.bizUrl && (isBare.value || !!bizUrlAbs.value));
function openBizUrl() {
if (detail.value?.bizUrl) {
window.open(detail.value.bizUrl, "_blank");
if (isBare.value) {
postToParent({ type: "open-biz", bizId: detail.value?.bizId ?? null, bizUrl: detail.value?.bizUrl || "" });
return;
}
if (!bizUrlAbs.value) {
Message.warning("业务详情地址暂不可用,请在业务系统内查看");
return;
}
window.open(bizUrlAbs.value, "_blank");
}
/** 流程图仍要能看,但 bare 下不能用 router.push——/diagram 不是 /task/ 前缀,会在 iframe 里长出菜单壳 */
function openDiagram() {
const target = {
path: "/diagram",
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
};
if (isBare.value) {
// 新窗口整页打开:地址必须只剩一个 hash(把 resolve 出的 "#/diagram?…" 直接当 path
// 会在当前地址后面长出第二个 hash,页面原地不动且不报错——线上 P1-3 即此)
window.open(toAppHashUrl(router.resolve(target).href), "_blank");
return;
}
router.push(target);
}
/**
* 办理成功后的收尾:bare 回 postMessage(handled) + 结果条 + 重新拉详情(本任务转为不可办理态,
* 避免"再点一次"),非 bare 保持原样跳工作台。
*/
async function afterHandled(action: FlowEmbedAction, tip: string) {
if (!isBare.value) {
router.push("/workbench");
return;
}
postToParent({ type: "handled", taskId: taskId.value, action });
doneTip.value = tip;
opinion.value = "";
chosenEdge.value = "";
await load();
}
async function submit(approved: boolean) {
@@ -102,6 +169,8 @@ async function submit(approved: boolean) {
return;
}
submitting.value = true;
failTip.value = "";
const label = approved ? "同意" : "拒绝";
try {
if (approved) {
// 多出口节点必须带所选出口
@@ -113,27 +182,47 @@ async function submit(approved: boolean) {
}
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined, edgeName).send();
Message.success("已同意");
await afterHandled("approve", "已同意,本任务办理完成");
} else {
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
Message.success("已拒绝");
await afterHandled("reject", "已拒绝,本任务办理完成");
}
router.push("/workbench");
} catch (e: any) {
// 失败就是失败:不跳走、不清意见、不写成功文案;服务端事务已回滚,任务仍是待办
failTip.value = flowFailTip(label, e);
} finally {
submitting.value = false;
}
}
/**
* 二次确认:独立打开用 Modal.confirm(原生体验);bare 态一律行内确认。
* iframe 内弹 Modal 会在父页面弹窗之上再叠一层遮罩——父页遮罩盖不到 iframe,视觉上是"弹窗套弹窗",
* 且弹出位置只在本页顶部(长表单下离按钮很远)。行内确认条没有这个问题。
*/
const confirming = ref(false);
function confirmApprove() {
if (outlets.value.length > 1 && !chosenEdge.value) {
Message.warning("该节点有多个办理去向,请先选择");
return;
}
if (isBare.value) {
confirming.value = true;
return;
}
Modal.confirm({
title: "同意该任务?",
onOk: () => submit(true),
});
}
function doApprove() {
confirming.value = false;
submit(true);
}
// ===== 退回 =====
function openReturn() {
const targets = detail.value?.returnTargets || [];
@@ -143,16 +232,18 @@ function openReturn() {
function doReturn() {
submitting.value = true;
failTip.value = "";
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)
.send()
.then(() => {
.then(async () => {
Message.success("已退回");
showReturn.value = false;
router.push("/workbench");
await afterHandled("return", "已退回,目标节点重新生成待办");
})
.catch((e: any) => { failTip.value = flowFailTip("退回", e); })
.finally(() => { submitting.value = false; });
}
@@ -164,22 +255,63 @@ async function doTransfer() {
return;
}
submitting.value = true;
failTip.value = "";
try {
// 姓名由后端 AssigneeResolver 严格解析(查不到会拒绝,前端不信任输入)
await flowApi.transfer(myTaskId.value, uid, opinion.value.trim() || undefined).send();
Message.success("已移交");
showTransfer.value = false;
router.push("/workbench");
await afterHandled("transfer", "已移交,本任务由接手人办理");
} catch (e: any) {
failTip.value = flowFailTip("移交", e);
} finally {
submitting.value = false;
}
}
onMounted(load);
// ===== 去壳态高度上报(父页面 iframe 自适应,避免固定高度里内滚两层) =====
const pageEl = ref<HTMLElement | null>(null);
let resizeObserver: ResizeObserver | null = null;
let lastReportedHeight = 0;
/** 内容变化(详情加载完、弹窗开合)都会触发;8px 死区防止亚像素抖动刷屏 */
function reportHeight() {
const el = pageEl.value;
if (!el) return;
const height = el.scrollHeight;
if (!height || Math.abs(height - lastReportedHeight) < 8) return;
lastReportedHeight = height;
postToParent({ type: "height", height });
}
watch(
() => detail.value,
async () => {
if (!isBare.value || !detail.value) return;
await nextTick();
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => reportHeight());
}
if (resizeObserver && pageEl.value) resizeObserver.observe(pageEl.value);
reportHeight();
}
);
onMounted(() => {
load();
window.addEventListener("resize", reportHeight);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", reportHeight);
resizeObserver?.disconnect();
resizeObserver = null;
});
</script>
<template>
<div class="page" v-if="detail">
<div ref="pageEl" class="page" :class="{ bare: isBare }" v-if="detail">
<Alert v-if="doneTip" type="success" show-icon class="done-tip">{{ doneTip }}</Alert>
<div class="head">
<span class="title">{{ detail.bizTitle }}</span>
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
@@ -189,31 +321,24 @@ onMounted(load);
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
</div>
<!-- 业务摘要区块(M1 办理页核心;富文本正文留在业务方,经 bizUrl 查看) -->
<!-- 业务信息区块(M1 办理页核心):只渲染子应用给的 summaryFields 中文标签行;
富文本正文留在业务方,经 bizUrl 查看 -->
<Card class="block" :bordered="false" dis-hover>
<template #title>业务摘要</template>
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供摘要)</div>
<template #title>业务信息</template>
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供业务信息)</div>
<div v-else class="snapshot">
<div v-for="row in snapshotRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
<Button v-if="detail.bizUrl" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
<Button v-if="canOpenBiz" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
查看业务详情
</Button>
</Card>
<!-- 业务表单数据(§3.4 只读渲染,非表单引擎;无 formData 时整块隐藏) -->
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
<template #title>业务表单数据</template>
<div class="snapshot">
<div v-for="row in formRows" :key="row.k" class="row">
<span class="k">{{ row.k }}</span>
<span class="v">{{ row.v }}</span>
</div>
</div>
</Card>
<!-- 业务表单数据不在此渲染(§3.4):formDataJson 是托管模式的条件路由入参(deptId/deptName 之类),
对办理人是内部噪音;原始表单只在管理面办件详情(InstanceBody full)看 -->
<!-- 办理区 -->
<Card class="block" :bordered="false" dis-hover>
@@ -228,17 +353,54 @@ onMounted(load);
</div>
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
placeholder="同意可选填意见;拒绝必须填写意见" />
<Alert v-if="failTip" type="error" show-icon class="fail-tip">
{{ failTip }}
<div class="muted">本次办理未生效,任务仍是你的待办;可稍后重试,或把页面提示与操作时间反馈给流程管理员。</div>
</Alert>
<div class="actions" v-if="myTaskId">
<template v-if="confirming">
<span class="confirm-tip">同意该任务?</span>
<Button type="primary" :loading="submitting" @click="doApprove">确定</Button>
<Button @click="confirming = false">取消</Button>
</template>
<template v-else>
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
<Button :loading="submitting" @click="openReturn">退回</Button>
<Button :loading="submitting" @click="showTransfer = true">移交</Button>
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
</template>
</div>
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</div>
<!-- bare 态退回/移交:行内面板(iframe 里再弹 Modal 会与父页面弹窗叠成三层) -->
<div v-if="isBare && showReturn" class="inline-panel">
<div class="modal-row">
<span class="modal-label">退回至:</span>
<Select v-model="returnTarget" size="small" style="width: 240px"
placeholder="缺省=最近一个已办节点">
<Option value="">(最近一个已办节点)</Option>
<Option v-for="t in detail.returnTargets || []" :key="t.nodeId" :value="t.nodeId"
:label="t.nodeName || t.nodeId" />
</Select>
<Button size="small" type="primary" :loading="submitting" @click="doReturn">确认退回</Button>
<Button size="small" @click="showReturn = false">取消</Button>
</div>
<div class="muted modal-tip">退回后目标节点重新生成待办;并行分支内节点不可作为退回目标。</div>
</div>
<div v-if="isBare && showTransfer" class="inline-panel">
<div class="modal-row">
<span class="modal-label">移交对象工号:</span>
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
<Button size="small" type="primary" :loading="submitting" @click="doTransfer">确认移交</Button>
<Button size="small" @click="showTransfer = false">取消</Button>
</div>
<div class="muted modal-tip">移交后由对方接手本任务办理;意见可在下方填写交接说明。</div>
</div>
</Card>
<!-- 流程轨迹 -->
<Card class="block" :bordered="false" dis-hover>
<!-- 流程轨迹(独立打开):中心侧唯一上下文,常显 -->
<Card v-if="!isBare" class="block" :bordered="false" dis-hover>
<template #title>
流程轨迹
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
@@ -247,12 +409,30 @@ onMounted(load);
</RadioGroup>
</template>
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
<Button size="small" ghost type="primary"
@click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
</Card>
<!-- 退回弹窗 -->
<Modal v-model="showReturn" title="退回" :width="480">
<!-- 流程轨迹(bare 态):父页面已有"审批进度"卡片,同一份 trace 不再整块铺开——
默认收起,需要核对明细(路由判断/抄送/退回留痕)时再展开 -->
<Card v-else class="block" :bordered="false" dis-hover>
<details class="trace-fold">
<summary>
<span class="trace-fold-title">流程轨迹({{ (detail.trace || []).length }} 条)</span>
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button"
@click.stop @on-change="switchMode">
<Radio label="user">按人员</Radio>
<Radio label="dept">按科室</Radio>
</RadioGroup>
</summary>
<div class="trace-fold-b">
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
<Button size="small" ghost type="primary" @click="openDiagram">查看流程图</Button>
</div>
</details>
</Card>
<!-- 退回弹窗(独立打开;bare 态走行内面板,见办理区) -->
<Modal v-if="!isBare" v-model="showReturn" title="退回" :width="480">
<div class="modal-row">
<span class="modal-label">退回至:</span>
<Select v-model="returnTarget" size="small" style="width: 240px"
@@ -269,8 +449,8 @@ onMounted(load);
</template>
</Modal>
<!-- 移交弹窗 -->
<Modal v-model="showTransfer" title="移交" :width="480">
<!-- 移交弹窗(独立打开;bare 态走行内面板) -->
<Modal v-if="!isBare" v-model="showTransfer" title="移交" :width="480">
<div class="modal-row">
<span class="modal-label">移交对象工号:</span>
<Input v-model="transferUser" size="small" style="width: 200px" placeholder="对方工号(数字)" />
@@ -287,6 +467,11 @@ onMounted(load);
<style scoped>
.page { padding: 12px 16px; }
/* 去壳态:iframe 里父页面已给了标题与外边距,这里只留最小呼吸(也稳住高度上报的抖动) */
.page.bare { padding: 8px 10px; }
.done-tip { margin-bottom: 10px; }
.fail-tip { margin: 10px 0 0; }
.fail-tip .muted { margin-top: 4px; font-size: 12px; }
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.title { font-size: 16px; font-weight: 600; }
.block { margin-bottom: 12px; }
@@ -294,7 +479,15 @@ onMounted(load);
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
.biz-link { margin-top: 8px; }
.actions { margin-top: 10px; display: flex; gap: 8px; }
.actions { margin-top: 10px; display: flex; gap: 8px; align-items: center; }
.confirm-tip { color: #515a6e; font-size: 13px; }
/* bare 态行内动作面板:与按钮行同宽,浅底描边,不遮挡父页面 */
.inline-panel {
margin-top: 10px; padding: 10px 12px;
border: 1px solid #e8eaec; border-radius: 6px; background: #fafbfc;
}
.inline-panel .modal-row { margin-bottom: 4px; }
.inline-panel .modal-row .ivu-btn { margin-left: 8px; }
.muted { color: #999; }
.outlet { margin-bottom: 8px; display: flex; align-items: center; }
.outlet-label { color: #515a6e; margin-right: 6px; font-size: 13px; }
@@ -305,4 +498,18 @@ onMounted(load);
.opinion { color: #666; }
.time { color: #aaa; font-size: 12px; }
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
/* bare 态轨迹折叠:summary 自绘(去默认三角),展开后内容与独立页一致 */
.trace-fold > summary {
cursor: pointer; list-style: none; display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600; color: #17233d;
}
.trace-fold > summary::-webkit-details-marker { display: none; }
.trace-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; font-size: 12px; transition: .2s; }
.trace-fold[open] > summary::after { transform: rotate(180deg); }
.trace-fold-title { position: relative; padding-left: 9px; }
.trace-fold-title::before {
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
}
.trace-fold-b { padding-top: 12px; }
</style>
+168 -46
View File
@@ -1,29 +1,42 @@
<script setup lang="ts">
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)。
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页状态各自独立
import { ref } from "vue";
// 工作台:我的待办 / 我的已办 / 我提交的 / 抄送我的(概览 §9.4 主路径查询,走 assignee 索引)。
// 四页签均有「按应用筛选」(办件定义归属 flw_def.app_key)。
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页与筛选状态各自独立
import { reactive, ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { Message, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
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 } from "@/api/module/flow";
import Utils from "@/utils";
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import { actionColumn } from "@/utils/tableActions";
type TabKey = "todo" | "done" | "cc" | "my";
const router = useRouter();
const tab = ref<"todo" | "done" | "cc">("todo");
const tab = ref<TabKey>("todo");
const drawerVisible = ref(false);
const drawerInstanceId = ref<string | number>("");
const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const ccTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
// tabs 页:表格高度在常规骨架上再扣 Tab 条
const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
const myTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const myParams = ref<{ status?: number }>({ status: undefined });
// 应用筛选(2026-10-12):后端按办件定义归属 flw_def.app_key 过滤;四页签筛选状态各自独立
const apps = ref<{ appKey: string; appName: string }[]>([]);
const filters = reactive<Record<TabKey, string>>({ todo: "", done: "", cc: "", my: "" });
const tableRefs: Record<TabKey, typeof todoTableRef> = {
todo: todoTableRef, done: doneTableRef, cc: ccTableRef, my: myTableRef,
};
const todoColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "办件标题", key: "bizTitle", minWidth: 160 },
{ title: "当前节点", key: "nodeName", width: 160 },
{ title: "提交人", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || p.row.assigneeName || "-") },
{ title: "到达时间", width: 170, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "办理", "icon-edit", () => goHandle(p.row)) },
{ title: "提交人", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || "-") },
{ title: "到达时间", width: 190, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
actionColumn([{ key: "handle", label: "办理", icon: "icon-edit", on: (r: any) => goHandle(r) }], { mode: "text", width: 100, fixed: "right" }),
];
const doneColumns = [
@@ -35,18 +48,18 @@ const doneColumns = [
title: "节点", minWidth: 150, render: (h: any, p: any) => {
const kids: any[] = [h("span", p.row.nodeName || p.row.nodeId || "-")];
if (p.row.doneCount > 1) {
kids.push(h("Tag", { color: "default", size: "small", style: "margin-left:6px" },
kids.push(h(Tag, { color: "default", size: "small", style: "margin-left:6px" },
() => `共办理 ${p.row.doneCount} 次`));
}
return h("div", kids);
},
},
{ title: "结果", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: TASK_STATUS_COLOR[p.row.status] || "default" }, () => TASK_STATUS[p.row.status] || p.row.status) },
{ title: "意见", key: "opinion", minWidth: 120 },
{ title: "办理时间", width: 170, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) },
{ title: "结果", width: 90, align: "center", render: (h: any, p: any) => h(Tag, { color: TASK_STATUS_COLOR[p.row.status] || "default" }, () => TASK_STATUS[p.row.status] || p.row.status) },
// 意见可能很长(退回理由/审批批语),单行截断 + hover 全文
{ title: "意见", key: "opinion", minWidth: 120, ellipsis: true, tooltip: true },
{ title: "办理时间", width: 190, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) },
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus) },
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) }], { mode: "text", width: 100, fixed: "right" }),
];
// 抄送行是 NOTIFY 知会(非任务):无 taskId/status,直接跳办件详情
@@ -54,53 +67,162 @@ const ccColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "抄送节点", key: "nodeName", width: 160 },
{ title: "抄送时间", width: 170, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) },
{ title: "抄送时间", width: 190, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) },
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus || "-") },
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.instanceId) }], { mode: "text", width: 100, fixed: "right" }),
];
// 我提交的(提交人视角):2026-10-12 由独立页 /my 并入本页签,口径不变
const myColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "业务类型", key: "bizType", width: 110 },
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
h(Tag, { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
{ title: "当前节点", key: "currentNodes", width: 150 },
{ title: "提交时间", width: 190, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
{ title: "办结时间", width: 190, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") },
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: "urge", label: "催办", icon: "icon-fasong", on: (r: any) => onUrge(r), disabledWhen: (r: any) => r.status !== 10 },
],
{ mode: "text", width: 180, fixed: "right" },
),
];
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize });
const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize });
const ccApi = (p: { pageIndex: number; pageSize: number }) => flowApi.ccMy({ page: p.pageIndex, size: p.pageSize });
const todoApi = (p: { pageIndex: number; pageSize: number }) =>
flowApi.todo({ appKey: filters.todo || undefined, page: p.pageIndex, size: p.pageSize });
const doneApi = (p: { pageIndex: number; pageSize: number }) =>
flowApi.done({ appKey: filters.done || undefined, page: p.pageIndex, size: p.pageSize });
const ccApi = (p: { pageIndex: number; pageSize: number }) =>
flowApi.ccMy({ appKey: filters.cc || undefined, page: p.pageIndex, size: p.pageSize });
const myApi = (p: { pageIndex: number; pageSize: number }) =>
flowApi.my({ status: myParams.value.status || undefined, appKey: filters.my || undefined, page: p.pageIndex, size: p.pageSize });
function onMySearch() {
myTableRef.value?.refreshData();
}
/** 应用下拉数据源:应用设置白名单(flw_app enabled=1),失败降级为空下拉 */
async function loadApps() {
try {
const rows: any[] = (await flowApi.appList().send()) || [];
apps.value = rows.filter((r) => r.enabled === 1)
.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
} catch {
apps.value = [];
}
}
/** 筛选变更:回到第一页重查;未加载过的页签标记为已加载,避免切页签时重复请求 */
function onFilterChange(name: TabKey) {
loaded[name] = true;
tableRefs[name].value?.refreshData();
}
async function onWithdraw(row: any) {
await MyConfirm({
title: "撤回",
msg: `确定撤回办件「${row.bizTitle}」吗?`,
info: "撤回后流程终止,不可恢复",
async onOk() {
try {
await flowApi.withdraw(row.instanceId).send();
Message.success("已撤回");
myTableRef.value?.reloadData(OperatorEnum.edit);
} catch (e: any) {
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
}
},
});
}
async function onUrge(row: any) {
try {
await flowApi.urge(row.instanceId).send();
Message.success("已催办");
} catch (e: any) {
Message.error(e?.message || "催办失败");
}
}
function goHandle(row: any) {
router.push(`/task/${row.taskId}?pid=100`);
}
// 两个内容区 v-show 共存,已办/抄送首次切换才拉取(保持原惰性加载行为)
const doneLoaded = ref(false);
const ccLoaded = ref(false);
function switchTab(name: string) {
tab.value = name as any;
if (name === "done" && !doneLoaded.value) {
doneLoaded.value = true;
doneTableRef.value?.reloadData(OperatorEnum.sel);
}
if (name === "cc" && !ccLoaded.value) {
ccLoaded.value = true;
ccTableRef.value?.reloadData(OperatorEnum.sel);
}
function openDetail(instanceId: string | number) {
drawerInstanceId.value = instanceId;
drawerVisible.value = true;
}
// 各内容区 v-show 共存,非首个页签首次切换才拉取(保持原惰性加载行为)
const loaded = reactive<Record<TabKey, boolean>>({ todo: true, done: false, cc: false, my: false });
function switchTab(name: string) {
tab.value = name as TabKey;
if (loaded[name as TabKey]) return;
loaded[name as TabKey] = true;
tableRefs[name as TabKey].value?.reloadData(OperatorEnum.sel);
}
onMounted(loadApps);
</script>
<template>
<div class="tabs-wrapper">
<Tabs type="card" :value="tab" @on-click="switchTab">
<div class="tabs-wrapper yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" :value="tab" @on-click="switchTab">
<TabPane label="我的待办" name="todo" />
<TabPane label="我的已办" name="done" />
<TabPane label="我提交的" name="my" />
<TabPane label="抄送我的" name="cc" />
</Tabs>
<div class="main-container" v-show="tab === 'todo'">
<TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" :tableHeight="tableHeight" />
<div class="tab-body" v-show="tab === 'todo'">
<div class="search-wrap fix">
<Select v-model="filters.todo" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
@on-change="onFilterChange('todo')">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
<div class="main-container" v-show="tab === 'done'">
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
<TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" />
</div>
<div class="main-container" v-show="tab === 'cc'">
<TablePageAlovaNew ref="ccTableRef" :dataApi="ccApi" :columns="ccColumns" :tableHeight="tableHeight" :autoLoad="false" />
<div class="tab-body" v-show="tab === 'done'">
<div class="search-wrap fix">
<Select v-model="filters.done" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
@on-change="onFilterChange('done')">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :autoLoad="false" />
</div>
<div class="tab-body" v-show="tab === 'my'">
<div class="search-wrap fix">
<Select v-model="myParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onMySearch">
<Option :value="10" label="审批中" />
<Option :value="20" label="已完成" />
<Option :value="30" label="已拒绝" />
<Option :value="40" label="已撤回" />
</Select>
<Select v-model="filters.my" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
@on-change="onFilterChange('my')">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
<TablePageAlovaNew ref="myTableRef" :dataApi="myApi" :columns="myColumns" :autoLoad="false" />
</div>
<div class="tab-body" v-show="tab === 'cc'">
<div class="search-wrap fix">
<Select v-model="filters.cc" class="search-wrap_sel" clearable filterable placeholder="按应用筛选"
@on-change="onFilterChange('cc')">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
<TablePageAlovaNew ref="ccTableRef" :dataApi="ccApi" :columns="ccColumns" :autoLoad="false" />
</div>
</div>
<InstanceDrawer v-model="drawerVisible" :instance-id="drawerInstanceId" />
</div>
</template>
+572
View File
@@ -0,0 +1,572 @@
<script setup lang="ts">
// 应用设置(2026-10-10 四议题):tab1 应用管理(flw_app 白名单 + 学校池同步 + 开关);
// tab2 科室管理(flw_dept 组+成员模型,取代旧「办理人映射」页——deptKey=本地自增 id,
// 切源后旧流程定义引用的 UserBase 组 id 需重建科室后重新配置)。
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { Message, Tag, Button } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const tab = ref("apps");
const router = useRouter();
// ==================== tab1 应用管理 ====================
const loading = ref(false);
const rows = ref<any[]>([]);
const searchKey = ref("");
const regStats = ref<Record<string, number>>({});
const appRegionRef = ref<HTMLElement | null>(null);
const appH = useElementHeight(appRegionRef);
// 搜索过滤(应用名/appKey 子串,纯前端)
const filteredRows = () => {
const k = searchKey.value.trim().toLowerCase();
if (!k) return rows.value;
return rows.value.filter((r: any) =>
String(r.appName || "").toLowerCase().includes(k) || String(r.appKey || "").includes(k));
};
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "应用", key: "appName", minWidth: 200 },
{ title: "appKey", key: "appKey", width: 120, align: "center" },
{
title: "回调注册数",
width: 110,
align: "center",
render: (h: any, p: any) => h("span", String(regStats.value[String(p.row.appKey)] ?? 0)),
},
{
title: "状态",
width: 90,
align: "center",
// 停用态走 iView 默认 Tag(不指定 color),避免自定义灰底与默认样式不一致
render: (h: any, p: any) =>
p.row.enabled === 1 ? h(Tag, { color: "green" }, () => "已开启") : h(Tag, () => "已停用"),
},
actionColumn(
[
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onToggle(r, false), disabledWhen: (r: any) => r.enabled !== 1, danger: true },
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onToggle(r, true), disabledWhen: (r: any) => r.enabled === 1 },
],
{ mode: "text", width: 120 },
),
];
async function load() {
loading.value = true;
try {
rows.value = ((await flowApi.appList().send()) as any[]) || [];
try {
regStats.value = ((await flowApi.appStats().send()) as any) || {};
} catch (e) {
regStats.value = {};
}
} finally {
loading.value = false;
}
}
async function onToggle(row: any, enabled: boolean) {
const doToggle = async () => {
row._switching = true;
try {
await flowApi.appEnable(row.id, enabled).send();
row.enabled = enabled ? 1 : 0;
Message.success(enabled ? `已开启「${row.appName}」` : `已停用「${row.appName}」`);
} catch (e: any) {
Message.error(e?.message || "操作失败");
load();
} finally {
row._switching = false;
}
};
if (enabled) {
doToggle();
return;
}
MyConfirm({
title: "停用应用",
msg: `确定停用「${row.appName}(${row.appKey})」吗?`,
info: "停用后新流程定义不能再归属到该应用(编辑器归属下拉与 defSave 校验拦截);存量定义不受影响",
async onOk() {
await doToggle();
},
});
}
// ==================== tab2 科室管理 ====================
const deptLoading = ref(false);
const depts = ref<any[]>([]);
const deptRegionRef = ref<HTMLElement | null>(null);
const deptH = useElementHeight(deptRegionRef);
const deptColumns = [
{ title: "序号", type: "index", width: 60, align: "center" },
{ title: "科室名", key: "deptName", minWidth: 160 },
{ title: "deptKey", key: "deptKey", width: 90, align: "center" },
{
title: "启用成员",
key: "count",
width: 90,
align: "center",
render: (h: any, p: any) => h("span", String(p.row.count)),
},
{
title: "状态",
width: 80,
align: "center",
render: (h: any, p: any) =>
h(Tag, { color: p.row.status === 0 ? "green" : "gray" },
() => (p.row.status === 0 ? "启用" : "停用")),
},
actionColumn(
[
{ key: "members", label: "成员", icon: "icon-yonghu", on: (r: any) => onMembers(r) },
{ key: "rename", label: "改名", icon: "icon-edit", on: (r: any) => onRename(r) },
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onDeptStatus(r, false), disabledWhen: (r: any) => r.status !== 0, danger: true },
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onDeptStatus(r, true), disabledWhen: (r: any) => r.status === 0 },
{ key: "del", label: "删除", icon: "icon-shanchu", on: (r: any) => onDelete(r), danger: true },
],
{ mode: "text", width: 180 },
),
];
async function loadDepts() {
deptLoading.value = true;
try {
depts.value = ((await flowApi.deptList().send()) as any[]) || [];
} finally {
deptLoading.value = false;
}
}
// ---- 新建科室 ----
const createVisible = ref(false);
const createSaving = ref(false);
const createName = ref("");
function onCreate() {
createName.value = "";
createVisible.value = true;
}
async function doCreate() {
if (!createName.value.trim()) {
Message.warning("科室名称不能为空");
return;
}
createSaving.value = true;
try {
await flowApi.deptCreate(createName.value.trim()).send();
Message.success("已创建");
createVisible.value = false;
loadDepts();
} catch (e: any) {
Message.error(e?.message || "创建失败");
} finally {
createSaving.value = false;
}
}
// ---- 改名 ----
const renameVisible = ref(false);
const renameSaving = ref(false);
const renameRow = ref<any>(null);
const renameName = ref("");
function onRename(row: any) {
renameRow.value = row;
renameName.value = row.deptName;
renameVisible.value = true;
}
async function doRename() {
if (!renameName.value.trim()) {
Message.warning("科室名称不能为空");
return;
}
renameSaving.value = true;
try {
await flowApi.deptRename(renameRow.value.deptKey, renameName.value.trim()).send();
Message.success("已改名");
renameVisible.value = false;
loadDepts();
} catch (e: any) {
Message.error(e?.message || "改名失败");
} finally {
renameSaving.value = false;
}
}
// ---- 停启用 / 删除 ----
function onDeptStatus(row: any, enabled: boolean) {
MyConfirm({
title: enabled ? "启用科室" : "停用科室",
msg: `确定${enabled ? "启用" : "停用"}「${row.deptName}」吗?`,
info: enabled
? "引用它的流程节点恢复解析到科室成员"
: "停用后引用它的流程定义不能发布;在途办件推进到该节点会报无可用办理人",
async onOk() {
await flowApi.deptStatus(row.deptKey, enabled).send();
Message.success("操作成功");
loadDepts();
},
});
}
function onDelete(row: any) {
MyConfirm({
title: "删除科室",
msg: `确定删除「${row.deptName}」及其全部成员吗?`,
info: "删除后引用该 deptKey 的流程定义节点将解析失败(发布校验拦截/办理报错),请先确认无流程引用",
async onOk() {
await flowApi.deptDelete(row.deptKey).send();
Message.success("已删除");
loadDepts();
},
});
}
// ---- 成员维护 ----
const memberVisible = ref(false);
const memberRow = ref<any>(null);
const members = ref<any[]>([]);
const memberLoading = ref(false);
function onMembers(row: any) {
memberRow.value = row;
memberVisible.value = true;
loadMembers(row);
}
async function loadMembers(row: any) {
memberLoading.value = true;
try {
members.value = ((await flowApi.deptMembers(row.deptKey).send()) as any[]) || [];
} catch (e: any) {
Message.error(e?.message || "成员加载失败");
members.value = [];
} finally {
memberLoading.value = false;
}
}
// 添加成员:规范选人组件(多选),提交即批量加入当前科室
const addMemberCheckRef = ref();
const memberSources = [
{
name: "基础平台",
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
},
];
function onAddMemberShow() {
addMemberCheckRef.value?.show();
}
async function onMemberAddSubmit(users: any[]) {
if (!users || !users.length) {
Message.warning("请先选择用户");
return;
}
const ids = users
.map((u: any) => Number(typeof u === "object" && u != null ? u.id : u))
.filter((n: number) => Number.isFinite(n) && n > 0);
if (!ids.length) {
Message.warning("请先选择用户");
return;
}
try {
const added: any = await flowApi.deptMemberAdd(memberRow.value.deptKey, ids).send();
Message.success(added > 0 ? `已添加 ${added} 人` : "所选成员均已存在");
loadMembers(memberRow.value);
loadDepts();
} catch (e: any) {
Message.error(e?.message || "添加失败");
}
}
function onMemberRemove(m: any) {
MyConfirm({
title: "移除成员",
msg: `确定把「${m.userName || "#" + m.userId}」移出「${memberRow.value.deptName}」吗?`,
info: "移除后引用该科室的流程节点解析时不再包含此人;在途待办不受影响",
async onOk() {
await flowApi.deptMemberRemove(m.id).send();
loadMembers(memberRow.value);
loadDepts();
},
});
}
// ---- 一键拉取教师组(初始化语义) ----
const groupVisible = ref(false);
const groupLoading = ref(false);
const groupImporting = ref(false);
const groupRows = ref<any[]>([]);
const groupColumns = [
{ title: "科室名称", key: "deptName", width: 160 },
{ title: "成员数", width: 90, align: "center", render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) },
{ title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") },
];
async function onGroupPreview() {
groupVisible.value = true;
groupLoading.value = true;
try {
groupRows.value = (await flowApi.deptGroupPreview().send()) || [];
} catch (e: any) {
Message.error(e?.message || "教师组拉取失败(UserBase 不可达?)");
groupVisible.value = false;
} finally {
groupLoading.value = false;
}
}
async function onGroupImport() {
groupImporting.value = true;
try {
const r: any = await flowApi.deptImportGroups().send();
Message.success(`导入完成:新建 ${r?.created ?? 0} / 刷新 ${r?.refreshed ?? 0} 个科室,新增成员 ${r?.users ?? 0} 人`);
groupVisible.value = false;
loadDepts();
} catch (e: any) {
Message.error(e?.message || "导入失败");
} finally {
groupImporting.value = false;
}
}
onMounted(() => {
load();
loadDepts();
});
// ==================== tab3 对接健康度(2026-10-15 议题二 P2) ====================
// 每应用一行六项检查 + 综合评分;数据全部来自已有接口(/callback/health 聚合),零新增表。
// 应用无注册行 = 未接入(由接入向导负责),不参与体检。
const healthLoading = ref(false);
const healthRows = ref<any[]>([]);
const healthColumns = [
{
title: "应用",
key: "appName",
minWidth: 180,
render: (h: any, p: any) =>
h("div", { class: "hc-app" }, [
h("span", { class: "hc-app-n" }, p.row.appName),
h("span", { class: "hc-app-k" }, p.row.appKey),
]),
},
{
title: "评分",
width: 96,
align: "center",
render: (h: any, p: any) =>
h("span", { class: ["hc-score", "is-" + p.row.level] }, String(p.row.score)),
},
{
title: "结论",
width: 90,
align: "center",
render: (h: any, p: any) => {
const map: Record<string, [string, string]> = {
ok: ["正常", "green"],
warn: ["关注", "orange"],
bad: ["异常", "red"],
};
const [txt, color] = map[p.row.level] || ["未知", "default"];
return h(Tag, { color }, () => txt);
},
},
{
title: "检查项",
minWidth: 460,
render: (h: any, p: any) =>
h("div", { class: "hc-checks" }, (p.row.checks || []).map((c: any) =>
h("span", {
class: ["hc-chip", "is-" + c.level],
attrs: { title: (c.advice ? c.advice + "\n" : "") + "实测:" + (c.value || "-") },
}, [
h("i", { class: ["hc-dot", "is-" + c.level] }),
h("span", { class: "hc-chip-n" }, c.name),
c.value ? h("em", { class: "hc-chip-v" }, c.value) : null,
]))),
},
{
title: "结论说明",
key: "summary",
minWidth: 220,
render: (h: any, p: any) => h("span", { class: "hc-sum" }, p.row.summary || "-"),
},
{
title: "操作",
width: 110,
align: "center",
render: (h: any, p: any) =>
h(Button, {
type: "text",
size: "small",
onClick: () => onViewLogs(p.row),
}, () => "查推送日志"),
},
];
function onViewLogs(row: any) {
router.push({ path: "/admin/callbacks", query: { appKey: row.appKey } });
}
async function loadHealth() {
healthLoading.value = true;
try {
healthRows.value = ((await (flowApi as any).callbackHealth().send()) as any[]) || [];
} catch (e: any) {
Message.error(e?.message || "健康度加载失败");
healthRows.value = [];
} finally {
healthLoading.value = false;
}
}
// 首次切到「对接健康度」时按需加载(避免进页面就多打一次聚合查询)
let healthLoaded = false;
watch(tab, (t) => {
if (t === "health" && !healthLoaded) {
healthLoaded = true;
loadHealth();
}
});
</script>
<template>
<div class="page-wrapper yh-fill">
<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="health"></TabPane>
</Tabs>
<!-- 应用管理 -->
<div class="tab-body" v-show="tab === 'apps'">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="load">刷新(同步学校池)</Button>
<Input class="fl" v-model="searchKey" placeholder="搜索应用名 / appKey" clearable
style="width: 220px; margin-left: 10px" suffix="ios-search" />
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
应用清单来自本校已开通应用(UserBase 学校组开通,含学校别名);开启后可用于流程归属与筛选。
新开通/改名在 UserBase 管理端操作后回本页刷新即可
</span>
</div>
<div class="table-region" ref="appRegionRef">
<Table :columns="columns" :data="filteredRows()" :loading="loading" class="link-table table-bottom-box"
:height="appH || undefined" no-data-text="本校暂无已开通应用(请先在用户中心开通)" />
</div>
</div>
<!-- 科室管理 -->
<div class="tab-body" v-show="tab === 'depts'">
<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">
<Table :columns="deptColumns" :data="depts" :loading="deptLoading" class="link-table table-bottom-box"
:height="deptH || undefined" no-data-text="暂无科室(可一键拉取教师组或手工新建)" />
</div>
</div>
<!-- 对接健康度(2026-10-15 P2):每应用一行六项检查,问题应用置顶 -->
<div class="tab-body" v-show="tab === 'health'">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="loadHealth">重新体检</Button>
<span class="fl" style="color: #808695; font-size: 12px; line-height: 40px; margin-left: 10px">
一屏定位「为什么收不到回调」:登记地址形态、base_path、推送成功率、挂起/死信、注册启用态。
数据来自回调注册与推送日志,无注册行的应用视为「未接入」,请走接入向导
</span>
</div>
<div class="table-region">
<Table :columns="healthColumns" :data="healthRows" :loading="healthLoading"
class="link-table table-bottom-box" no-data-text="暂无可体检应用(尚无任何回调注册,请先走接入向导)" />
</div>
</div>
<Modal v-model="createVisible" title="新建科室" :mask-closable="false">
<Input v-model="createName" placeholder="科室名称,如 人事处" :maxlength="30" />
<template #footer>
<Button @click="createVisible = false">取消</Button>
<Button type="primary" :loading="createSaving" @click="doCreate">创建</Button>
</template>
</Modal>
<Modal v-model="renameVisible" title="科室改名" :mask-closable="false">
<Input v-model="renameName" placeholder="科室名称" :maxlength="30" />
<template #footer>
<Button @click="renameVisible = false">取消</Button>
<Button type="primary" :loading="renameSaving" @click="doRename">保存</Button>
</template>
</Modal>
<Modal v-model="memberVisible" :title="`成员维护 - ${memberRow?.deptName || ''}`" :mask-closable="false" width="640">
<div class="fix" style="margin-bottom: 10px">
<Button class="fl" type="primary" icon="md-add" @click="onAddMemberShow">添加成员</Button>
</div>
<Table :columns="[
{ title: '用户ID', key: 'userId', width: 100 },
{ title: '姓名', key: 'userName' },
actionColumn([{ key: 'remove', label: '移除', icon: 'icon-shanchu', on: (r: any) => onMemberRemove(r), danger: true }], { mode: 'text', width: 90 }),
]" :data="members" :loading="memberLoading" size="small" max-height="360" no-data-text="暂无成员" />
<template #footer>
<Button @click="memberVisible = false">关闭</Button>
</template>
</Modal>
<Modal v-model="groupVisible" title="一键拉取教师组(预览)" :mask-closable="false" width="720">
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 8px">
以下为本校教师组容器下的科室及组内成员;确认后导入。已拉取过的科室(同名 UserBase 组)将刷新组名与成员集,不会重复建组
</p>
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" size="small" max-height="380"
no-data-text="未发现可拉取的教师组科室" />
<template #footer>
<Button @click="groupVisible = false">取消</Button>
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">导入</Button>
</template>
</Modal>
<!-- 添加成员选人弹窗(多选,与 Notice 设置中心同款组件) -->
<UserCheckModal ref="addMemberCheckRef" :sources="memberSources" @on-submit="onMemberAddSubmit" />
</div>
</div>
</template>
<style scoped>
/* 对接健康度面板:一屏一行,检查项紧凑成 chip */
.hc-app { display: flex; flex-direction: column; line-height: 1.35; }
.hc-app-n { color: #17233d; font-weight: 500; }
.hc-app-k { color: #98a1b3; font-size: 12px; }
.hc-score { display: inline-block; min-width: 34px; padding: 1px 6px; border-radius: 4px; font-weight: 600; font-variant-numeric: tabular-nums; }
.hc-score.is-ok { background: #e8f7ee; color: #19a35a; }
.hc-score.is-warn { background: #fff5e6; color: #d98106; }
.hc-score.is-bad { background: #fdecec; color: #e03e3e; }
.hc-checks { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0; }
.hc-chip { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; padding: 1px 7px; border-radius: 3px; font-size: 12px; line-height: 18px; background: #f4f6f9; color: #515a6e; }
.hc-chip.is-warn { background: #fff5e6; color: #b26a00; }
.hc-chip.is-bad { background: #fdecec; color: #c0392b; }
.hc-dot { width: 6px; height: 6px; border-radius: 50%; background: #19a35a; flex: none; }
.hc-dot.is-warn { background: #f0a020; }
.hc-dot.is-bad { background: #e03e3e; }
.hc-chip-n { flex: none; }
.hc-chip-v { font-style: normal; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.85; }
.hc-sum { color: #515a6e; font-size: 12px; }
</style>
+136 -26
View File
@@ -2,37 +2,53 @@
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
import { ref, onMounted } from "vue";
import { Message } from "view-ui-plus";
import { useRoute } from "vue-router";
import { Message, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { OperatorEnum } from "@/types";
import { flowApi } from "@/api/module/flow";
import Utils from "@/utils";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const LOG_STATUS: Record<number, string> = { 0: "待推/重试中", 1: "成功", 2: "死信" };
const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled", "flow.completed", "flow.rejected", "flow.withdrawn"];
const tab = ref("regs");
const loading = ref(false);
const route = useRoute();
// tabs 页高度:在常规骨架上再扣 Tab 条;日志页含搜索区+分页,挂起页含提示条
const regsTableHeight = ref(Utils.useTableHeight({ isTabs: true }));
const logTableHeight = ref(Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
const hungTableHeight = ref(Utils.useTableHeight({ isTabs: true, otherH: 46 }));
// 无分页 tab 的表格高度:实测各自 .table-region(flex:1),替代 useTableHeight 常量减法
const regsRegionRef = ref<HTMLElement | null>(null);
const hungRegionRef = ref<HTMLElement | null>(null);
const regsH = useElementHeight(regsRegionRef);
const hungH = useElementHeight(hungRegionRef);
// ---- 注册列表 ----
const regs = ref<any[]>([]);
// appKey 筛选(2026-10-15):健康度面板「查推送日志」跳转带入 query.appKey 预填
const regFilter = ref("");
const filteredRegs = () => {
const k = regFilter.value.trim().toLowerCase();
if (!k) return regs.value;
return regs.value.filter((r: any) => String(r.appKey || "").toLowerCase().includes(k));
};
const regColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "应用 appKey", key: "appKey", width: 110 },
{ title: "流程 GUID", key: "flowGuid", width: 210 },
{ title: "回调地址", key: "callbackUrl", minWidth: 240 },
{ title: "订阅事件", key: "eventTypes", width: 200, render: (h: any, p: any) => h("span", p.row.eventTypes || "全部") },
{ title: "状态", width: 80, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
{ title: "操作", width: 140, align: "center", fixed: "right", render: (h: any, p: any) => [
Utils.operatingHandle(h, p.row.status === 0 ? "停用" : "启用", p.row.status === 0 ? "icon-stop" : "icon-shiyong", () => onRegStatus(p.row, p.row.status === 0)),
Utils.operatingHandle(h, "重置secret", "icon-shezhi", () => onResetSecret(p.row)),
] },
{ title: "状态", width: 80, align: "center", render: (h: any, p: any) => h(Tag, { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
actionColumn(
[
{ key: "probe", label: "探测", icon: "icon-fasong", on: (r: any) => onProbe(r) },
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onRegStatus(r, true), disabledWhen: (r: any) => r.status !== 0, danger: true },
{ key: "enable", label: "启用", icon: "icon-shiyong", on: (r: any) => onRegStatus(r, false), disabledWhen: (r: any) => r.status === 0 },
{ key: "resetSecret", label: "重置secret", icon: "icon-shezhi", on: (r: any) => onResetSecret(r) },
],
{ mode: "text", width: 240, fixed: "right" },
),
];
// ---- 推送日志 ----
@@ -46,13 +62,32 @@ const logColumns = [
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
{ title: "状态", width: 110, align: "center", render: (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 2 ? "red" : "orange";
return h("Tag", { color: c }, () => LOG_STATUS[p.row.status]);
return h(Tag, { color: c }, () => LOG_STATUS[p.row.status]);
} },
{ title: "重试", key: "retryCount", width: 60, align: "center" },
{ title: "末次结果", key: "lastError", minWidth: 200, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
actionColumn([{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openLogDetail(r) }], { mode: "text", width: 100, fixed: "right" }),
];
// ---- 推送日志详情抽屉(2026-10-11:完整请求体/响应摘要/重试链可视) ----
const logDetail = ref(false);
const logDetailRow = ref<any>(null);
function openLogDetail(row: any) {
logDetailRow.value = row;
logDetail.value = true;
}
function prettyJson(raw: any): string {
if (raw === null || raw === undefined || raw === "") return "-";
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch (e) {
return String(raw);
}
}
// ---- 挂起列表(持续重试中:待推且重试达阈值;人工判定无意义 → 标记死信止血) ----
const hungs = ref<any[]>([]);
const hungLoading = ref(false);
@@ -61,11 +96,10 @@ const hungColumns = [
{ title: "事件", key: "event", width: 150 },
{ title: "办件", key: "instanceId", width: 80 },
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
{ title: "重试", key: "retryCount", width: 70, align: "center", render: (h: any, p: any) => h("Tag", { color: "orange" }, () => `${p.row.retryCount} 次`) },
{ title: "重试", key: "retryCount", width: 70, align: "center", render: (h: any, p: any) => h(Tag, { color: "orange" }, () => `${p.row.retryCount} 次`) },
{ title: "末次结果", key: "lastError", minWidth: 220, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 120, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "标记死信", "icon-ashbin", () => onMarkDead(p.row)) },
actionColumn([{ key: "markDead", label: "标记死信", icon: "icon-ashbin", on: (r: any) => onMarkDead(r), danger: true }], { mode: "text", width: 120, fixed: "right" }),
];
async function loadRegs() {
@@ -128,6 +162,22 @@ function onResetSecret(row: any) {
});
}
// ---- 回调探测(2026-10-11):签一条 flow.ping 走真实管道,结果弹窗展示并落推送日志 ----
const probeResult = ref<any>(null);
const probeShowing = ref(false);
const probingId = ref<number | null>(null);
async function onProbe(row: any) {
probingId.value = row.id;
try {
const d: any = await flowApi.callbackProbe(row.id).send();
probeResult.value = { ...d, appKey: row.appKey, callbackUrl: row.callbackUrl };
probeShowing.value = true;
} finally {
probingId.value = null;
}
}
function onMarkDead(row: any) {
MyConfirm({
title: "标记死信",
@@ -167,25 +217,43 @@ function switchTab(name: string) {
}
}
onMounted(loadRegs);
onMounted(() => {
// 从健康度面板跳转时带 appKey,预填注册列表筛选并切到「注册列表」tab
const q = route.query.appKey;
if (q) {
regFilter.value = String(q);
tab.value = "regs";
}
loadRegs();
});
</script>
<template>
<div class="tabs-wrapper">
<Tabs type="card" :value="tab" @on-click="switchTab">
<div class="tabs-wrapper yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" :value="tab" @on-click="switchTab">
<TabPane label="回调注册" name="regs" />
<TabPane label="推送日志" name="logs" />
<TabPane label="挂起中" name="hung" />
</Tabs>
<!-- 回调注册:无分页 -->
<div class="main-container" v-show="tab === 'regs'">
<Table :columns="regColumns" :data="regs" :loading="loading" class="link-table table-bottom-box"
:height="regsTableHeight" no-data-text="暂无数据" />
<div class="tab-body" v-show="tab === 'regs'">
<div class="fix mb-12">
<Input class="fl" v-model="regFilter" placeholder="按 appKey 筛选" clearable
style="width: 220px" suffix="ios-search" />
<span class="fl" style="color: #808695; font-size: 12px; line-height: 32px; margin-left: 10px">
每行一个 (应用 × 流程) 登记;「探测」同步发一条 flow.ping 验证可达与验签
</span>
</div>
<div class="table-region" ref="regsRegionRef">
<Table :columns="regColumns" :data="filteredRegs()" :loading="loading" class="link-table table-bottom-box"
:height="regsH || undefined" no-data-text="暂无数据" />
</div>
</div>
<!-- 推送日志:搜索区 + 分页,重推选中落分页栏左侧批量操作位 -->
<div class="main-container" v-show="tab === 'logs'">
<div class="tab-body" v-show="tab === 'logs'">
<div class="search-wrap fix">
<Select v-model="logFilter.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onLogSearch">
<Option :value="0" label="待推/重试中" />
@@ -198,8 +266,7 @@ onMounted(loadRegs);
<Input class="fr" v-model="logFilter.instanceId" placeholder="办件ID" search suffix="ios-search"
style="width: 200px" @on-search="onLogSearch" @on-clear="onLogSearch" />
</div>
<TablePageAlovaNew ref="logTableRef" :dataApi="logApi" :columns="logColumns"
:tableHeight="logTableHeight" :autoLoad="false">
<TablePageAlovaNew ref="logTableRef" :dataApi="logApi" :columns="logColumns" :autoLoad="false">
<template #default="{ checkList }">
<Button size="small" type="primary" :disabled="!checkList.length" @click="onRepush(checkList)">重推选中</Button>
</template>
@@ -207,16 +274,59 @@ onMounted(loadRegs);
</div>
<!-- 挂起中:无分页 -->
<div class="main-container" v-show="tab === 'hung'">
<div class="tab-body" v-show="tab === 'hung'">
<div class="tip">
持续重试中的事件(消费方疑似停机;消费方恢复后自动追平,无需干预)。仅当人工判定事件已无意义时才标记死信。
</div>
<div class="table-region" ref="hungRegionRef">
<Table :columns="hungColumns" :data="hungs" :loading="hungLoading" class="link-table table-bottom-box"
:height="hungTableHeight" no-data-text="暂无数据" />
:height="hungH || undefined" no-data-text="暂无数据" />
</div>
</div>
</div>
<!-- 回调探测结果 -->
<Modal v-model="probeShowing" :title="`回调探测${probeResult?.success ? '成功' : '失败'}(${probeResult?.appKey})`"
:width="640" :footer-hide="true">
<div v-if="probeResult" class="probe-result">
<Alert :type="probeResult.success ? 'success' : 'error'" show-icon>
{{ probeResult.success ? "接收方可达且验签通过(HTTP 2xx)" : "探测失败:接收方不可达或验签被拒" }}
</Alert>
<div class="kv"><span class="k">回调地址</span><span class="v">{{ probeResult.callbackUrl }}</span></div>
<div class="kv"><span class="k">HTTP 状态</span><span class="v">{{ probeResult.httpStatus ?? "-" }}</span></div>
<div class="kv"><span class="k">耗时</span><span class="v">{{ probeResult.elapsedMs }} ms</span></div>
<div class="kv" v-if="probeResult.error"><span class="k">错误</span><span class="v text-error">{{ probeResult.error }}</span></div>
<div class="kv" v-if="probeResult.respSnippet">
<span class="k">响应摘要</span>
<pre class="v pre">{{ probeResult.respSnippet }}</pre>
</div>
<div class="tip" style="margin-top:10px">探测事件 flow.ping 已落「推送日志」(探测结果以接收方按规范记日志后 200 为准)</div>
</div>
</Modal>
<!-- 推送日志详情抽屉 -->
<Drawer :title="`推送详情:${logDetailRow?.event || ''}`" :value="logDetail" :width="560" class="log-detail-drawer">
<div v-if="logDetailRow" class="log-detail">
<div class="kv"><span class="k">事件ID</span><span class="v">{{ logDetailRow.eventId }}</span></div>
<div class="kv"><span class="k">办件 / biz</span><span class="v">{{ logDetailRow.instanceId }} / {{ logDetailRow.bizType }}/{{ logDetailRow.bizId }}</span></div>
<div class="kv"><span class="k">标题</span><span class="v">{{ logDetailRow.bizTitle || "-" }}</span></div>
<div class="kv"><span class="k">状态</span><span class="v">{{ LOG_STATUS[logDetailRow.status] }}</span></div>
<div class="kv"><span class="k">重试次数</span><span class="v">{{ logDetailRow.retryCount }}</span></div>
<div class="kv"><span class="k">末次 HTTP</span><span class="v">{{ logDetailRow.lastHttpStatus || "-" }}</span></div>
<div class="kv"><span class="k">末次错误</span><span class="v">{{ logDetailRow.lastError || "-" }}</span></div>
<div class="kv"><span class="k">推送时间</span><span class="v">{{ (logDetailRow.pushTime || "-").replace("T", " ") }}</span></div>
<div class="kv"><span class="k">下次重推</span><span class="v">{{ (logDetailRow.nextRetryTime || "-").replace("T", " ") }}</span></div>
<div class="kv"><span class="k">响应摘要</span><pre class="v pre">{{ logDetailRow.respBody || "-" }}</pre></div>
<div class="kv"><span class="k">请求体(完整快照)</span><pre class="v pre">{{ prettyJson(logDetailRow.payloadJson) }}</pre></div>
</div>
</Drawer>
</div>
</template>
<style scoped>
.tip { margin-bottom: 10px; padding: 6px 10px; background: #f8f8f9; border-radius: 4px; color: #515a6e; font-size: 12px; }
.probe-result .kv, .log-detail .kv { margin-bottom: 8px; font-size: 13px; }
.probe-result .k, .log-detail .k { display: inline-block; width: 120px; color: #808695; vertical-align: top; }
.probe-result .v, .log-detail .v { color: #515a6e; word-break: break-all; white-space: pre-wrap; }
.pre { margin: 0; padding: 8px; background: #f8f8f9; border-radius: 4px; max-height: 320px; overflow: auto; font-size: 12px; }
</style>
+472 -40
View File
@@ -2,68 +2,292 @@
// 流程定义管理:列表 + 发布/启用/停用/升版本/版本历史/图示/源码。
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程编辑器方案.md》);
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
import { ref, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { Message, Table, Tag, Tooltip } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
import { flowApi } from "@/api/module/flow";
import Utils from "@/utils";
import { actionColumn } from "@/utils/tableActions";
import { lineActionErrorDetail, runLineAction } from "@/utils/lineOffline";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const router = useRouter();
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
const loading = ref(false);
const rows = ref<any[]>([]);
// 线级启停在途标记(按 flowGuid):确认后的请求期间禁用入口并展示「处理中」
const lineBusyGuid = ref("");
const versionVisible = ref(false);
const versions = ref<any[]>([]);
// 源码抽屉(列表行已带回 contentJson,无需再请求)
const sourceVisible = ref(false);
const sourceText = ref("");
const sourceTitle = ref("");
// 操作按钮区 + 表格,无搜索、无分页
const tableHeight = ref(Utils.useTableHeight({ isOperat: true }));
// 操作按钮区 + 表格,无搜索、无分页:表格高度实测 .table-region(flex:1)
const defsRegionRef = ref<HTMLElement | null>(null);
const defsH = useElementHeight(defsRegionRef);
// 按应用筛选(flw_def.app_key,2026-10-10):下拉数据源来自应用设置白名单(flw_app enabled=1)
const apps = ref<{ appKey: string; appName: string }[]>([]);
const appMap = ref<Record<string, string>>({});
const filterApp = ref("");
// 议题二②:列表视图(group=同 flowGuid 折叠一行,默认;flat=现状全版本平铺)——纯前端切换,defList 已全量返回
const viewMode = ref<"group" | "flat">("group");
// 分组展开态(key=flowGuid):Table 数据由 computed 派生,展开态必须存外面才不会丢
const openMap = ref<Record<string, boolean>>({});
// 议题二④:flowGuid → 在途办件数;拉取失败降级空 map(行内显示「—」,列表不崩)
const inflightMap = ref<Record<string, number>>({});
/** 在途展示:数字=件数(0 也展示);undefined=接口无该线数据 → 「—」 */
function inflightText(guid: string): string {
const n = inflightMap.value[guid];
return n == null ? "在途 —" : `在途 ${n} 件`;
}
/** 创建时间格式化 YYYY-MM-DD HH:mm(后端 LocalDateTime 带 T,空值显示 -) */
function fmtTime(raw: any): string {
return raw ? String(raw).replace("T", " ").slice(0, 16) : "-";
}
/** 应用列(未归属标记 2026-10-12 appKey 必填化):平铺/分组/展开行共用,口径不漂移 */
const renderApp = (h: any, p: any) => {
if (!p.row.appKey) {
return h(Tag, { color: "warning" }, () => "未归属");
}
return h("span", appMap.value[p.row.appKey] || p.row.appKey);
};
/** 状态列(草稿橙/已发布绿/停用灰):三态共用 */
const renderStatus = (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
return h(Tag, { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
};
/** 流程线级停发状态(T03):与版本 status 正交,必须独立展示,不能混读成“发布/停用” */
const renderLineState = (h: any, p: any) => {
if (!p.row.flowGuid) {
return h("span", { style: "color:#999" }, "—");
}
const offline = Number(p.row.lineOffline) === 1;
const tag = h(Tooltip, {
content: offline
? "流程线已停发:新发起/选人预览均被拒绝,在途办件按原版本跑完;与版本发布状态相互独立"
: "流程线正常:新发起可用;与版本发布状态相互独立",
transfer: true,
maxWidth: 360,
}, () => h(Tag, { color: offline ? "red" : "default" }, () => (offline ? "已停发" : "正常")));
return isLineBusy(p.row)
? h("span", {}, [tag, h(Tag, { color: "blue", style: "margin-left:6px" }, () => "处理中…")])
: tag;
};
function isLineBusy(row: any): boolean {
const guid = String(row?.flowGuid || "").trim();
return !!guid && lineBusyGuid.value === guid;
}
/** 生效/在途 徽标(2026-10-14 从「流程」列拆出独立成列,口径不变:计数仍按流程线) */
const renderRunState = (h: any, p: any) => {
const n = inflightMap.value[p.row.flowGuid];
return h("span", {}, [
h(Tag, { color: p.row.hasPublished ? "green" : "default" },
() => (p.row.hasPublished ? `当前生效 v${p.row.version}` : "无生效版")),
h(Tag, { color: n ? "blue" : "default", style: "margin-left:12px" }, () => inflightText(p.row.flowGuid)),
]);
};
/** 固定宽文本列 + 省略号 + Tooltip:列上写了 render 时 iView 内置 column.tooltip 不生效(cell 只在
* renderType==='normal' 分支渲染 tooltip),故手挂 Tooltip;ellipsis 仍走 column 配置。 */
const ellipsisCell = (key: string, tag = "span") => (h: any, p: any) => h(Tooltip, {
content: p.row[key] ?? "", transfer: true, maxWidth: 480,
}, () => h(tag, p.row[key] ?? ""));
/** 版本级操作集(平铺态、分组展开行同一套;禁用规则按 status,勿与线级启停混用) */
const versionActions = [
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "publish", label: "发布", icon: "icon-fasong", on: (r: any) => onPublish(r), disabledWhen: (r: any) => r.status !== 0 },
{ key: "newVersion", label: "升版本", icon: "icon-tianjia", on: (r: any) => onNewVersion(r), disabledWhen: (r: any) => r.status !== 1 },
// 停用:破坏性动作(已发布→停用),danger 沉底并进「更多」
{ key: "offline", label: "停用", icon: "icon-stop", on: (r: any) => onOffline(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
// 草稿不走这里,草稿用「发布」)
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status !== 2 },
// 元数据原地编辑(2026-10-10 议题2):改名称/归属应用不升版本(含已发布/停用)
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
{ key: "diagram", label: "图示", icon: "icon-tupian", on: (r: any) => onDiagram(r) },
{ key: "source", label: "源码", icon: "icon-liebiao", on: (r: any) => onSource(r) },
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
];
/** 流程线级操作(T03):按 flowGuid 批量停发/恢复,只在有流程线标识时出现 */
const lineActions = [
{ key: "lineOffline", label: "停发流程线", icon: "icon-stop", on: (r: any) => onLineOffline(r), when: (r: any) => !!r.flowGuid && Number(r.lineOffline) !== 1, disabledWhen: (r: any) => isLineBusy(r), danger: true },
{ key: "lineEnable", label: "恢复流程线", icon: "icon-play", on: (r: any) => onLineEnable(r), when: (r: any) => !!r.flowGuid && Number(r.lineOffline) === 1, disabledWhen: (r: any) => isLineBusy(r) },
];
/** 平铺态(每行=一个版本)同时提供线级入口;展开的版本子表仍只用版本级操作,避免误操作整线 */
const flatActions = [...versionActions, ...lineActions];
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "流程", key: "name", minWidth: 180 },
{ title: "编码", key: "code", width: 160 },
{ title: "编码", key: "code", width: 200 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
{ title: "版本", key: "version", width: 70, align: "center" },
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
return h("Tag", { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
} },
{ title: "GUID", key: "flowGuid", width: 200 },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", (p.row.publishTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 250, align: "center", fixed: "right", render: (h: any, p: any) => {
const btns: any[] = [];
if (p.row.status === 0) {
btns.push(Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)));
btns.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => onPublish(p.row)));
} else if (p.row.status === 1) {
btns.push(Utils.operatingHandle(h, "升版本", "icon-tianjia", () => onNewVersion(p.row)));
btns.push(Utils.operatingHandle(h, "停用", "icon-stop", () => onOffline(p.row)));
} else if (p.row.status === 2) {
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
// 草稿不走这里,草稿用「发布」)
btns.push(Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)));
}
btns.push(Utils.operatingHandle(h, "图示", "icon-tupian", () => onDiagram(p.row)));
btns.push(Utils.operatingHandle(h, "源码", "icon-liebiao", () => onSource(p.row)));
btns.push(Utils.operatingHandle(h, "版本历史", "icon-shijian", () => onVersions(p.row)));
return btns;
{ title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "线状态", width: 96, align: "center", render: renderLineState },
{ title: "GUID", key: "flowGuid", width: 320, render: (h: any, p: any) => {
// 平铺态也带在途(议题二④):计数按流程线口径,故挂在 GUID 旁;列宽同分组态,保证 32 位 guid 单行全显
const n = inflightMap.value[p.row.flowGuid];
return h("span", {}, [
p.row.flowGuid,
h(Tag, { color: n ? "blue" : "default", style: "margin-left:6px" }, () => inflightText(p.row.flowGuid)),
]);
} },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
actionColumn(flatActions, { mode: "text", width: 180, fixed: "right" }),
];
async function load() {
loading.value = true;
try {
rows.value = (await flowApi.defList().send()) || [];
rows.value = (await flowApi.defList({ appKey: filterApp.value || undefined }).send()) || [];
loadInflight();
} finally {
loading.value = false;
}
}
/** 在途统计(议题二④):列表到位后取 distinct flowGuids 批量查,失败静默降级(行内显示「—」) */
async function loadInflight() {
const guids = Array.from(new Set(rows.value.map((r: any) => r.flowGuid).filter(Boolean))).slice(0, 100);
if (!guids.length) {
inflightMap.value = {};
return;
}
try {
inflightMap.value = (await flowApi.defInflight(guids).send()) || {};
} catch {
inflightMap.value = {};
}
}
/** 分组行(议题二②):同 flowGuid 折叠一行,代表行=已发布中最高版;无已发布取最高版并标「无生效版」。
* 空 flowGuid(历史脏数据)无流程线可折叠,各自单列为一行,避免被整体丢弃。 */
const groups = computed(() => {
const bucket: Record<string, any[]> = {};
const order: string[] = [];
const noGuid: any[] = [];
rows.value.forEach((r: any) => {
const g = r.flowGuid || "";
if (!g) {
noGuid.push(r);
return;
}
if (!bucket[g]) {
bucket[g] = [];
order.push(g);
}
bucket[g].push(r);
});
const out = order.map((g) => {
const vs = bucket[g].slice().sort((a: any, b: any) => b.version - a.version);
const published = vs.filter((v: any) => v.status === 1);
const cur = published[0] || vs[0];
return {
_key: g, flowGuid: g, id: cur.id, name: cur.name, code: cur.code, appKey: cur.appKey,
version: cur.version, status: cur.status, publishTime: cur.publishTime,
// 线状态全版本同值;任一行标记停发即按停发展示(防历史脏行让管理员误判可用)
lineOffline: vs.some((v: any) => Number(v.lineOffline) === 1) ? 1 : 0,
total: vs.length, hasPublished: published.length > 0,
versions: vs, _expanded: !!openMap.value[g],
};
});
// 空 flowGuid 兜底:openMap 用合成 key,避免与其他行共用 "" 键互相牵连
noGuid.forEach((r: any) => {
const k = `__noguid__${r.id}`;
out.push({
_key: k, flowGuid: "", id: r.id, name: r.name, code: r.code, appKey: r.appKey,
version: r.version, status: r.status, publishTime: r.publishTime,
lineOffline: 0,
total: 1, hasPublished: r.status === 1, versions: [r], _expanded: !!openMap.value[k],
});
});
return out;
});
/** 展开行:该流程线全部版本明细(行对象即 FlwDef,操作只用版本级 versionActions)。
* 在途计数接口按 flowGuid 聚合,没有按版本的可靠数据,故明细列不铺在途、只在分组行展示。 */
const versionColumns = [
{ title: "版本", width: 70, render: (h: any, p: any) => h("b", `v${p.row.version}`) },
{ title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "创建人", width: 110, render: (h: any, p: any) => h("span", p.row.createBy || "-") },
// 创建时间「2026-10-13 23:44」实测行盒 2(折行)→ 取 160,与主表「发布时间」同宽(同文案已证单行)
{ title: "创建时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.createTime)) },
// 改动说明:固定窄列 + 省略号 + Tooltip(2026-10-14 用户要求压短,完整内容进 Tooltip)
{ title: "改动说明", width: 260, ellipsis: true, render: (h: any, p: any) => (p.row.changeLog
? ellipsisCell("changeLog")(h, p)
: h("span", { style: "color:#999" }, "(未填写)")) },
actionColumn(versionActions, { mode: "text", width: 210 }),
];
/** 子表各列声明宽之和:作为内层 Table 的 width 传下去,避免 iView 把展开行剩余空间摊到各列
* (否则「改动说明」会被撑回数百像素,等于没压短),同时让子表右端不再压到固定条带下方。 */
const VERSIONS_TABLE_WIDTH = 70 + 90 + 110 + 160 + 260 + 210;
const groupColumns = [
{ type: "expand", width: 40, render: (h: any, p: any) => h(Table, {
columns: versionColumns, data: p.row.versions, size: "small", noDataText: "暂无数据",
width: VERSIONS_TABLE_WIDTH,
}) },
{ title: "序号", type: "index", width: 60, align: "center" },
// 标题列固定宽 + 超长省略 + Tooltip;生效/在途 徽标已拆到独立列(2026-10-14)
{ title: "流程", width: 260, ellipsis: true, render: ellipsisCell("name", "b") },
{ title: "生效/在途", width: 240, align: "center", render: renderRunState },
// 编码实测单行 155px + 内边距 24 → 160 会折行,故 190
{ title: "编码", key: "code", width: 200 },
{ title: "应用", key: "appKey", width: 130, align: "center", render: renderApp },
{ title: "版本", width: 120, render: (h: any, p: any) => h("span", `v${p.row.version} · 共 ${p.row.total} 版`) },
{ title: "状态", width: 90, align: "center", render: renderStatus },
{ title: "线状态", width: 96, align: "center", render: renderLineState },
// GUID 列宽按 32 位 uuid 实测单行 259px + 内边距 24 定尺寸(留余量,防不同字形更宽);不再截 8 位
{ title: "GUID", width: 320, render: (h: any, p: any) => (p.row.flowGuid
? h("span", { title: p.row.flowGuid }, p.row.flowGuid)
: h("span", { style: "color:#999" }, "—")) },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", fmtTime(p.row.publishTime)) },
actionColumn(
[
...lineActions,
{ key: "versions", label: "版本历史", icon: "icon-shijian", on: (r: any) => onVersions(r) },
{ key: "editMeta", label: "编辑信息", icon: "icon-bianji", on: (r: any) => onEditMeta(r) },
],
{ mode: "text", width: 260, fixed: "right" },
),
];
/** 分组态展开/收起:只由左侧箭头触发(Table 的 on-expand),行内任意位置点击不再切换 */
function onGroupExpand(row?: any, status?: boolean) {
if (row && row._key) {
openMap.value[row._key] = !!status;
}
}
/** 应用清单(筛选下拉):应用设置白名单(flw_app enabled=1),失败降级为空下拉 */
async function loadAppKeys() {
try {
const rows: any[] = (await flowApi.appList().send()) || [];
const enabled = rows.filter((r) => r.enabled === 1);
apps.value = enabled.map((r) => ({ appKey: String(r.appKey), appName: r.appName || String(r.appKey) }));
const map: Record<string, string> = {};
rows.forEach((r) => { if (r.appKey != null) map[String(r.appKey)] = r.appName || String(r.appKey); });
appMap.value = map;
} catch {
apps.value = [];
appMap.value = {};
}
}
/** 新建:编辑器空模板(默认含一个待配置审批节点) */
function onNew() {
router.push({ path: "/admin/defs/editor" });
@@ -91,10 +315,13 @@ async function onPublish(row: any) {
}
function onOffline(row: any) {
// 议题二④:在途数 >0 才追加提示(0 或接口降级态沿用原文案)
const n = inflightMap.value[row.flowGuid] || 0;
MyConfirm({
title: "停用",
msg: `确定停用流程「${row.name}」吗?`,
info: "停用后无法发起新办件,进行中的办件不受影响",
info: "停用后无法发起新办件,进行中的办件不受影响"
+ (n ? `;当前在途 ${n} 件仍跑本版直至完成` : ""),
async onOk() {
await flowApi.defOffline(row.id).send();
Message.success("已停用");
@@ -121,6 +348,103 @@ function onEnable(row: any) {
});
}
/** 流程线级停发:与版本级「停用」正交,确认文案明确讲清影响面 */
function onLineOffline(row: any) {
if (!row.flowGuid) {
Message.warning("该行缺少流程线标识,无法执行线级停发");
return;
}
if (isLineBusy(row)) {
Message.info("该流程线操作正在处理中,请稍候");
return;
}
if (Number(row.lineOffline) === 1) {
Message.info("该流程线已处于停发状态");
return;
}
const n = inflightMap.value[row.flowGuid] || 0;
MyConfirm({
title: "停发流程线",
msg: `确定停发流程线「${row.name}」吗?`,
info: "停发后整条流程线不可发起新办件/选人预览,各版本发布状态不变,在途办件不受影响"
+ (n ? `;当前在途 ${n} 件仍按原版本跑完` : ""),
async onOk() {
await applyLineAction(row, "offline");
},
});
}
/** 流程线级恢复:只恢复线可用性,不改任何版本状态或发布内容 */
function onLineEnable(row: any) {
if (!row.flowGuid) {
Message.warning("该行缺少流程线标识,无法执行线级恢复");
return;
}
if (isLineBusy(row)) {
Message.info("该流程线操作正在处理中,请稍候");
return;
}
if (Number(row.lineOffline) !== 1) {
Message.info("该流程线当前未被停发");
return;
}
MyConfirm({
title: "恢复流程线",
msg: `确定恢复流程线「${row.name}」吗?`,
info: "仅恢复流程线可用性,不改动各版本状态或发布内容;恢复后新发起使用当前最高已发布版本",
async onOk() {
await applyLineAction(row, "enable");
},
});
}
/** 线级启停统一编排:状态守卫 → 调后端 → 按受影响行数反馈 → 刷新列表(失败不误报成功) */
async function applyLineAction(row: any, action: "offline" | "enable") {
const label = action === "offline" ? "停发" : "恢复";
if (isLineBusy(row)) {
Message.info("该流程线操作正在处理中,请稍候");
return;
}
lineBusyGuid.value = String(row.flowGuid || "").trim();
try {
const out = await runLineAction({
flowGuid: row.flowGuid,
lineOffline: Number(row.lineOffline) || 0,
action,
request: async (guid) => Number(await (action === "offline"
? flowApi.defOfflineLine(guid, { silentBizToast: true }).send()
: flowApi.defEnableLine(guid, { silentBizToast: true }).send())) || 0,
reload: () => load(),
});
if (out.kind === "skipped") {
if (out.reason === "no-guid") {
Message.warning(`该行缺少流程线标识,无法执行线级${label}`);
} else if (out.reason === "already-offline") {
Message.info("该流程线已处于停发状态");
} else {
Message.info("该流程线当前未被停发");
}
return;
}
if (out.kind === "empty") {
Message.warning("未更新任何版本,流程线状态可能已变化,请刷新后确认");
} else {
Message.success(action === "offline"
? `流程线已停发(${out.affected} 个版本同步标记,在途办件不受影响)`
: `流程线已恢复(${out.affected} 个版本同步恢复,版本状态与发布内容未改动)`);
}
if (out.reloadFailed) {
Message.warning("操作已提交,但列表刷新失败,请手动刷新后确认最新状态");
}
} catch (e: any) {
Message.error(`${label}流程线失败:${lineActionErrorDetail(e?.message || "")}`);
} finally {
if (lineBusyGuid.value === String(row.flowGuid || "").trim()) {
lineBusyGuid.value = "";
}
}
}
/** 源码:列表行已带回 contentJson(defList 返回全量实体),直接开抽屉,无需再拉接口 */
function onSource(row: any) {
sourceText.value = prettyJson(row.contentJson);
@@ -148,30 +472,138 @@ function onDiagram(row: any) {
router.push({ path: "/diagram", query: { defId: row.id } });
}
onMounted(load);
// ---- 编辑信息(元数据原地编辑,2026-10-10 议题2:改名称/归属应用不升版本) ----
const metaVisible = ref(false);
const metaSaving = ref(false);
const metaForm = ref<{ id: number; name: string; appKey: string; version: number; status: number }>({
id: 0, name: "", appKey: "", version: 0, status: 0,
});
function onEditMeta(row: any) {
metaForm.value = { id: row.id, name: row.name || "", appKey: row.appKey || "", version: row.version, status: row.status };
metaVisible.value = true;
}
/** 当前 appKey 不在启用集合时兜底插入(保存会被后端拦,提示用户先开启或清空) */
const metaAppOptions = computed(() => {
const list = apps.value.map((a) => ({ value: a.appKey, label: a.appName }));
const cur = metaForm.value.appKey;
if (cur && !list.some((o) => o.value === cur)) {
list.unshift({ value: cur, label: `${cur}(未开启,保存将被拦)` });
}
return list;
});
async function onMetaSave() {
if (!metaForm.value.name.trim()) {
Message.warning("流程名称不能为空");
return;
}
// 归属应用必填(2026-10-12 拍板):存量未归属行在此原地补填
if (!metaForm.value.appKey) {
Message.warning("请选择归属应用(应用需先在 流程管理 > 应用设置 中开启)");
return;
}
metaSaving.value = true;
try {
await flowApi.defUpdateMeta({
id: metaForm.value.id,
name: metaForm.value.name.trim(),
appKey: metaForm.value.appKey,
}).send();
Message.success("已更新(版本不变)");
metaVisible.value = false;
load();
} catch (e: any) {
Message.error(e?.message || "保存失败");
} finally {
metaSaving.value = false;
}
}
onMounted(() => {
load();
loadAppKeys();
});
</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<!-- 操作按钮区:主操作靠左(bsch应用页规范 §1) -->
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="onNew">新建流程</Button>
<Select v-model="filterApp" clearable filterable placeholder="按应用筛选" class="fl"
style="width: 200px; margin-left: 8px" @on-change="load">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
<!-- 议题二②:分组(同 flowGuid 折叠一行,默认)/ 平铺(现状全版本行)纯前端切换 -->
<RadioGroup v-model="viewMode" type="button" button-style="solid" class="fl" style="margin-left: 8px">
<Radio label="group">仅最新版(分组)</Radio>
<Radio label="flat">显示全部版本</Radio>
</RadioGroup>
<span class="fl" style="margin-left: 12px; color: #808695; font-size: 12px; line-height: 32px">
{{ viewMode === "group"
? "同 flowGuid 多版折叠为一行,点行展开可见各版创建人 / 创建时间 / 改动说明;「线状态」按 flowGuid 停发,与版本发布状态相互独立"
: "平铺所有版本行(现状口径);「线状态」为整条流程线共享,停发/恢复对每个版本行均可操作" }}
</span>
</div>
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="tableHeight" no-data-text="暂无数据" />
<div class="table-region" ref="defsRegionRef">
<Table v-if="viewMode === 'group'" :columns="groupColumns" :data="groups" :loading="loading"
class="link-table table-bottom-box" :height="defsH || undefined" no-data-text="暂无数据"
@on-expand="onGroupExpand" />
<Table v-else :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="defsH || undefined" no-data-text="暂无数据" />
</div>
<Modal v-model="versionVisible" title="版本历史" width="640">
<Modal v-model="versionVisible" title="版本历史" width="820">
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 8px">
审计信息:创建人 / 创建时间 / 改动说明为本次新增列(存量版本未填说明时显示「(未填写)」)
</p>
<Table :columns="[
{ title: '版本', key: 'version', width: 80 },
{ title: '名称', key: 'name' },
{ title: '状态', width: 90, render: (h, p) => h('Tag', () => DEF_STATUS[p.row.status]) },
{ title: '发布时间', width: 160, render: (h, p) => (p.row.publishTime || '').replace('T', ' ') || '-' },
{ title: '版本', key: 'version', width: 70, render: (h, p) => h('b', 'v' + p.row.version) },
{ title: '名称', key: 'name', width: 150 },
{ title: '状态', width: 80, render: (h, p) => h(Tag, () => DEF_STATUS[p.row.status]) },
{ title: '发布时间', width: 132, render: (h, p) => (p.row.publishTime || '').replace('T', ' ').slice(0, 16) || '-' },
{ title: '创建人', width: 90, render: (h, p) => p.row.createBy || '-' },
{ title: '创建时间', width: 132, render: (h, p) => (p.row.createTime || '').replace('T', ' ').slice(0, 16) || '-' },
{ title: '改动说明', minWidth: 230, render: (h, p) => p.row.changeLog
? h('span', p.row.changeLog) : h('span', { style: 'color:#999' }, '(未填写)') },
]" :data="versions" size="small" no-data-text="暂无数据" />
</Modal>
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
<Modal v-model="metaVisible" title="编辑流程信息(不升版本)" :mask-closable="false" width="460">
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-bottom: 10px">
只修改名称与归属应用,版本与流程内容保持不变(v{{ metaForm.version }},{{
DEF_STATUS[metaForm.status] || metaForm.status }})。结构改动请走「{{
metaForm.status === 0 ? "编辑" : "升版本" }}」进编辑器。
</p>
<Form :label-width="90">
<FormItem label="流程名称">
<Input v-model="metaForm.name" placeholder="流程名称" :maxlength="30" />
</FormItem>
<FormItem label="归属应用">
<Select v-model="metaForm.appKey" filterable placeholder="归属应用(必选)">
<Option v-for="o in metaAppOptions" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
</FormItem>
</Form>
<template #footer>
<Button @click="metaVisible = false">取消</Button>
<Button type="primary" :loading="metaSaving" @click="onMetaSave">保存</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped>
/* 无分页脚:通道只对含 .page-panel 的卡片自动转纵向分栏,本页自声明保证 table-region 撑满 */
.main-container {
display: flex;
flex-direction: column;
}
</style>
+57 -30
View File
@@ -1,55 +1,75 @@
<script setup lang="ts">
// 办件记录(管理端):全校办件查询;CONDITION 判断失败挂起的办件在此"重跑路由"恢复
import { ref } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import { Message, Tag } from "view-ui-plus";
import { OperatorEnum } from "@/types";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import InstanceDrawer from "@/views/flow/components/InstanceDrawer.vue";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import Utils from "@/utils";
import { actionColumn } from "@/utils/tableActions";
const router = useRouter();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
const dataParams = ref<{ status?: number; keyword?: string }>({ status: undefined, keyword: "" });
// status 下拉含伪选项 'hung'(路由挂起):表内是 string,出站转 hung=true(不再传 status)
const dataParams = ref<{ status?: number | string; keyword?: string }>({ status: undefined, keyword: "" });
const drawerVisible = ref(false);
const drawerInstanceId = ref<string | number>("");
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) },
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
{ title: "状态", width: 120, align: "center", render: (h: any, p: any) => h("span", [
h(Tag, { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status),
// F11-4 路由挂起红标:接口回传 routeHung(最新 attempt 路由判断仍无出口)
p.row.routeHung ? h(Tag, { color: "red" }, () => "路由挂起") : null,
]) },
{ title: "提交人", key: "submitterName", width: 100 },
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
{ title: "操作", width: 170, align: "center", fixed: "right", render: (h: any, p: any) => [
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)),
h("span", { style: "display:inline-block;width:10px" }),
// 仅审批中的办件可能挂在条件分支节点(重跑入口对非挂起办件会得到业务错提示)
p.row.status === 10
? Utils.operatingHandle(h, "重跑路由", "icon-play", () => onReroute(p.row))
: h("span"),
] },
actionColumn(
[
{ key: "detail", label: "详情", icon: "icon-browse", on: (r: any) => openDetail(r.id) },
// 仅审批中的办件可挂条件分支节点;其余行置灰保位(重跑入口对非挂起办件是业务错提示)
{ key: "reroute", label: "重跑路由", icon: "icon-play", on: (r: any) => onReroute(r), disabledWhen: (r: any) => r.status !== 10 },
],
{ mode: "text", width: 170, fixed: "right" },
),
];
/** 重跑路由(2026-09-24 §4.4):重新调判断服务,成功即按出口续走;仍失败回业务错(办件继续挂起) */
function openDetail(instanceId: string | number) {
drawerInstanceId.value = instanceId;
drawerVisible.value = true;
}
/** 重跑路由(2026-09-24 §4.4 + F5-4 逃生门):确认框带「按当前已发布定义重跑」复选——
* 僵尸办件(挂起时的表达式已修好并发布为新版)勾选后按新版续走,否则按落库快照重跑 */
const rerouteVisible = ref(false);
const rerouteUseLatest = ref(false);
const rerouteRow = ref<any>(null);
function onReroute(row: any) {
MyConfirm({
title: "重跑路由",
msg: `确定对办件「${row.bizTitle || row.id}」重跑条件路由判断吗?`,
info: "仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)",
async onOk() {
await flowApi.instanceReroute(row.id).send();
Message.success("路由已重跑,流程按新判断结果继续");
rerouteRow.value = row;
rerouteUseLatest.value = false;
rerouteVisible.value = true;
}
async function confirmReroute() {
const row = rerouteRow.value;
if (!row) return;
await flowApi.instanceReroute(row.id, rerouteUseLatest.value).send();
rerouteVisible.value = false;
Message.success("路由已重跑,流程按判断结果继续");
tableRef.value?.reloadData(OperatorEnum.edit);
},
});
}
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
const params: any = { page: p.pageIndex, size: p.pageSize };
if (dataParams.value.status) params.status = dataParams.value.status;
if (dataParams.value.status === "hung") {
// F11-4 路由挂起伪选项:只传 hung,不带 status(后端 hung 过滤已内含审批中)
params.hung = true;
} else if (dataParams.value.status) {
params.status = dataParams.value.status;
}
if (dataParams.value.keyword) params.keyword = dataParams.value.keyword.trim();
return flowApi.adminInstances(params);
};
@@ -60,7 +80,7 @@ function onSearch() {
</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
@@ -68,11 +88,18 @@ function onSearch() {
<Option :value="20" label="已完成" />
<Option :value="30" label="已拒绝" />
<Option :value="40" label="已撤回" />
<Option value="hung" label="路由挂起" />
</Select>
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<InstanceDrawer v-model="drawerVisible" :instance-id="drawerInstanceId" />
<Modal v-model="rerouteVisible" title="重跑路由" :mask-closable="false" @on-ok="confirmReroute">
<p style="margin: 0 0 8px">确定对办件「{{ rerouteRow?.bizTitle || rerouteRow?.id }}」重跑条件路由判断吗?</p>
<p style="margin: 0 0 12px; color: #808695">仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)</p>
<Checkbox v-model="rerouteUseLatest">按当前已发布流程定义重跑(救僵尸办件)</Checkbox>
</Modal>
</div>
</template>
-205
View File
@@ -1,205 +0,0 @@
<script setup lang="ts">
// 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;办理人改为弹窗选人(参照 Notice 选人范式)
import { ref, onMounted } from "vue";
import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import UserSelectInput from "@/components/UserSelectInput.vue";
import { flowApi } from "@/api/module/flow";
import Utils from "@/utils";
const loading = ref(false);
const deptKeyFilter = ref("");
const rows = ref<any[]>([]);
// 操作按钮区 + 搜索区、无分页
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
// 一键拉取教师组(预览确认后批量导入)
const groupVisible = ref(false);
const groupLoading = ref(false);
const groupImporting = ref(false);
const groupRows = ref<any[]>([]);
const groupColumns = [
{ title: "科室编码", key: "deptKey", width: 100 },
{ title: "科室名称", key: "deptName", width: 160 },
{ title: "成员数", key: "memberCount", width: 90, align: "center", render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) },
{ title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") },
];
async function onGroupPreview() {
groupVisible.value = true;
groupLoading.value = true;
try {
groupRows.value = (await flowApi.mapGroupPreview().send()) || [];
} catch (e: any) {
Message.error(e?.message || "教师组拉取失败(UserBase 不可达?)");
groupVisible.value = false;
} finally {
groupLoading.value = false;
}
}
async function onGroupImport() {
groupImporting.value = true;
try {
const r: any = await flowApi.mapImportGroups().send();
Message.success(`导入完成:${r?.depts ?? 0} 个科室 / 新增 ${r?.users ?? 0} 条映射 / 跳过已存在 ${r?.skippedExisting ?? 0} 条`);
groupVisible.value = false;
load();
} catch (e: any) {
Message.error(e?.message || "导入失败");
} finally {
groupImporting.value = false;
}
}
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "科室编码", key: "deptKey", width: 140 },
{ title: "科室名称", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") },
{ title: "办理人", key: "userName", width: 120 },
{ title: "用户ID", key: "userId", width: 90 },
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
p.row.status === 0
? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
: Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)) },
];
async function load() {
loading.value = true;
try {
rows.value = (await flowApi.mapList(deptKeyFilter.value.trim() || undefined).send()) || [];
} finally {
loading.value = false;
}
}
function onSearch() {
load();
}
// ---- 新增/更新映射弹窗(规范:必填红星标注,校验失败提示统一文案) ----
const saveVisible = ref(false);
const saving = ref(false);
const formRef = ref<any>(null);
const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" });
const saveRules = {
deptKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
userId: [{ required: true, message: "请填写红色星号内容!", trigger: "change" }],
};
function onAdd() {
form.value = { deptKey: "", deptName: "", userId: null, userName: "" };
saveVisible.value = true;
}
function onUserPick(u: any) {
// 选人组件回传 id+name 同帧快照(姓名不再自查)
form.value.userName = u?.name || "";
}
function onSave() {
formRef.value?.validate(async (valid: boolean) => {
if (!valid) return;
saving.value = true;
try {
await flowApi.mapSave({
deptKey: form.value.deptKey.trim(),
deptName: form.value.deptName,
userId: Number(form.value.userId),
userName: form.value.userName,
}).send();
Message.success("已保存");
saveVisible.value = false;
load();
} finally {
saving.value = false;
}
});
}
function onDisable(row: any) {
MyConfirm({
title: "停用",
msg: `确定停用科室「${row.deptName || row.deptKey}」的办理人映射吗?`,
info: "停用后该科室流程节点需重新配置办理人",
async onOk() {
await flowApi.mapDisable(row.id).send();
Message.success("已停用");
load();
},
});
}
/** 启用(2026-09-18 补齐:原来停用后只能重新"新增映射"恢复) */
function onEnable(row: any) {
MyConfirm({
title: "启用",
msg: `确定启用科室「${row.deptName || row.deptKey}」的办理人「${row.userName || row.userId}」吗?`,
info: "启用后该科室的流程节点会重新解析到这名办理人",
async onOk() {
await flowApi.mapEnable(row.id).send();
Message.success("已启用");
load();
},
});
}
onMounted(load);
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<!-- 操作按钮区:主操作靠左、其余靠右(bsch应用页规范 §1) -->
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="onAdd">新增映射</Button>
<Button class="fr" size="large" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
</div>
<!-- 搜索区:关键字即输即搜,不设查询按钮 -->
<div class="search-wrap fix">
<Input class="fr" v-model="deptKeyFilter" placeholder="按科室编码筛选" search suffix="ios-search"
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
</div>
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="tableHeight" no-data-text="暂无数据" />
<Modal v-model="saveVisible" title="新增/更新映射" :mask-closable="false">
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="90">
<FormItem label="科室编码" prop="deptKey">
<Input v-model="form.deptKey" placeholder="流程 JSON 中 DEPT_KEY 引用的编码,如 ybb" />
</FormItem>
<FormItem label="科室名称">
<Input v-model="form.deptName" placeholder="如 医保办(可空)" />
</FormItem>
<FormItem label="办理人" prop="userId">
<UserSelectInput
:model-value="form.userId"
:display-name="form.userName"
@update:model-value="form.userId = $event"
@update:display-name="form.userName = $event"
@on-change="onUserPick"
/>
</FormItem>
</Form>
<template #footer>
<Button @click="saveVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
</template>
</Modal>
<Modal v-model="groupVisible" title="一键拉取教师组(UserBase 学校组树)" width="720" :mask-closable="false">
<p style="margin-bottom: 8px; color: #808695">
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
</p>
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" no-data-text="暂无数据" />
<template #footer>
<Button @click="groupVisible = false">取消</Button>
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">确认导入</Button>
</template>
</Modal>
</div>
</div>
</template>
+741
View File
@@ -0,0 +1,741 @@
<script setup lang="ts">
// 接入向导(2026-10-15 议题二 P1):把子应用接入流程中心从「人背文档 + 逐字对齐字符串」变成
// 「向导引导 + 机器生成正确配置 + 每步可自检」。五步:应用档案 → 工程接入 → 回调注册 → 流程编排 → 联调验收。
// 数据源全部复用已有接口(appList / regList / callbackProbe / defList / defGet / callbackLogs / adminInstances),
// 零新增表、零新增写接口。配置生成器对齐《子应用接入流程中心规范.md》§7.2/§7.5。
// 方案文档:docs/流程中心接入向导与配置生成器-实施方案-20261015.md
import { ref, computed, onMounted } from "vue";
import { Message, Tag, Button } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
import Utils from "@/utils";
import { extractExprVars, EXPR_BUILTIN_VARS } from "@/views/flow/admin/def-editor/model";
// ==================== 步骤骨架 ====================
const STEPS = [
{ title: "应用档案", hint: "我是谁:appKey / target / context-path / bizType" },
{ title: "工程接入", hint: "生成正确形态的 yh.flow-callback 配置" },
{ title: "回调注册", hint: "登记行存在且地址合规 + 探测" },
{ title: "流程编排", hint: "流程定义 → 变量契约显性化" },
{ title: "联调验收", hint: "六项自检 + 自检报告" },
];
const step = ref(0);
// ==================== STEP① 应用档案 ====================
const apps = ref<any[]>([]);
const appMap = ref<Record<string, string>>({});
const s1 = ref({
appKey: "",
target: "",
ctx: "",
bizTypes: "",
bindLevel: "app",
});
const BIND_LEVELS = [
{ value: "app", label: "应用级", desc: "整个应用一种审批流(一校一行绑定表)" },
{ value: "object", label: "对象级", desc: "每个业务对象各走各的流(对象行加 flow_guid 列)" },
{ value: "form", label: "表单级", desc: "自定义表单统一审批路径,事项零配置继承" },
];
// ==================== STEP② 工程接入(配置生成器) ====================
const s2 = ref({
// 端点形态:platform=平台验签式(式 A,推荐)|xflw=注册级 X-Flw 式(式 B)
endpointMode: "platform" as "platform" | "xflw",
// 接入场景:new=新接入|upgrade=存量升级(已接回调,升 starter 版本即可)
scenario: "new" as "new" | "upgrade",
configDone: false,
});
// 前置条件清单(规范 §7.1 + work-leave 四个静默断点);后两条高危,红底强调
const PREREQS = [
{ key: "openapi", label: "已配 yh-openapi-spring-boot-starter", danger: false, tip: "Registrar 自注册用" },
{ key: "appauth", label: "平台验签式:已配 yh.app-auth.server.enforce-patterns: [/oapi/*]", danger: false, tip: "式 A 的验签过滤器" },
{ key: "datasource", label: "应用有 JDBC DataSource", danger: false, tip: "幂等表/事件表落地" },
{ key: "redis", label: "projman 已勾选 Redis 公共组件", danger: true, tip: "断点 3:缺 Redis 验签链无密钥,编译启动都不报错,直到验签 401" },
{ key: "trust", label: "trust-registry: true 已配", danger: true, tip: "断点 4:缺失 = 验签不过,症状与「收不到回调」同构" },
{ key: "endpoint", label: "接收端点已部署且可访问", danger: false, tip: "注册接口会探活,端点必须先上线" },
];
const prereqChecked = ref<Record<string, boolean>>({});
const prereqAllDone = computed(() => PREREQS.every((p) => prereqChecked.value[p.key]));
/** 生成的 yh.flow-callback 配置块(技术核心,对齐规范 §7.2 推导式) */
const generatedYaml = computed(() => {
const ctx = s1.value.ctx.trim();
const bizTypes = s1.value.bizTypes
.split(",")
.map((s: string) => s.trim())
.filter(Boolean);
const isXflw = s2.value.endpointMode === "xflw";
const L: string[] = ["yh:", " flow-callback:", " enabled: true"];
L.push(ctx
? ` base-path: ${ctx} # 接收方 servlet context-path;§4.2:填错/漏填 ⇒ 推送全 401 无限重试`
: " # base-path: # 本地无 context-path 就不填(填了反而 401)");
L.push(` biz-types: [${bizTypes.join(", ")}] # 本应用 bizType 白名单;空=不过滤全交 Handler`);
L.push(` verify-flw-signature: ${isXflw}${isXflw ? " # 式 B:注册级 X-Flw 签名" : " # 式 A:平台应用签名(AppAuthFilter)"}`);
if (isXflw) {
L.push(" path: /callback/flow");
L.push(" # ⚠ 端点第二段非 oapi → 网关 TargetRouter 裸 404,必须显式配 callback-url 直连:");
L.push(" # callback-url: http://<host>:<port>/callback/flow");
} else {
L.push(" # path: /oapi/flow/callback # 平台验签式用 starter 默认值,不必写");
}
L.push(" store:");
L.push(" auto-create-table: true # 首次使用自动 CREATE TABLE IF NOT EXISTS");
return L.join("\n");
});
/** 生成的依赖块(存量升级场景不生成,改提示升版本) */
const generatedPom = computed(() => {
if (s2.value.scenario === "upgrade") {
return "<!-- 存量升级:无需新增依赖,升级 yh-flow-callback-spring-boot-starter 版本即可。\n 回调接收与条件路由判断由同一个 jar 提供。 -->";
}
const extra = s2.value.endpointMode === "platform"
? "\n<!-- 平台验签式额外需要:应用已配 yh-app-auth-spring-boot-starter -->"
: "";
return "<dependency>\n <groupId>com.yunhai.platform</groupId>\n <artifactId>yh-flow-callback-spring-boot-starter</artifactId>\n</dependency>" + extra;
});
/** 生成的 Handler 骨架(规范 §7.3) */
const generatedHandler = computed(() =>
"@Bean\npublic FlowCallbackHandler flowCallbackHandler(YourRepository repo) {\n return event -> {\n // 走到这里已验签、已过幂等/乱序门/bizType 过滤,且与业务推进同一事务\n switch (event.getEvent()) {\n case \"flow.submitted\": /* 在途 */ break;\n case \"flow.task.created\": /* 建待办 */ break;\n case \"flow.task.handled\": /* 记录办理 */ break;\n case \"flow.completed\": /* 终态·通过(终态不可逆!) */ break;\n case \"flow.rejected\": /* 终态·驳回 */ break;\n case \"flow.withdrawn\": /* 终态·撤回 */ break;\n default: /* 未知事件码 → 记日志后正常返回,200 吞掉(向前兼容) */ break;\n }\n };\n}");
// ==================== STEP③ 回调注册 ====================
const regs = ref<any[]>([]);
const regLoading = ref(false);
/** 当前 appKey 的登记行(同一应用多个 flowGuid 各一行) */
const appRegs = computed(() =>
regs.value.filter((r: any) => String(r.appKey) === String(s1.value.appKey)));
const regCheck = ref<{ level: "ok" | "warn" | "bad"; text: string; advice?: string }[]>([]);
const regCheckedAt = ref("");
const probeResult = ref<any>(null);
const probing = ref(false);
async function loadRegs() {
regLoading.value = true;
try {
regs.value = ((await flowApi.regList().send()) as any[]) || [];
} catch (e: any) {
Message.error(e?.message || "登记列表加载失败");
regs.value = [];
} finally {
regLoading.value = false;
}
}
/** 登记行检查(调研 §3.3 痛点② 的直接兜底) */
async function checkRegs() {
if (!s1.value.appKey) {
Message.warning("请先在第 ① 步选择应用");
step.value = 0;
return;
}
await loadRegs();
const mine = appRegs.value;
const out: { level: "ok" | "warn" | "bad"; text: string; advice?: string }[] = [];
if (!mine.length) {
out.push({ level: "bad", text: "该应用没有任何回调登记行", advice: "在管理端保存绑定时调 ensureRegistered(schId, flowGuid)(规范 §7.4);不要在发起时才注册" });
} else {
// 地址形态:私网/开发机地址必然拒连无限重试(断点 1)
const PRIVATE = /:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|10\.[0-9.]+|192\.168\.[0-9.]+|172\.(1[6-9]|2[0-9]|3[01])\.[0-9.]+)/;
const bad = mine.filter((r: any) => r.callbackUrl && PRIVATE.test(r.callbackUrl));
if (bad.length) {
out.push({ level: "bad", text: bad.length + " 条登记地址是私网/开发机地址(" + bad[0].callbackUrl + ")", advice: "推送必然拒连并无限重试:删掉本地镜像行 → 管理端重新保存绑定,走 starter 自注册" });
}
// basePath:有 ctx 却漏填 ⇒ 推送全 401(§4.2 高频事故)
const emptyBase = mine.filter((r: any) => !r.basePath);
if (emptyBase.length && s1.value.ctx.trim()) {
out.push({ level: "bad", text: emptyBase.length + " 条登记未填 base_path,而你在第 ① 步声明了 context-path", advice: "漏填 base_path → 推送全 401 → 无限重试;症状表现为「收不到回调」" });
} else if (emptyBase.length) {
out.push({ level: "warn", text: emptyBase.length + " 条登记未填 base_path(你声明了无 context-path,需确认)", advice: "本地无 ctx 才留空;生产有 ctx 必须填" });
}
// 登记地址与第 ① 步声明的 target 一致性
const target = s1.value.target.trim();
if (target) {
const mismatch = mine.filter((r: any) => r.callbackUrl && !String(r.callbackUrl).includes("/" + target));
if (mismatch.length) {
out.push({ level: "warn", text: mismatch.length + " 条登记地址不含声明的 target「" + target + "」", advice: "确认是旧登记残留,还是登记地址推导有误" });
}
}
if (!out.length) {
out.push({ level: "ok", text: mine.length + " 条登记全部合规(地址为网关形态、base_path 一致)" });
}
}
regCheck.value = out;
regCheckedAt.value = new Date().toLocaleTimeString();
}
/** 探测:同步发一条 flow.ping 走真实管道(复用管理端 callbackProbe) */
async function probeFirst() {
const first = appRegs.value[0];
if (!first) {
Message.warning("该应用没有可探测的登记行");
return;
}
probing.value = true;
probeResult.value = null;
try {
probeResult.value = (await flowApi.callbackProbe(first.id).send()) || null;
} catch (e: any) {
Message.error(e?.message || "探测失败");
} finally {
probing.value = false;
}
}
// ==================== STEP④ 流程编排 ====================
const defs = ref<any[]>([]);
const defLoading = ref(false);
const selectedDefId = ref<number | null>(null);
/** 选中流程的 CONDITION 节点清单 + 变量契约核对 */
const condNodes = ref<{ name: string; judgeMode: string; routerKey: string; outlets: { name: string; expr: string; unknown: string[] }[] }[]>([]);
const knownVars = ref<string[]>([]);
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
async function loadDefs() {
if (!s1.value.appKey) return;
defLoading.value = true;
try {
defs.value = ((await flowApi.defList({ appKey: s1.value.appKey }).send()) as any[]) || [];
if (!selectedDefId.value && defs.value.length) selectedDefId.value = defs.value[0].id;
} catch (e: any) {
Message.error(e?.message || "流程定义加载失败");
defs.value = [];
} finally {
defLoading.value = false;
}
}
/** 已知变量 = 近期 50 条办件 formData 顶层键(去重截 40)+ 内置 8 个;口径同编辑器与调试弹窗 */
async function loadKnownVars() {
const keys = new Set<string>();
try {
const rows: any[] = (await flowApi.adminInstances({ page: 1, size: 50 }).send())?.content || [];
rows.forEach((r: any) => {
try {
const v = JSON.parse(r.formDataJson || "{}");
if (v && typeof v === "object" && !Array.isArray(v)) Object.keys(v).forEach((k: string) => keys.add(k));
} catch { /* 脏 formData 跳过 */ }
});
} catch { /* 办件拉取失败:退化为仅内置,不阻塞 */ }
knownVars.value = Array.from(new Set([...Array.from(keys).slice(0, 40), ...EXPR_BUILTIN_VARS]));
}
const hasPublished = computed(() => defs.value.some((d: any) => d.status === 1));
/** 解析选中流程的 CONDITION 节点,把表达式变量与已知键核对(发布期 warn 校验的可视化复述) */
async function inspectDef(id: number) {
selectedDefId.value = id;
condNodes.value = [];
try {
const d: any = await flowApi.defGet(id).send();
const content = typeof d?.contentJson === "string" ? JSON.parse(d.contentJson || "{}") : (d?.contentJson || {});
const nodes: any[] = content.nodes || [];
const edges: any[] = content.edges || [];
condNodes.value = nodes
.filter((n: any) => n.type === "CONDITION")
.map((n: any) => ({
name: n.name || n.id,
judgeMode: n.judgeMode || "SERVICE",
routerKey: n.routerKey || "",
outlets: edges
.filter((e: any) => e.from === n.id)
.map((e: any) => {
const expr = e.condition || "";
return { name: e.name || "(未命名出口)", expr, unknown: expr ? extractExprVars(expr).filter((v: string) => !knownVars.value.includes(v)) : [] };
}),
}));
} catch (e: any) {
Message.error(e?.message || "流程详情加载失败");
}
}
// ==================== STEP⑤ 联调验收 ====================
const accept = ref<{ no: number; name: string; level: "ok" | "warn" | "bad" | "none"; detail: string; advice?: string }[]>([]);
const acceptDone = ref(false);
const acceptLoading = ref(false);
/** 六项自检:把「手册的诊断命令集」变成按钮 */
async function runAcceptance() {
acceptLoading.value = true;
try {
const R: typeof accept.value = [];
const app = apps.value.find((a: any) => String(a.appKey) === String(s1.value.appKey));
// 1 应用白名单启用(必须通过)
R.push(app && app.enabled === 1
? { no: 1, name: "应用白名单启用", level: "ok", detail: app.appName + "(" + app.appKey + ")已开通且启用" }
: { no: 1, name: "应用白名单启用", level: "bad", detail: app ? "应用已停用" : "应用不在白名单", advice: "请先在用户中心开通/启用该应用" });
// 2 前置条件全勾选(黄色可带病通过)
const missed = PREREQS.filter((p) => !prereqChecked.value[p.key]);
R.push(missed.length
? { no: 2, name: "前置条件全勾选", level: "warn", detail: "尚有 " + missed.length + " 项未勾选:" + missed.map((m) => m.label).join(";"), advice: "回到第 ② 步补勾,优先 Redis 公共组件与 trust-registry" }
: { no: 2, name: "前置条件全勾选", level: "ok", detail: PREREQS.length + " 项前置条件已确认" });
// 3 登记行存在且地址合规(必须通过)
const regBad = regCheck.value.filter((c) => c.level === "bad");
const regWarn = regCheck.value.filter((c) => c.level === "warn");
if (!regCheckedAt.value) {
R.push({ no: 3, name: "登记行存在且地址合规", level: "warn", detail: "尚未在第 ③ 步执行「检查注册」", advice: "回第 ③ 步点「检查注册」" });
} else if (regBad.length) {
R.push({ no: 3, name: "登记行存在且地址合规", level: "bad", detail: regBad.map((c) => c.text).join(";"), advice: regBad[0].advice });
} else {
R.push({ no: 3, name: "登记行存在且地址合规", level: regWarn.length ? "warn" : "ok", detail: regWarn.length ? "地址合规,但有 " + regWarn.length + " 条告警:" + regWarn.map((c) => c.text).join(";") : appRegs.value.length + " 条登记全部合规", advice: regWarn[0]?.advice });
}
// 4 探测可达且验签通过(黄色可带病通过)
if (!probeResult.value) {
R.push({ no: 4, name: "探测可达且验签通过", level: "warn", detail: "尚未执行探测", advice: "回第 ③ 步点「发一条 flow.ping」" });
} else if (probeResult.value.success) {
R.push({ no: 4, name: "探测可达且验签通过", level: "ok", detail: "HTTP " + probeResult.value.httpStatus + " · 耗时 " + probeResult.value.elapsedMs + "ms" });
} else {
R.push({ no: 4, name: "探测可达且验签通过", level: "warn", detail: "探测失败:" + (probeResult.value.error || "HTTP " + (probeResult.value.httpStatus ?? "?")), advice: "401 → 查 base-path / trust-registry;拒连 → 查登记地址" });
}
// 5 有已发布流程(必须通过)
R.push(hasPublished.value
? { no: 5, name: "有已发布流程", level: "ok", detail: defs.value.filter((d: any) => d.status === 1).length + " 个已发布流程" }
: { no: 5, name: "有已发布流程", level: "bad", detail: defs.value.length ? "仅有草稿/停用流程" : "该应用无任何流程定义", advice: "回第 ④ 步:发布流程后再回填绑定" });
// 6 近 7 日推送有成功记录(无数据不扣分)
try {
const page: any = await flowApi.callbackLogs({ status: 1, size: 1 }).send();
const total = page?.totalCount ?? page?.total ?? 0;
R.push(total > 0
? { no: 6, name: "近 7 日推送有成功记录", level: "ok", detail: "累计成功推送 " + total + " 条" }
: { no: 6, name: "近 7 日推送有成功记录", level: "warn", detail: "暂无成功推送记录(可能还没发起过办件,可跳过)" });
} catch {
R.push({ no: 6, name: "近 7 日推送有成功记录", level: "warn", detail: "推送日志拉取失败,跳过该项" });
}
accept.value = R;
acceptDone.value = true;
} finally {
acceptLoading.value = false;
}
}
/** 一键生成接入自检报告(纯前端拼装,不落库;可直接复制进工单) */
const reportVisible = ref(false);
const reportText = computed(() => {
const app = apps.value.find((a: any) => String(a.appKey) === String(s1.value.appKey));
const L: string[] = [];
L.push("# 子应用接入流程中心 · 自检报告");
L.push("");
L.push("- 生成时间:" + new Date().toLocaleString());
L.push("- 应用:" + (app ? app.appName + "(" + app.appKey + ")" : s1.value.appKey || "未选择"));
L.push("- 网关 target:" + (s1.value.target || "未填(缺省取 base-path)"));
L.push("- context-path:" + (s1.value.ctx || "无"));
L.push("- bizType:" + (s1.value.bizTypes || "未填"));
L.push("- 绑定档位:" + (BIND_LEVELS.find((b) => b.value === s1.value.bindLevel)?.label || "-"));
L.push("- 接入口径:" + (s2.value.endpointMode === "xflw" ? "注册级 X-Flw 式(式 B)" : "平台验签式(式 A)"));
L.push("");
L.push("## 六项自检结果");
L.push("");
if (!acceptDone.value) {
L.push("_尚未执行自检,请先点「执行六项自检」。_");
} else {
const icon = { ok: "✅", warn: "⚠️", bad: "❌", none: "➖" };
accept.value.forEach((r) => {
L.push(icon[r.level] + " **" + r.no + ". " + r.name + "** —— " + r.detail);
if (r.advice) L.push(" - 建议:" + r.advice);
});
}
L.push("");
L.push("## 生成的配置块");
L.push("");
L.push("```yaml");
L.push(generatedYaml.value);
L.push("```");
L.push("");
L.push("## 前置条件确认");
L.push("");
PREREQS.forEach((p) => L.push((prereqChecked.value[p.key] ? "- [x]" : "- [ ]") + " " + p.label + (p.danger ? "(高危断点)" : "")));
L.push("");
L.push("## 登记行");
L.push("");
if (!appRegs.value.length) L.push("_无登记行_");
else {
L.push("| 流程 GUID | 回调地址 | base_path | 状态 |");
L.push("|---|---|---|---|");
appRegs.value.forEach((r: any) => L.push("| " + (r.flowGuid || "-") + " | " + (r.callbackUrl || "-") + " | " + (r.basePath || "-") + " | " + (r.status === 0 ? "启用" : "停用") + " |"));
}
L.push("");
L.push("## 条件路由变量契约");
L.push("");
if (!condNodes.value.length) L.push("_所选流程无 CONDITION 节点_");
else {
condNodes.value.forEach((n) => {
L.push("### " + n.name + "(" + (n.judgeMode === "EXPRESSION" ? "表达式" : "外部判断服务") + (n.routerKey ? " · routerKey=" + n.routerKey : "") + ")");
n.outlets.forEach((o) => {
L.push("- 出口「" + o.name + "」:" + (o.expr || "(无表达式)"));
if (o.unknown.length) L.push(" - ⚠️ 引用了未在已知变量中的:" + o.unknown.join("、") + "(疑似拼错,运行时会挂起)");
});
});
}
return L.join("\n");
});
async function copyText(t: string, ok = "已复制") {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(t);
Message.success(ok);
return;
}
await Utils.copyToClipboard(t);
Message.success(ok);
} catch {
Message.error("复制失败,请手动选择文本复制");
}
}
// ==================== 生命周期 ====================
onMounted(async () => {
try {
apps.value = ((await flowApi.appList().send()) as any[]) || [];
appMap.value = {};
apps.value.forEach((a: any) => { if (a.appKey) appMap.value[a.appKey] = a.appName; });
} catch { /* 应用列表拉取失败:第 ① 步会给红字提示 */ }
await loadKnownVars();
});
/** 进入某步时的按需装载 */
async function goto(n: number) {
step.value = n;
if (n === 2) await loadRegs();
if (n === 3) await loadDefs();
}
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container ob-wrap">
<!-- 顶栏 -->
<div class="ob-head fix">
<span class="fl ob-title">接入向导</span>
<span class="fl ob-sub">把「子应用接入流程中心」从人背文档变成向导引导 + 机器生成正确配置</span>
<Button class="fr" type="primary" size="large" @click="reportVisible = true">生成接入自检报告</Button>
</div>
<!-- 步骤条(可任意跳转,不强制线性) -->
<div class="ob-steps">
<div v-for="(s, i) in STEPS" :key="i" class="ob-step" :class="{ 'is-cur': step === i, 'is-done': i < step }" @click="goto(i)">
<span class="ob-num">{{ i + 1 }}</span>
<span class="ob-st">
<b>{{ s.title }}</b>
<em>{{ s.hint }}</em>
</span>
</div>
</div>
<!-- ============ STEP① 应用档案 ============ -->
<div v-show="step === 0" class="ob-body">
<div class="ob-card">
<h3>我是谁</h3>
<p class="ob-desc">这四个值是后续所有生成器的输入;context-path 是 §4.2 高频事故点,请如实填写。</p>
<div class="ob-form">
<div class="ob-row">
<label class="ob-lab">应用<i>*</i></label>
<Select v-model="s1.appKey" style="width: 320px" placeholder="选择已开通的应用" @on-change="loadDefs">
<Option v-for="a in apps" :key="a.appKey" :value="a.appKey" :label="a.appName + '(' + a.appKey + ')'" />
</Select>
<span v-if="!apps.length" class="ob-err">应用清单拉取失败或尚未开通 —— 请先在用户中心开通该应用</span>
</div>
<div class="ob-row">
<label class="ob-lab">网关 target</label>
<Input v-model="s1.target" style="width: 320px" placeholder="如 notice;可留空,缺省取 base-path" />
<span class="ob-tip">网关路由首段;契约上与 base-path 恒等,通常留空即可</span>
</div>
<div class="ob-row">
<label class="ob-lab">context-path(base-path)</label>
<Input v-model="s1.ctx" style="width: 320px" placeholder="如 /notice;本地无 ctx 留空" />
<span class="ob-warn-txt">⚠ 填错/漏填 ⇒ 推送全 401 无限重试,症状表现为「收不到回调」</span>
</div>
<div class="ob-row">
<label class="ob-lab">bizType</label>
<Input v-model="s1.bizTypes" style="width: 320px" placeholder="如 notice;多个用逗号分隔" />
<span class="ob-tip">发起时声明的业务关联对;回调按它路由到单据行</span>
</div>
<div class="ob-row">
<label class="ob-lab">绑定档位</label>
<RadioGroup v-model="s1.bindLevel" type="button">
<Radio v-for="b in BIND_LEVELS" :key="b.value" :label="b.value">{{ b.label }}</Radio>
</RadioGroup>
<span class="ob-tip">{{ (BIND_LEVELS.find((b) => b.value === s1.bindLevel) || {}).desc }}</span>
</div>
</div>
</div>
</div>
<!-- ============ STEP② 工程接入(配置生成器) ============ -->
<div v-show="step === 1" class="ob-body">
<div class="ob-card">
<h3>端点形态与接入场景</h3>
<div class="ob-form">
<div class="ob-row">
<label class="ob-lab">端点形态<i>*</i></label>
<RadioGroup v-model="s2.endpointMode">
<Radio label="platform">平台验签式(式 A,推荐)</Radio>
<Radio label="xflw">注册级 X-Flw 式(式 B)</Radio>
</RadioGroup>
</div>
<div class="ob-row">
<label class="ob-lab">接入场景<i>*</i></label>
<RadioGroup v-model="s2.scenario">
<Radio label="new">新接入</Radio>
<Radio label="upgrade">存量升级(已接回调)</Radio>
</RadioGroup>
</div>
</div>
</div>
<div class="ob-card">
<h3>A · Maven 依赖</h3>
<pre class="ob-code" :class="{ 'is-warn': s2.scenario === 'upgrade' }">{{ generatedPom }}</pre>
<Button size="small" class="ob-copy" @click="copyText(generatedPom, '依赖块已复制')">复制</Button>
</div>
<div class="ob-card">
<h3>B · yh.flow-callback 配置块(自动生成)</h3>
<p class="ob-desc">按上方选择 + 第 ① 步输入实时生成,形态对齐规范 §7.2 推导式。</p>
<pre class="ob-code">{{ generatedYaml }}</pre>
<Button size="small" class="ob-copy" @click="copyText(generatedYaml, '配置块已复制')">复制</Button>
<div v-if="s2.endpointMode === 'xflw'" class="ob-alert">
⚠ X-Flw 式端点第二段非 <code>oapi</code>,网关 TargetRouter 裸 404,<b>必须显式配 callback-url 直连</b>(规范 §7.5)
</div>
</div>
<div class="ob-card">
<h3>C · FlowCallbackHandler 骨架</h3>
<pre class="ob-code">{{ generatedHandler }}</pre>
<Button size="small" class="ob-copy" @click="copyText(generatedHandler, 'Handler 骨架已复制')">复制</Button>
</div>
<div class="ob-card">
<h3>D · 前置条件清单</h3>
<p class="ob-desc">把散落文档里的前置条件变成可勾选待办;红底两项是 work-leave 复盘出的高危静默断点。</p>
<div class="ob-prereqs">
<label v-for="p in PREREQS" :key="p.key" class="ob-prereq" :class="{ 'is-danger': p.danger }">
<Checkbox v-model="prereqChecked[p.key]" />
<span class="ob-prereq-t">{{ p.label }}</span>
<span class="ob-prereq-tip">{{ p.tip }}</span>
</label>
</div>
<div class="ob-row" style="margin-top: 14px">
<Checkbox v-model="s2.configDone" />
<span style="margin-left: 8px">我已完成上述配置部署</span>
<span class="ob-tip" style="margin-left: 12px">计入第 ⑤ 步判分</span>
</div>
</div>
<div class="ob-card">
<h3>E · 避坑速查(规范 §10)</h3>
<Collapse class="ob-pit">
<Panel name="1">
接入前必读的 10 条坑
<template #content>
<ol class="ob-pit-list">
<li><b>basePath 401</b>:填错/漏填 ⇒ 推送必 401 无限重试。</li>
<li><b>重复注册轮换 secret</b>:本地镜像行有效就别再 reg。</li>
<li><b>注册探活</b>:接收端点必须先上线再注册。</li>
<li><b>4xx 也无限重试</b>:流程中心只认 2xx,脏消息必须 200 吞掉。</li>
<li><b>验签用原始字节</b>:先反序列化再序列化会验签失败。</li>
<li><b>事件行与业务同事务</b>:拆开会导致重推被幂等吞掉丢单。</li>
<li><b>乱序是常态</b>:靠 instanceVersion 门 + 终态不可逆守卫。</li>
<li><b>bizId 是字符串</b>:无论数值与否都用字符串处理。</li>
<li><b>地址推导与漂移</b>:callback-url 可省略;reconcile-on-register 默认关。</li>
<li><b>待办在流程中心</b>:子应用不维护待办表,用 progress/instance 接口拉。</li>
</ol>
</template>
</Panel>
</Collapse>
</div>
</div>
<!-- ============ STEP③ 回调注册 ============ -->
<div v-show="step === 2" class="ob-body">
<div class="ob-card">
<div class="fix">
<h3 class="fl">登记行({{ appRegs.length }} 条)</h3>
<span class="fr">
<Button size="small" :loading="regLoading" @click="loadRegs">刷新</Button>
<Button size="small" type="primary" style="margin-left: 8px" @click="checkRegs">检查注册</Button>
<Button size="small" style="margin-left: 8px" :loading="probing" @click="probeFirst">发一条 flow.ping</Button>
</span>
</div>
<p class="ob-desc">
登记由子应用在「管理端保存绑定」时调 <code>ensureRegistered</code> 完成,向导只检查不代注册。
<span v-if="regCheckedAt">上次检查:{{ regCheckedAt }}</span>
</p>
<Table :columns="[
{ title: '流程 GUID', key: 'flowGuid', minWidth: 200 },
{ title: '回调地址', key: 'callbackUrl', minWidth: 240 },
{ title: 'base_path', key: 'basePath', width: 110, render: (h: any, p: any) => h('span', p.row.basePath || '(空)') },
{ title: '状态', width: 80, align: 'center', render: (h: any, p: any) => h(Tag, { color: p.row.status === 0 ? 'green' : 'gray' }, () => p.row.status === 0 ? '启用' : '停用') },
]" :data="appRegs" :loading="regLoading" size="small" max-height="260"
no-data-text="该应用暂无登记行(未接入:回第②步配置后,在管理端保存绑定触发注册)" />
<div v-if="regCheck.length" class="ob-results">
<div v-for="(c, i) in regCheck" :key="i" class="ob-result" :class="'is-' + c.level">
<b>{{ c.level === 'ok' ? '✓ 通过' : c.level === 'warn' ? '⚠ 告警' : '✗ 失败' }}</b>
<span>{{ c.text }}</span>
<em v-if="c.advice">建议:{{ c.advice }}</em>
</div>
</div>
<div v-if="probeResult" class="ob-result" :class="probeResult.success ? 'is-ok' : 'is-warn'">
<b>{{ probeResult.success ? '✓ 探测成功' : '⚠ 探测失败' }}</b>
<span>HTTP {{ probeResult.httpStatus ?? '?' }} · 耗时 {{ probeResult.elapsedMs ?? '?' }} ms<span v-if="probeResult.respSnippet"> · 响应片段:{{ probeResult.respSnippet }}</span><span v-if="probeResult.error"> · {{ probeResult.error }}</span></span>
<em v-if="!probeResult.success">401 → 查 base-path 与 trust-registry;拒连/未知主机 → 查登记地址是否网关形态</em>
</div>
</div>
</div>
<!-- ============ STEP④ 流程编排 ============ -->
<div v-show="step === 3" class="ob-body">
<div class="ob-card">
<h3>流程定义</h3>
<p class="ob-desc">至少需要一个「已发布」流程;发起时用它的 flowGuid。</p>
<Table :columns="[
{ title: '流程', key: 'name', minWidth: 180 },
{ title: 'flowGuid', key: 'flowGuid', minWidth: 200 },
{ title: '状态', width: 90, align: 'center', render: (h: any, p: any) => h(Tag, { color: p.row.status === 1 ? 'green' : p.row.status === 0 ? 'orange' : 'gray' }, () => DEF_STATUS[p.row.status] || p.row.status) },
{ title: '操作', width: 90, align: 'center', render: (h: any, p: any) => h(Button, { type: 'text', size: 'small', onClick: () => inspectDef(p.row.id) }, () => '查看条件') },
]" :data="defs" :loading="defLoading" size="small" max-height="220"
no-data-text="该应用无流程定义(先回第①步确认应用,或去流程定义页新建)" />
</div>
<div class="ob-card" v-if="condNodes.length">
<h3>条件路由变量契约</h3>
<p class="ob-desc">
EXPRESSION 模式的判断依据就是子应用 apply 时 <code>formData</code> 的顶层键。这里把表达式引用的变量与
已知键(近 50 条办件 formData 聚合 + 内置 8 个)核对 —— 引用了从未出现的键,运行时会<b>挂起</b>。
</p>
<div v-for="(n, i) in condNodes" :key="i" class="ob-cond">
<div class="ob-cond-h">
<b>{{ n.name }}</b>
<Tag :color="n.judgeMode === 'EXPRESSION' ? 'blue' : 'purple'" style="margin-left: 8px">
{{ n.judgeMode === 'EXPRESSION' ? '表达式' : '外部判断服务' }}
</Tag>
<span v-if="n.routerKey" class="ob-cond-k">routerKey = {{ n.routerKey }}</span>
</div>
<div v-for="(o, j) in n.outlets" :key="j" class="ob-cond-o">
<span class="ob-cond-on">出口「{{ o.name }}」</span>
<code class="ob-cond-e">{{ o.expr || '(无表达式)' }}</code>
<span v-if="o.unknown.length" class="ob-cond-u">⚠ 引用了未在已知变量中的:<b>{{ o.unknown.join('、') }}</b> —— 疑似拼错,运行时会挂起</span>
<span v-else-if="o.expr" class="ob-cond-ok">✓ 变量均在已知集合内</span>
</div>
</div>
</div>
<div class="ob-card" v-else-if="selectedDefId">
<p class="ob-desc">所选流程没有 CONDITION 节点(无需变量契约核对)。</p>
</div>
</div>
<!-- ============ STEP⑤ 联调验收 ============ -->
<div v-show="step === 4" class="ob-body">
<div class="ob-card">
<div class="fix">
<h3 class="fl">六项自检</h3>
<Button class="fr" size="small" type="primary" :loading="acceptLoading" @click="runAcceptance">执行六项自检</Button>
</div>
<p class="ob-desc">把「手册的诊断命令集」变成按钮。1/3/5 必须通过;2/4 黄色可带病通过;6 无数据不扣分。</p>
<div v-if="!acceptDone" class="ob-empty">尚未执行自检</div>
<div v-else class="ob-results">
<div v-for="r in accept" :key="r.no" class="ob-result" :class="'is-' + r.level">
<b>{{ r.no }}. {{ r.name }} —— {{ r.level === 'ok' ? '✓ 通过' : r.level === 'warn' ? '⚠ 关注' : '✗ 失败' }}</b>
<span>{{ r.detail }}</span>
<em v-if="r.advice">建议:{{ r.advice }}</em>
</div>
</div>
</div>
</div>
<!-- 底部操作条 -->
<div class="ob-foot">
<Button size="large" :disabled="step === 0" @click="step > 0 && step--">上一步</Button>
<span class="ob-foot-r">
<span v-if="step === 1 && !prereqAllDone" class="ob-foot-hint">尚有前置条件未勾选,仍可继续</span>
<span v-if="step === 3 && !hasPublished" class="ob-foot-hint">尚无已发布流程,联调验收第 5 项会失败</span>
<Button size="large" v-if="step < 4" type="primary" @click="goto(step + 1)">下一步</Button>
</span>
</div>
</div>
<!-- 接入自检报告 -->
<Drawer v-model="reportVisible" title="接入自检报告" :width="720" :mask-closable="false">
<p class="ob-desc" style="margin-bottom: 10px">纯前端拼装、不落库。可直接复制进工单或群。</p>
<pre class="ob-code ob-report">{{ reportText }}</pre>
<template #footer>
<Button @click="reportVisible = false">关闭</Button>
<Button type="primary" @click="copyText(reportText.value, '报告已复制')">复制报告</Button>
</template>
</Drawer>
</div>
</template>
<style scoped>
.ob-wrap { display: flex; flex-direction: column; padding: 16px 20px; overflow: auto; }
.ob-head { margin-bottom: 4px; }
.ob-title { font-size: 18px; font-weight: 600; color: #17233d; }
.ob-sub { margin-left: 14px; color: #808695; font-size: 12px; line-height: 34px; }
/* 步骤条 */
.ob-steps { display: flex; gap: 8px; margin: 14px 0 18px; }
.ob-step { flex: 1; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #e5e6eb; border-radius: 4px; cursor: pointer; background: #fff; transition: all .15s; }
.ob-step:hover { border-color: #2d8cf0; }
.ob-step.is-cur { border-color: #2d8cf0; background: #f0f7ff; }
.ob-step.is-done { opacity: .75; }
.ob-num { flex: none; width: 24px; height: 24px; border-radius: 50%; background: #e5e6eb; color: #515a6e; text-align: center; line-height: 24px; font-weight: 600; font-size: 13px; }
.ob-step.is-cur .ob-num { background: #2d8cf0; color: #fff; }
.ob-step.is-done .ob-num { background: #19a35a; color: #fff; }
.ob-st { display: flex; flex-direction: column; line-height: 1.35; min-width: 0; }
.ob-st b { font-size: 13px; color: #17233d; }
.ob-st em { font-style: normal; font-size: 11px; color: #98a1b3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ob-body { overflow: auto; }
.ob-card { background: #fff; border: 1px solid #e5e6eb; border-radius: 4px; padding: 16px 18px; margin-bottom: 14px; }
.ob-card h3 { font-size: 14px; color: #17233d; margin-bottom: 4px; }
.ob-desc { color: #808695; font-size: 12px; line-height: 1.7; margin-bottom: 12px; }
.ob-desc code { background: #f4f6f9; padding: 1px 4px; border-radius: 3px; font-family: Consolas, Menlo, monospace; }
.ob-form { display: flex; flex-direction: column; gap: 14px; }
.ob-row { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.ob-lab { flex: none; width: 168px; text-align: right; line-height: 32px; color: #515a6e; font-size: 13px; }
.ob-lab i { color: #e03e3e; font-style: normal; margin-left: 2px; }
.ob-tip { line-height: 32px; color: #98a1b3; font-size: 12px; }
.ob-warn-txt { line-height: 32px; color: #d98106; font-size: 12px; }
.ob-err { line-height: 32px; color: #e03e3e; font-size: 12px; }
.ob-code { position: relative; background: #f7f8fa; border: 1px solid #e5e6eb; border-radius: 4px; padding: 12px 14px; font-family: Consolas, Menlo, monospace; font-size: 12px; line-height: 1.6; color: #314659; white-space: pre-wrap; word-break: break-all; margin: 0; }
.ob-code.is-warn { background: #fff8e6; border-color: #f0d9a0; }
.ob-copy { margin-top: 8px; }
.ob-report { max-height: 60vh; overflow: auto; }
.ob-alert { margin-top: 10px; background: #fff5e6; border: 1px solid #f0d9a0; border-radius: 4px; padding: 8px 12px; color: #b26a00; font-size: 12px; line-height: 1.6; }
.ob-alert code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 3px; }
.ob-prereqs { display: flex; flex-direction: column; gap: 10px; }
.ob-prereq { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border: 1px solid #e5e6eb; border-radius: 4px; }
.ob-prereq.is-danger { border-color: #f5c2c2; background: #fff5f5; }
.ob-prereq-t { font-size: 13px; color: #314659; line-height: 20px; }
.ob-prereq-tip { font-size: 11px; color: #98a1b3; line-height: 20px; margin-left: auto; text-align: right; max-width: 46%; }
.ob-pit-list { padding-left: 20px; color: #515a6e; font-size: 12px; line-height: 1.9; }
.ob-pit-list b { color: #314659; }
/* 检查结果 */
.ob-results { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.ob-result { border-radius: 4px; padding: 10px 12px; font-size: 12px; line-height: 1.7; border: 1px solid #e5e6eb; background: #f7f8fa; }
.ob-result b { display: block; margin-bottom: 2px; }
.ob-result em { display: block; font-style: normal; color: #808695; margin-top: 2px; }
.ob-result.is-ok { background: #eefaf2; border-color: #b7e4c7; }
.ob-result.is-ok b { color: #19a35a; }
.ob-result.is-warn { background: #fff8e6; border-color: #f0d9a0; }
.ob-result.is-warn b { color: #d98106; }
.ob-result.is-bad { background: #fdecec; border-color: #f5c2c2; }
.ob-result.is-bad b { color: #e03e3e; }
.ob-empty { color: #98a1b3; font-size: 12px; text-align: center; padding: 30px 0; }
/* 条件契约 */
.ob-cond { border: 1px solid #e5e6eb; border-radius: 4px; padding: 12px 14px; margin-bottom: 10px; }
.ob-cond-h { display: flex; align-items: center; margin-bottom: 8px; }
.ob-cond-h b { font-size: 13px; color: #17233d; }
.ob-cond-k { margin-left: auto; color: #98a1b3; font-size: 11px; font-family: Consolas, Menlo, monospace; }
.ob-cond-o { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 4px 0; font-size: 12px; }
.ob-cond-on { color: #515a6e; flex: none; }
.ob-cond-e { background: #f4f6f9; padding: 2px 6px; border-radius: 3px; font-family: Consolas, Menlo, monospace; color: #314659; }
.ob-cond-u { color: #d98106; }
.ob-cond-ok { color: #19a35a; }
.ob-foot { display: flex; align-items: center; justify-content: space-between; padding: 12px 0 4px; border-top: 1px solid #e5e6eb; margin-top: auto; }
.ob-foot-r { display: flex; align-items: center; gap: 12px; }
.ob-foot-hint { color: #d98106; font-size: 12px; }
</style>
+43 -18
View File
@@ -3,20 +3,30 @@
// CONDITION 节点的 routerKey 必须命中启用中的注册(发布校验口径);basePath 是对端 context-path,
// 漏填必 401(规范 §4.2 同款坑)——生产 notice 为 /notice,本地为空。
import { ref, onMounted } from "vue";
import { Message } from "view-ui-plus";
import { Message, Tag } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
import { flowApi } from "@/api/module/flow";
import Utils from "@/utils";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const loading = ref(false);
const rows = ref<any[]>([]);
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
// 无搜索分页脚:表格高度实测 .table-region(flex:1)
const routerRegionRef = ref<HTMLElement | null>(null);
const routersH = useElementHeight(routerRegionRef);
const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "注册键", key: "routerKey", width: 220 },
{ title: "名称", key: "name", width: 160 },
{ title: "服务目录键", key: "serviceKey", width: 110 },
{
// 2026-10-12 SERVICE 收口:reg_app_key 非空=yh-flow-callback starter 自注册行(归属应用)
title: "来源",
width: 160,
render: (h: any, p: any) => h("span", p.row.regAppKey ? `自注册 · ${p.row.regAppKey}` : "手工"),
},
{ title: "判断端点", key: "path", minWidth: 300 },
{
title: "basePath",
@@ -28,21 +38,16 @@ const columns = [
width: 90,
align: "center",
render: (h: any, p: any) =>
h("Tag", { color: p.row.status === 1 ? "green" : "gray" }, () => (p.row.status === 1 ? "启用" : "停用")),
h(Tag, { color: p.row.status === 1 ? "green" : "gray" }, () => (p.row.status === 1 ? "启用" : "停用")),
},
{
title: "操作",
width: 150,
align: "center",
fixed: "right",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)),
h("span", { style: "display:inline-block;width:10px" }),
p.row.status === 1
? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
: Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)),
actionColumn(
[
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => onEdit(r) },
{ key: "disable", label: "停用", icon: "icon-stop", on: (r: any) => onDisable(r), disabledWhen: (r: any) => r.status !== 1, danger: true },
{ key: "enable", label: "启用", icon: "icon-play", on: (r: any) => onEnable(r), disabledWhen: (r: any) => r.status === 1 },
],
},
{ mode: "text", width: 150, fixed: "right" },
),
];
async function load() {
@@ -131,14 +136,22 @@ function onEnable(row: any) {
});
}
// ---- 表达式调试(2026-10-10 四议题;2026-10-12 议题三改为三模式共享弹窗 ExprEvalModal)----
const exprVisible = ref(false);
function onEval() {
exprVisible.value = true;
}
onMounted(load);
</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="onAdd">新增注册</Button>
<Button class="fl" size="large" style="margin-left: 8px" @click="onEval">表达式调试</Button>
</div>
<div class="search-wrap fix">
@@ -148,8 +161,10 @@ onMounted(load);
</span>
</div>
<div class="table-region" ref="routerRegionRef">
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="tableHeight" no-data-text="暂无数据" />
:height="routersH || undefined" no-data-text="暂无数据" />
</div>
<Modal v-model="saveVisible" title="新增/编辑判断服务" :mask-closable="false">
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="100">
@@ -177,6 +192,16 @@ onMounted(load);
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
</template>
</Modal>
<ExprEvalModal v-model="exprVisible" />
</div>
</div>
</template>
<style scoped>
/* 无分页脚:通道只对含 .page-panel 的卡片自动转纵向分栏,本页自声明保证 table-region 撑满 */
.main-container {
display: flex;
flex-direction: column;
}
</style>
@@ -0,0 +1,226 @@
/**
* 审批节点「预览候选人」(FL上线独立验收报告 P2-1:/assign-preview 页存在但编辑器内无入口)。
*
* 口径:
* - 只读。解析当前抽屉里的**草稿配置**,不写回节点、不改流程定义、不建实例。
* - 数据全部来自既有接口(科室成员 / UserBase 用户检索 / 发起预览),前端不重实现人员解析:
* DEPT_KEY 读的是后端 AssigneeResolver 同一张 flw_dept_member,USERS 读的是 UserBase byId。
* - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。
*/
import { describeFlowError } from "@/utils/flowError";
export type PreviewAssigneeType = "DEPT_KEY" | "USERS";
export interface PreviewAssignPolicy {
mode?: string;
min?: number | null;
max?: number | null;
required?: boolean;
}
/** 抽屉里的当前(可能未保存)节点配置 */
export interface PreviewNodeConfig {
uid: string;
name: string;
type: PreviewAssigneeType;
deptKey: string;
userIds: number[];
countersign: "ALL" | "ANY";
assignPolicy: PreviewAssignPolicy | null | undefined;
}
export interface PreviewDept {
deptKey: string;
deptName: string;
count: number;
}
export interface CandidateRow {
userId: number;
userName: string;
/** 该候选人不计入/存在风险的说明;空=正常 */
warn?: string;
}
/** 已发布版本的发起预览对账结果 */
export interface PublishedView {
state: "ok" | "missing" | "unavailable";
defVersion?: number;
rows: CandidateRow[];
text: string;
}
export interface NodePreviewResult {
/** 是否解析出了可用候选人(false 时一定有 blocked 或 published.unavailable 解释) */
ok: boolean;
headline: string;
rows: CandidateRow[];
blocked?: string;
published?: PublishedView;
notes: string[];
}
export interface PreviewApi {
deptMembers: (deptId: string) => Promise<any[]>;
lookupUser: (userId: number) => Promise<any>;
applyPreview: (flowGuid: string) => Promise<any>;
}
/** flw_dept_member.status / flw_dept.status:0=启用(与后端 resolveEnabled 同口径) */
const DEPT_MEMBER_ENABLED = 0;
function countersignText(c: "ALL" | "ANY"): string {
return c === "ALL" ? "会签(全部同意才通过)" : "或签(任一人同意即通过)";
}
function assignPolicyText(p: PreviewAssignPolicy | null | undefined): string {
if (!p) return "";
const mode = p.mode === "SINGLE" ? "单选 1 人" : `多选 ${p.min ?? 1}~${p.max ?? "不限"} 人`;
return `;发起时指派:${mode}${p.required ? " · 必选" : ""}`;
}
/** UserBase BoaUser.status:1=启用(后端 D11 终审按此拒绝禁用/未知状态账号) */
function userRow(u: any, id: number): CandidateRow {
if (!u) return { userId: id, userName: `#${id}`, warn: "UserBase 查不到该用户,正式发起时会被拒绝" };
const status = typeof u.status === "number" ? u.status : null;
return {
userId: id,
userName: u.name || `#${id}`,
warn: status === 1 ? undefined : `账号状态${status == null ? "未知" : status}(非启用),正式办理时会被服务端拒绝`,
};
}
async function deptRows(deptKey: string, api: PreviewApi): Promise<CandidateRow[]> {
const members = (await api.deptMembers(deptKey)) || [];
return members.map((m: any) => ({
userId: Number(m.userId),
userName: m.userName || `#${m.userId}`,
warn: Number(m.status) === DEPT_MEMBER_ENABLED ? undefined : "该成员已停用,不计入候选",
}));
}
/** 已发布版本对账:只有本线已保存(有 flowGuid)时才有可比对象 */
export async function loadPublishedView(
cfg: PreviewNodeConfig,
flowGuid: string,
api: PreviewApi,
): Promise<PublishedView> {
if (!flowGuid) {
return { state: "unavailable", rows: [], text: "本流程线尚未保存,没有可比对的已发布版本(预览按当前草稿配置解析)。" };
}
let res: any;
try {
res = await api.applyPreview(flowGuid);
} catch (e: any) {
return { state: "unavailable", rows: [], text: `已发布版本预览不可用:${describeFlowError(e)}` };
}
const version = Number(res?.defVersion) || undefined;
const hit = (res?.nodes || []).find((n: any) => String(n.nodeId) === String(cfg.uid));
if (!hit) {
return {
state: "missing",
defVersion: version,
rows: [],
text: `已发布版本 v${version ?? "?"} 的发起预览里没有本节点:要么它未开启发起时指派,要么是尚未发布的草稿节点。`,
};
}
const rows: CandidateRow[] = (hit.candidates || []).map((c: any) => ({
userId: Number(c.userId),
userName: c.userName || `#${c.userId}`,
}));
return {
state: "ok",
defVersion: version,
rows,
text: `已发布版本 v${version ?? "?"} 解析出的可指派候选人(发起人实际可选范围以此为准,本节点${rows.length ? "" : "当前无候选人"})。`,
};
}
/**
* 解析当前节点配置的候选人。
* depts 传编辑器的科室上下文(含启用人数),用于把「科室不存在/0 人」讲清楚而不靠猜。
*/
export async function resolveNodePreview(
cfg: PreviewNodeConfig,
depts: PreviewDept[],
api: PreviewApi,
flowGuid = "",
): Promise<NodePreviewResult> {
const notes = [
`多人审批方式:${countersignText(cfg.countersign)}${assignPolicyText(cfg.assignPolicy)}`,
"预览只读:不写回节点、不改动流程定义;最终办理资格由 FL 服务端在正式发起时复核。",
];
const published = await loadPublishedView(cfg, flowGuid, api);
if (cfg.type === "DEPT_KEY") {
if (!cfg.deptKey) {
return { ok: false, headline: "按科室映射解析", rows: [], blocked: "尚未选择科室映射,请先在「科室映射」里选一个科室。", published, notes };
}
const dept = depts.find((d) => d.deptKey === cfg.deptKey);
if (!dept) {
return {
ok: false,
headline: "按科室映射解析",
rows: [],
blocked: `科室映射「${cfg.deptKey}」不在本校科室列表中(可能已删除),发布会被拦截。`,
published,
notes,
};
}
let rows: CandidateRow[];
try {
rows = await deptRows(cfg.deptKey, api);
} catch (e: any) {
return {
ok: false,
headline: `科室「${dept.deptName || dept.deptKey}」`,
rows: [],
blocked: `读取科室成员失败:${describeFlowError(e)}`,
published,
notes,
};
}
const enabled = rows.filter((r) => !r.warn);
if (!enabled.length) {
return {
ok: false,
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})`,
rows,
blocked: `科室「${dept.deptName || dept.deptKey}」当前 0 名启用办理人,发布会被拦截;请到「办理人映射」补人。`,
published,
notes,
};
}
if (rows.length !== enabled.length) {
notes.unshift(`共 ${rows.length} 名成员,其中 ${enabled.length} 名启用(停用成员已在下表标注)。`);
}
return {
ok: true,
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})· ${enabled.length} 名启用办理人`,
rows,
published,
notes,
};
}
if (!cfg.userIds.length) {
return { ok: false, headline: "按指定用户解析", rows: [], blocked: "尚未选择用户,请先点「选择用户」添加审批人。", published, notes };
}
const rows: CandidateRow[] = [];
for (const id of cfg.userIds) {
try {
rows.push(userRow(await api.lookupUser(Number(id)), Number(id)));
} catch (e: any) {
rows.push({ userId: Number(id), userName: `#${id}`, warn: `用户检索失败:${describeFlowError(e)}` });
}
}
const usable = rows.filter((r) => !r.warn);
return {
ok: usable.length > 0,
headline: `指定用户 · ${rows.length} 人(${usable.length} 人可检索到且状态启用)`,
rows,
blocked: usable.length ? undefined : "指定的用户全部查不到或状态非启用,发布会被拦截。",
published,
notes,
};
}
@@ -1,110 +0,0 @@
/**
* bpmn-js 定制模块:调色板/上下文垫收敛为 4 类业务节点 + 汉化。
* 拖拽/多选/撤销重做/缩放平移均为 bpmn-js 内建能力,此处无需实现。
* 调色板与上下文垫只暴露业务语义的 5 类元素(开始/结束/审批/并行分发/并行合并用同一 ParallelGateway,
* split/join 由 serialize.ts 以扩展属性 flowNodeType 显式标记),其余 BPMN 形状对管理员隐藏。
* bpmn.io 水印为协议要求,不做隐藏。
*/
import Modeler from "bpmn-js/lib/Modeler";
import PaletteProvider from "bpmn-js/lib/features/palette/PaletteProvider";
// 必需:diagram-js 画布/连线/调色板样式 + bpmn 字体图标;漏引会导致连线变黑条、黑锚点外露、左侧工具栏不可见
import "diagram-js/assets/diagram-js.css";
import "bpmn-js/dist/assets/bpmn-js.css";
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
/** 汉化词表(palette/contextPad/常用按钮;未命中键原样透回) */
const TRANSLATIONS: Record<string, string> = {
"Create StartEvent": "创建开始",
"Create EndEvent": "创建结束",
"Create Task": "创建审批节点",
"Create User Task": "创建审批节点",
"Create Service Task": "创建抄送节点",
"Create Gateway": "创建并行分支",
"Create Gateways": "创建并行分支",
"Create ParallelGateway": "创建并行分支",
"Create expanded ParallelGateway": "创建并行分支",
"Create ExclusiveGateway": "创建条件分支",
"Create expanded ExclusiveGateway": "创建条件分支",
"Append anything": "追加",
"Change type": "变更类型",
"Remove element": "删除",
Connect: "连线",
"Connect to other element": "连线到其他元素",
"Connect using Association/Sequence/DataAssociation": "连线",
"Activate the global connect tool": "激活全局连线工具",
"Activate the create/remove space tool": "激活空间调整工具",
"Activate the lasso tool": "激活套索选择",
"Activate the hand tool": "激活平移",
"Activate the direct editing tool": "激活文本编辑",
undo: "撤销",
redo: "重做",
};
function createTranslateModule() {
return {
translate: [
"value",
(template: string, replacements?: Record<string, string>) => {
let t = TRANSLATIONS[template] || template;
if (replacements) {
for (const [k, v] of Object.entries(replacements)) {
t = t.replace(`{${k}}`, v);
}
}
return t;
},
],
};
}
/** 调色板收敛:仅连线工具 + 4 类节点 */
function createPaletteModule() {
function CustomPaletteProvider(this: unknown, palette: unknown, create: unknown, elementFactory: unknown,
globalConnect: unknown, translate: unknown) {
// @ts-expect-error 继承自 bpmn-js 内部父类(js 库,无类型面板)
PaletteProvider.call(this, palette, create, elementFactory, globalConnect, translate);
this.create = create;
this.elementFactory = elementFactory;
this.globalConnect = globalConnect;
this.translate = translate;
}
CustomPaletteProvider.$inject = ["palette", "create", "elementFactory", "globalConnect", "translate"];
CustomPaletteProvider.prototype = Object.create(PaletteProvider.prototype);
CustomPaletteProvider.prototype.getPaletteEntries = function (this: { elementFactory: unknown } & Record<string, unknown>) {
const { create, elementFactory, translate, globalConnect } = this as Record<string, any>;
const action = (type: string, label: string, className: string) => ({
group: "flow",
className,
title: translate(label),
action: {
dragstart: (evt: unknown) => create.start(evt, elementFactory.createShape({ type })),
click: (evt: unknown) => create.start(evt, elementFactory.createShape({ type })),
},
});
return {
"lc-connect": {
group: "tools",
className: "bpmn-icon-connection-multi",
title: translate("连线"),
action: { click: (evt: unknown) => globalConnect.toggle(evt) },
},
"lc-sep": { group: "tools", separator: true },
"lc-start": action("bpmn:StartEvent", "创建开始", "bpmn-icon-start-event-none"),
"lc-end": action("bpmn:EndEvent", "创建结束", "bpmn-icon-end-event-none"),
"lc-task": action("bpmn:UserTask", "创建审批节点", "bpmn-icon-user-task"),
"lc-notify": action("bpmn:ServiceTask", "创建抄送节点", "bpmn-icon-send-task"),
"lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"),
"lc-condition": action("bpmn:ExclusiveGateway", "创建条件分支", "bpmn-icon-gateway-xor"),
};
};
return { __init__: ["paletteProvider"], paletteProvider: ["type", CustomPaletteProvider] };
}
/** 创建设计器实例(调色板收敛 + 汉化;快捷键绑定 window:Ctrl+Z 撤销等) */
export function createDesigner(container: HTMLElement): Modeler {
return new Modeler({
container,
keyboard: { bindTo: window },
additionalModules: [createPaletteModule(), createTranslateModule()],
});
}
@@ -1,277 +0,0 @@
/**
* contentJson(+layout) ⇄ bpmn-js 内存图 双向转换(画布模式数据通道)。
*
* 设计决策(方案 §二):BPMN XML 不作为持久化格式——contentJson 仍是唯一运行时格式,
* bpmn-js 仅作内存画布。节点映射:START→StartEvent / END→EndEvent / APPROVAL→UserTask /
* PARALLEL_SPLIT|JOIN→ParallelGateway,用扩展属性 flowNodeType 显式区分 split/join
* (不靠出入度推断,用户编辑连线后语义不漂移);出边 name 即多出口按钮文案。
* 坐标缺失(旧定义无 layout)时用共享 flowLayout 算初始坐标。
*/
import { layout as autoLayout } from "@/utils/flowLayout";
/** bpmn 画布尺寸(与 flowLayout 的槽位框不同——bpmn 内建元素有自己尺寸,就不指定 shape 尺寸) */
const CANVAS_GAP_X = 96;
const CANVAS_ROW_H = 96;
const CANVAS_START = { x: 160, y: 160 };
/** 画布模式读写接口(与后端 contentJson 同构,另含可选 layout 坐标) */
export interface CanvasJson {
code: string;
name: string;
start: string;
nodes: Array<Record<string, unknown>>;
edges: Array<Record<string, unknown>>;
settings: Record<string, unknown>;
layout?: Record<string, { x: number; y: number }>;
}
const BPMN_TYPE: Record<string, string> = {
START: "bpmn:StartEvent",
END: "bpmn:EndEvent",
APPROVAL: "bpmn:UserTask",
NOTIFY: "bpmn:ServiceTask",
PARALLEL_SPLIT: "bpmn:ParallelGateway",
PARALLEL_JOIN: "bpmn:ParallelGateway",
CONDITION: "bpmn:ExclusiveGateway",
};
/** 把 contentJson 画到画布上(缺 layout 时自动布局) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function toBpmn(modeler: any, def: CanvasJson): void {
const nodes = def.nodes || [];
const edges = def.edges || [];
// 自动布局:有保存坐标用保存坐标,否则 Kahn 分层换算为 bpmn 网格
let pos: Record<string, { x: number; y: number }> = def.layout || (undefined as never);
if (!pos || !Object.keys(pos).length) {
const auto = autoLayout({ nodes, edges });
pos = {};
Object.entries(auto).forEach(([id, p]) => {
pos![id] = { x: CANVAS_START.x + p.x + p.w / 2, y: CANVAS_START.y + p.y + p.h / 2 };
});
}
const modeling = modeler.get("modeling");
const canvas = modeler.get("canvas");
const root = canvas.getRootElement();
const byId: Record<string, unknown> = {};
nodes.forEach((n) => {
const type = BPMN_TYPE[String(n.type)];
if (!type) return; // 未知类型不渲染(local validate 会提示)
const p = pos[String(n.id)] || CANVAS_START;
const shape = modeling.createShape({ type }, { x: p.x, y: p.y }, root);
// id 必须显式落 businessObject:配置回填与跨会话稳定绑定都按 contentJson 的节点 id 走
// (若不设,bpmn-js 生成 Task_xxx 随机 id,二次载入时审批人/会签等配置将失配丢失)
// 节点名(直编可改);split/join 语义由「载入显式类型 / 读出按出入度」两段规则维持,存储在 contentJson
modeling.updateProperties(shape, { id: String(n.id), name: String(n.name || "") });
byId[String(n.id)] = shape;
});
edges.forEach((e) => {
const src = byId[String(e.from)];
const tgt = byId[String(e.to)];
if (!src || !tgt) return;
// connect(source, target) 按规则自动建 SequenceFlow
const conn = modeling.connect(src, tgt);
if (e.name) {
modeling.updateProperties(conn, { name: String(e.name) });
}
});
}
/** bpmn 内存图 → {nodes,edges,layout}:遍历 elementRegistry 序列化回 contentJson 主体 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: string): CanvasJson {
const registry = modeler.get("elementRegistry");
const all = registry.getAll();
const nodes: Array<Record<string, unknown>> = [];
const edges: Array<Record<string, unknown>> = [];
const layoutOut: Record<string, { x: number; y: number }> = {};
let startId = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeTypeOf = (n: any): string | null => {
switch (n.type) {
case "bpmn:StartEvent": {
return "START";
}
case "bpmn:EndEvent": {
return "END";
}
case "bpmn:UserTask": {
return "APPROVAL";
}
case "bpmn:ServiceTask": {
return "NOTIFY";
}
case "bpmn:ParallelGateway": {
// 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型)
return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT";
}
case "bpmn:ExclusiveGateway": {
// CONDITION 无 join 对应物(排他网关不用于汇聚):多入口也按条件分支读出
return "CONDITION";
}
default:
return null;
}
};
// 节点业务配置(审批人等)以 base 内容继承,这里只做几何/连线
all.forEach((n: any) => {
const type = nodeTypeOf(n);
if (!type) return;
const biz = n.businessObject || {};
const node: Record<string, unknown> = {
id: String(biz.id || n.id),
type,
};
if (biz.name) node.name = biz.name;
if (type === "APPROVAL" || type === "NOTIFY" || type === "CONDITION") {
// 审批人/抄送对象/判断服务等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置)
const prev = base.nodes.find((x) => String(x.id) === String(node.id));
if (prev) {
const keys = type === "APPROVAL"
? ["assignee", "countersign", "deadlineHours"]
: type === "NOTIFY" ? ["receivers"] : ["routerKey"];
keys.forEach((k) => {
if (prev[k] !== undefined) node[k] = prev[k];
});
if (!node.name && prev.name) node.name = prev.name;
}
}
nodes.push(node);
layoutOut[String(node.id)] = { x: Math.round(n.x), y: Math.round(n.y) };
if (type === "START") startId = String(node.id);
});
all.forEach((n: any) => {
(n.outgoing || []).forEach((c: any) => {
if (c.type !== "bpmn:SequenceFlow") return;
const target = c.target;
if (!target) return;
const edge: Record<string, unknown> = {
from: String(n.businessObject.id || n.id),
to: String(target.businessObject.id || target.id),
};
if (c.businessObject?.name) edge.name = c.businessObject.name;
edges.push(edge);
});
});
if (!startId && nodes.length) {
throw new Error("画布缺少开始节点");
}
// 已有定义继承 settings/审批配置由 base 传入
return {
code,
name,
start: startId,
nodes,
edges,
settings: base?.settings || { rejectPolicy: "END_REJECTED" },
layout: layoutOut,
};
}
/** contentJson → CanvasJson(对既有 struct 直接加 layout) */
export function toCanvasJson(contentJson: string | CanvasJson): CanvasJson {
if (typeof contentJson === "string") {
return JSON.parse(contentJson) as CanvasJson;
}
return contentJson;
}
/** 查看器 BPMN DI 形状尺寸(事件 36、网关 50、任务两行文本 150x76) */
const DI_SIZE: Record<string, { w: number; h: number }> = {
START: { w: 36, h: 36 },
END: { w: 36, h: 36 },
APPROVAL: { w: 150, h: 76 },
NOTIFY: { w: 150, h: 76 },
PARALLEL_SPLIT: { w: 50, h: 50 },
PARALLEL_JOIN: { w: 50, h: 50 },
CONDITION: { w: 50, h: 50 },
};
/**
* contentJson(+layout) → 可导入只读 Viewer 的 BPMN XML(含 DI 布局)。
* 画布视图专用:bpmn-js Viewer 没有 modeling 模块,不能走 toBpmn 的程序化布点,
* 直接生成带 BPMNShape/BPMNEdge 的 XML 一次导入;连线给正交 waypoints(右中→中线→左中)。
*/
export function toBpmnXml(def: CanvasJson): string {
const nodes: any[] = def.nodes || [];
const edges: any[] = def.edges || [];
// 元素中心点:layout 覆盖全部节点用 layout(编辑器保存的是中心坐标),否则回退自动布局
const saved = def.layout || {};
const useSaved = nodes.length > 0 && nodes.every((n) => saved[String(n.id)]);
const centers: Record<string, { x: number; y: number }> = {};
if (useSaved) {
nodes.forEach((n) => { centers[String(n.id)] = saved[String(n.id)]; });
} else {
const auto = autoLayout({ nodes, edges });
nodes.forEach((n) => {
const p = auto[String(n.id)];
if (p) centers[String(n.id)] = { x: p.x + p.w / 2, y: p.y + p.h / 2 };
});
}
const esc = (s: unknown): string =>
String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const body = nodes.filter((n) => BPMN_TYPE[String(n.type)] && centers[String(n.id)]);
const flows = edges.filter((e) => centers[String(e.from)] && centers[String(e.to)]);
const elems = body
.map((n) => {
const tag = BPMN_TYPE[String(n.type)].replace("bpmn:", "");
const name = n.name ? ` name="${esc(n.name)}"` : "";
return ` <bpmn:${tag} id="${esc(n.id)}"${name} />`;
})
.join("\n");
const flowElems = flows
.map((e, i) => {
const name = e.name ? ` name="${esc(e.name)}"` : "";
return ` <bpmn:sequenceFlow id="flow_${i}"${name} sourceRef="${esc(e.from)}" targetRef="${esc(e.to)}" />`;
})
.join("\n");
const bounds = (id: string): { x: number; y: number; w: number; h: number } => {
const c = centers[id];
const type = String(nodes.find((n) => String(n.id) === id)?.type || "APPROVAL");
const s = DI_SIZE[type] || DI_SIZE.APPROVAL;
return { x: Math.round(c.x - s.w / 2), y: Math.round(c.y - s.h / 2), w: s.w, h: s.h };
};
const shapes = body
.map((n) => {
const b = bounds(String(n.id));
return ` <bpmndi:BPMNShape id="di_${esc(n.id)}" bpmnElement="${esc(n.id)}">
<dc:Bounds x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" />
</bpmndi:BPMNShape>`;
})
.join("\n");
const edgeDi = flows
.map((e, i) => {
const fb = bounds(String(e.from));
const tb = bounds(String(e.to));
const x1 = fb.x + fb.w;
const y1 = fb.y + fb.h / 2;
const x2 = tb.x;
const y2 = tb.y + tb.h / 2;
const mid = Math.round((x1 + x2) / 2);
const pts = Math.abs(y1 - y2) < 2
? [[x1, y1], [x2, y2]]
: [[x1, y1], [mid, y1], [mid, y2], [x2, y2]];
const wp = pts.map(([x, y]) => ` <di:waypoint x="${Math.round(x)}" y="${Math.round(y)}" />`).join("\n");
return ` <bpmndi:BPMNEdge id="di_flow_${i}" bpmnElement="flow_${i}">\n${wp}\n </bpmndi:BPMNEdge>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="lc_process" isExecutable="true">
${elems}
${flowElems}
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process">
${shapes}
${edgeDi}
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>`;
}
@@ -6,7 +6,9 @@ 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 { ApprovalNode, EDITOR_CTX } from "../model";
import AssigneePreviewModal from "./AssigneePreviewModal.vue";
import type { PreviewNodeConfig } from "../assignPreview";
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
const props = defineProps<{
modelValue: boolean;
@@ -28,6 +30,12 @@ const form = reactive({
userIds: [] as number[],
countersign: "ANY" as "ALL" | "ANY",
deadlineHours: null as number | null,
multiOutlet: false,
assignEnabled: false,
assignMode: "MULTI" as "SINGLE" | "MULTI",
assignMin: 1,
assignMax: null as number | null,
assignRequired: false,
});
const userCheckModal = ref<any>();
@@ -47,6 +55,12 @@ function syncFromNode(): void {
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
form.countersign = n.countersign;
form.deadlineHours = n.deadlineHours;
form.multiOutlet = !!(n.outlets && n.outlets.length >= 2);
form.assignEnabled = !!n.assignPolicy;
form.assignMode = n.assignPolicy?.mode === "SINGLE" ? "SINGLE" : "MULTI";
form.assignMin = n.assignPolicy?.min ?? 1;
form.assignMax = n.assignPolicy?.max ?? null;
form.assignRequired = !!n.assignPolicy?.required;
}
watch(
@@ -57,7 +71,7 @@ watch(
{ immediate: true }
);
/** 科室下拉项:mapList 聚合 + 当前值(可能已不在映射表中)兜底,避免选项丢失 */
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
const deptOptions = computed(() => {
const list = ctx.depts.map((d) => ({
value: d.deptKey,
@@ -102,6 +116,49 @@ function removeUser(id: number): void {
if (i >= 0) form.userIds.splice(i, 1);
}
// ===== 高级设置分层(P2 核心/高级):只挪位置,不改字段、不改数据语义 =====
/** 高级区是否展开;打开抽屉时若已有高级配置则默认展开,避免"配了但看不见" */
const advOpen = ref(false);
const activeAdvanced = computed(() => {
const hits: string[] = [];
if (form.countersign === "ALL") hits.push("会签");
if (form.assignEnabled) hits.push("发起时指派");
if (form.deadlineHours) hits.push("办理时限");
if (form.multiOutlet) hits.push("多出口");
return hits;
});
watch(() => [props.modelValue, props.node?.uid], () => {
if (props.modelValue) advOpen.value = activeAdvanced.value.length > 0;
}, { immediate: true });
// ===== 指派预览(验收 P2-1:编辑器内要有可发现入口) =====
const previewModal = ref<any>();
/** 用抽屉里的实时表单(含未保存改动)作为预览输入;预览全程只读,不回写节点 */
function previewConfig(): PreviewNodeConfig {
return {
uid: props.node?.uid || "",
name: form.name,
type: form.type,
deptKey: form.deptKey,
userIds: [...form.userIds],
countersign: form.countersign,
assignPolicy: form.assignEnabled
? {
mode: form.assignMode,
min: form.assignMin,
max: form.assignMax,
required: form.assignRequired,
}
: null,
};
}
function onPreviewCandidates(): void {
previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid);
}
function onOk(): void {
const n = props.node;
if (!n) {
@@ -119,6 +176,19 @@ function onOk(): void {
: { type: "DEPT_KEY", deptKey: form.deptKey };
n.countersign = form.countersign;
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
// 开启多出口(2026-10-03 P2):默认 同意/退回 两泳道各放一个待配置审批节点;
// 关闭不在抽屉做——泳道头部"恢复单出口"负责(要处理泳道内容的去向)
if (form.multiOutlet && !(n.outlets && n.outlets.length >= 2)) {
n.outlets = ["同意", "退回"];
n.branches = [[createApproval()], [createApproval()]];
}
n.assignPolicy = form.assignEnabled
? {
mode: form.assignMode,
...(form.assignMode === "MULTI" ? { min: form.assignMin || 1, max: form.assignMax ?? undefined } : {}),
required: form.assignRequired,
}
: undefined;
emit("update:modelValue", false);
}
@@ -181,6 +251,20 @@ function onRemove(): void {
</div>
</template>
<!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) -->
<div class="fld">
<Button size="small" type="primary" ghost @click="onPreviewCandidates">预览候选人</Button>
<div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div>
</div>
<!-- 高级能力:默认收起,已配置时自动展开并在收起态标出条目(不删除、不改语义) -->
<div class="adv-head" @click="advOpen = !advOpen">
<span class="adv-caret">{{ advOpen ? "▾" : "▸" }}</span>
<span class="adv-title">高级设置</span>
<span v-if="!advOpen && activeAdvanced.length" class="adv-badge">已配置 {{ activeAdvanced.join("、") }}</span>
<span v-else-if="!advOpen" class="adv-sub">会签/或签 · 发起时指派 · 办理时限 · 多出口路由</span>
</div>
<div v-show="advOpen" class="adv-body">
<div class="fld">
<label>多人审批方式</label>
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
@@ -192,11 +276,50 @@ function onRemove(): void {
</div>
</div>
<div class="fld">
<label>发起时指派</label>
<div class="mo-row">
<i-switch v-model="form.assignEnabled" />
<span class="mo-label">允许发起人在提交时指定本节点审批人</span>
</div>
<template v-if="form.assignEnabled">
<div class="mo-row" style="margin-top: 8px">
<RadioGroup v-model="form.assignMode" type="button" button-style="solid" size="small">
<Radio label="SINGLE">单选</Radio>
<Radio label="MULTI">可多选</Radio>
</RadioGroup>
</div>
<div v-if="form.assignMode === 'MULTI'" class="mo-row" style="margin-top: 8px">
<span>最少</span>
<InputNumber v-model="form.assignMin" :min="1" size="small" style="width: 90px" />
<span>最多</span>
<InputNumber v-model="form.assignMax" :min="1" size="small" style="width: 90px" placeholder="不限" />
</div>
<div class="mo-row" style="margin-top: 8px">
<Checkbox v-model="form.assignRequired">提交时必选(子应用提交页提示)</Checkbox>
</div>
</template>
<div class="hint">开启后,发起人在子应用提交时可从本节点候选审批人中指定实际审核人;未指定则全体候选按上方方式审批。或签/会签语义不变,仅收窄人选</div>
</div>
<div class="fld">
<label>办理时限(小时)</label>
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</div>
</div>
<div class="fld">
<label>出口路由</label>
<div class="mo-row">
<i-switch v-model="form.multiOutlet" />
<span class="mo-label">多出口审批(办理人按下按钮选路)</span>
</div>
<div class="hint">
开启后卡片下出现出口泳道(默认 同意/退回),出口名即办理按钮文案;泳道尾自动汇合到后继节点。
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
</div>
</div>
</div>
</Form>
<div class="dw-foot">
@@ -215,6 +338,8 @@ function onRemove(): void {
:userids="form.userIds"
@on-submit="onUsersPicked"
/>
<AssigneePreviewModal ref="previewModal" />
</template>
<style scoped>
@@ -239,6 +364,32 @@ function onRemove(): void {
.hint.warn {
color: #ff9900;
}
.adv-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
margin: 4px 0 12px;
border-top: 1px dashed #e8eaec;
cursor: pointer;
user-select: none;
}
.adv-title {
font-weight: 600;
font-size: 13px;
}
.adv-sub,
.adv-badge {
font-size: 12px;
color: #808695;
}
.adv-badge {
color: #ff9900;
}
.adv-body {
padding-left: 10px;
border-left: 2px solid #f0f0f0;
}
.user-add {
display: flex;
gap: 8px;
@@ -249,6 +400,14 @@ function onRemove(): void {
flex-wrap: wrap;
gap: 4px 0;
}
.mo-row {
display: flex;
align-items: center;
gap: 8px;
}
.mo-label {
font-size: 13px;
}
.dw-foot {
position: sticky;
bottom: -16px;
@@ -0,0 +1,91 @@
<script setup lang="ts">
/**
* 审批节点候选人预览弹窗(编辑器内入口,验收报告 P2-1)。
* 只读展示 resolveNodePreview 的结果:草稿解析 + 已发布版本对账 + 明确的不可预览原因。
* 关闭不做任何写回——它只接收抽屉里的本地表单快照。
*/
import { ref } from "vue";
import { Modal, Alert, Tag, Button } from "view-ui-plus";
import { resolveNodePreview, type PreviewApi, type PreviewDept, type PreviewNodeConfig } from "../assignPreview";
import { flowApi } from "@/api/module/flow";
const api: PreviewApi = {
deptMembers: (deptId) => flowApi.deptMembers(deptId).send() as Promise<any[]>,
lookupUser: (userId) => flowApi.mapLookupUser(userId).send() as Promise<any>,
applyPreview: (flowGuid) => flowApi.applyPreview(flowGuid).send() as Promise<any>,
};
const visible = ref(false);
const loading = ref(false);
const title = ref("预览候选人");
const result = ref<Awaited<ReturnType<typeof resolveNodePreview>> | null>(null);
async function show(cfg: PreviewNodeConfig, depts: PreviewDept[], flowGuid = ""): Promise<void> {
visible.value = true;
loading.value = true;
result.value = null;
title.value = `预览候选人 · ${cfg.name || "未命名节点"}`;
try {
result.value = await resolveNodePreview(cfg, depts, api, flowGuid);
} finally {
loading.value = false;
}
}
defineExpose({ show });
</script>
<template>
<Modal v-model="visible" :title="title" :width="620" :footer-hide="true">
<div v-if="loading" class="muted" style="padding: 24px 0; text-align: center">正在解析候选人…</div>
<template v-else-if="result">
<Alert v-if="result.blocked" type="warning" show-icon class="pv-block">{{ result.blocked }}</Alert>
<div class="pv-headline">
<b>{{ result.headline }}</b>
<span class="pv-source">数据源:{{ result.rows.length ? "FL 科室映射 / UserBase 既有接口" : "未取到候选" }}</span>
</div>
<div v-if="result.rows.length" class="pv-rows">
<div v-for="r in result.rows" :key="r.userId" class="pv-row">
<Tag :color="r.warn ? 'warning' : 'blue'">{{ r.userName }}</Tag>
<span class="pv-id">#{{ r.userId }}</span>
<span v-if="r.warn" class="pv-warn">{{ r.warn }}</span>
</div>
</div>
<div v-else class="muted">(本节点当前没有可展示的候选人)</div>
<div class="pv-pub" v-if="result.published">
<div class="pv-pub-title">已发布版本对账</div>
<div class="pv-pub-body">
<Tag :color="result.published.state === 'ok' ? 'success' : result.published.state === 'missing' ? 'default' : 'warning'">
{{ result.published.state === "ok" ? "可比对" : result.published.state === "missing" ? "草稿节点未发布" : "不可比对" }}
</Tag>
<span>{{ result.published.text }}</span>
</div>
<div v-if="result.published.state === 'ok' && result.published.rows.length" class="pv-rows">
<Tag v-for="p in result.published.rows" :key="p.userId" color="green">{{ p.userName }}</Tag>
</div>
</div>
<ul class="pv-notes">
<li v-for="(n, i) in result.notes" :key="i">{{ n }}</li>
</ul>
</template>
<div style="margin-top: 12px; text-align: right">
<Button @click="visible = false">关闭</Button>
</div>
</Modal>
</template>
<style scoped>
.pv-block { margin-bottom: 10px; }
.pv-headline { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.pv-source { color: #808695; font-size: 12px; }
.pv-rows { display: flex; flex-wrap: wrap; gap: 6px 0; margin-bottom: 12px; }
.pv-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.pv-id { color: #c5c8ce; font-size: 12px; }
.pv-warn { color: #ff9900; font-size: 12px; }
.pv-pub { border-top: 1px dashed #e8eaec; padding-top: 10px; margin-bottom: 10px; }
.pv-pub-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
.pv-pub-body { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; }
.pv-notes { margin: 0; padding-left: 18px; color: #808695; font-size: 12px; line-height: 1.7; }
</style>
@@ -1,226 +0,0 @@
<script setup lang="ts">
// bpmn-js 画布(方案 §C):拖拽建模器,仅暴露业务 5 类元素;contentJson 为唯一持久化格式,
// 本组件负责画布初始化、按名路由的出口命名、以及 fromBpmn 序列化。
// bpmn.io 水印为协议要求(左下角 "Powered by bpmn.io"),勿删。
import { onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { createDesigner } from "../bpmn/custom";
import { toBpmn, fromBpmn, CanvasJson } from "../bpmn/serialize";
const props = defineProps<{
/** contentJson 字符串;空 = 新建骨架 */
source: string;
}>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const emit = defineEmits<{ dirty: []; nodeConfig: [any, any]; edgeBusy: [] }>();
const wrap = ref<HTMLDivElement | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let modeler: any = null;
/** 选中元素(审批节点弹配置抽屉 / 连线弹命名) */
const selected = ref<any>(null);
onMounted(() => {
if (!wrap.value) return;
modeler = createDesigner(wrap.value);
// 选中级联:parent emit 让外层判断(APPROVAL → 打开配置抽屉;连线 → 预留命名按钮)
modeler.on("selection.changed", (_e: unknown) => {
const sel = modeler.get("selection").get();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected.value = sel.length === 1 ? sel[0] : null;
emit("nodeConfig", selected.value, null);
});
modeler.on("connectivity-rule-violation", (evt: any) => Message.warning(String(evt?.message || "连线规则不允许")));
init();
});
onUnmounted(() => modeler?.destroy());
async function init() {
try {
if (props.source && props.source.trim().startsWith("{")) {
const def = JSON.parse(props.source) as CanvasJson;
if (def.nodes?.length) {
// 已有定义:先导入最小骨架再程序化布点连线
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await modeler.importXML(minimalXml());
toBpmn(modeler, { ...def, code: def.code || "", name: def.name || "" });
return;
}
}
// 新建:画默认骨架 start → 审批 → end
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await modeler.importXML(minimalXml());
toBpmn(modeler, skeleton());
} catch (e: any) {
Message.error(`流程图载入失败:${e?.message || e}`);
}
}
/** 新建骨架(含默认审批节点,审批人可见在外层点开抽屉配置) */
function skeleton(): CanvasJson {
return {
code: "",
name: "",
start: "start",
nodes: [
{ id: "start", type: "START" },
{ id: "n1", type: "APPROVAL", name: "步骤 1" },
{ id: "end", type: "END" },
],
edges: [
{ from: "start", to: "n1" },
{ from: "n1", to: "end" },
],
settings: { rejectPolicy: "END_REJECTED" },
};
}
/** 最小合法 BPMN XML(空 process):程序化布点用 */
function minimalXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="lc_process" isExecutable="true" />
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process" />
</bpmndi:BPMNDiagram>
</bpmn:definitions>`;
}
watch(
() => props.source,
() => {
if (modeler) void init();
}
);
// ==================== 工具栏 ====================
function zoom(delta: number) {
const canvas = modeler.get("canvas");
canvas.zoom(canvas.zoom() + delta);
}
function resetZoom() {
modeler.get("canvas").zoom("fit-viewport", "auto");
}
function undo() {
modeler.get("commandStack").undo();
}
function redo() {
modeler.get("commandStack").redo();
}
/** 选中连线命名(出口按钮文案):弹 Vue 端 prompt(用 window.prompt 保实现简单) */
function renameEdge() {
const sel = modeler.get("selection").get();
if (sel.length !== 1 || sel[0].type !== "bpmn:SequenceFlow") {
Message.warning("请先点击选中一条连线");
return;
}
const conn = sel[0];
const cur = conn.businessObject?.name || "";
const name = window.prompt("出口名称(即办理按钮文案;单出口可留空,多出口必须命名)", cur);
if (name === null) return;
modeler.get("modeling").updateProperties(conn, { name: name.trim() });
}
function exportBpmn() {
modeler.saveXML({ format: true }).then(({ xml }: { xml: string }) => {
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "flow.bpmn";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
Message.success("已导出 BPMN 文件(不参与保存,仅备档)");
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defineExpose({
/** 序列化当前画布(外层合并 code/name/settings 与审批配置) */
serialize: (base: CanvasJson | null, code: string, name: string): CanvasJson => {
return fromBpmn(modeler, base || ({} as CanvasJson), code, name);
},
/** 按 contentJson 节点 id 删除元素(卡片抽屉删除回调) */
removeNode: (nodeId: string) => {
const el = modeler.get("elementRegistry").get(nodeId);
if (el) modeler.get("modeling").removeElements([el]);
},
/** 更新节点名(配置抽屉确定后回写画布) */
renameNode: (nodeId: string, name: string) => {
const el = modeler.get("elementRegistry").get(nodeId);
if (el) modeler.get("modeling").updateProperties(el, { name });
},
commandStack: () => modeler?.get("commandStack"),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
elementRegistry: () => (modeler ? modeler.get("elementRegistry") : null),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
markClean: () => modeler?.get("commandStack").clear(),
selected: () => modeler?.get("selection").get(),
});
</script>
<template>
<div class="bpmn-wrap">
<div class="bpmn-toolbar">
<Button size="small" @click="undo">撤销</Button>
<Button size="small" @click="redo">重做</Button>
<span class="sep" />
<Button size="small" @click="zoom(-0.1)">−</Button>
<Button size="small" @click="resetZoom">复位</Button>
<Button size="small" @click="zoom(0.1)">+</Button>
<span class="sep" />
<Button size="small" @click="renameEdge">出口命名</Button>
<Button size="small" @click="exportBpmn">导出 BPMN</Button>
<span class="tip">左侧工具栏拖入节点 · 选中审批节点配置 · 选中连线命名出口(多出口必填)</span>
</div>
<div ref="wrap" class="bpmn-container" />
</div>
</template>
<style scoped>
.bpmn-wrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #fafafa;
}
.bpmn-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fff;
border-bottom: 1px solid #e8eaec;
z-index: 5;
}
.sep {
width: 1px;
height: 20px;
background: #e8eaec;
margin: 0 4px;
}
.tip {
color: #a5abb8;
font-size: 12px;
margin-left: auto;
}
.bpmn-container {
flex: 1;
min-height: 380px;
}
/* bpmn.io 水印按协议保留 */
.bpmn-container :deep(.bjs-powered-by) {
display: block;
}
</style>
@@ -1,10 +1,20 @@
<script setup lang="ts">
// 条件分支容器(2026-09-24):紫系区分并行橙;与 ParallelBox 的本质差异——无 JOIN 语义,
// 分支按出口名互斥选路(运行时调判断服务),分支尾隐式汇合到容器后继。
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约)。
import { computed, inject } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
// 分支按出口名互斥选路(运行时调判断服务/表达式求值),分支尾隐式汇合到容器后继。
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约);
// EXPRESSION 模式(2026-10-10)下分支头部再就地编辑出边 condition,序号即求值优先级。
import { computed, inject, nextTick, ref } from "vue";
import {
BRANCH_LABELS,
EDITOR_CTX,
EXPR_BUILTIN_SET,
ConditionNode,
MAX_BRANCHES,
MIN_BRANCHES,
extractExprVars,
} from "../model";
import NodeList from "./NodeList.vue";
import ExprEvalModal from "@/views/flow/components/ExprEvalModal.vue";
const props = defineProps<{ node: ConditionNode }>();
@@ -14,8 +24,10 @@ const issues = computed(() => ctx.issueOf(props.node.uid));
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
const canRemoveBranch = computed(() => props.node.branches.length > MIN_BRANCHES);
const canAddBranch = computed(() => props.node.branches.length < MAX_BRANCHES);
const expression = computed(() => props.node.judgeMode === "EXPRESSION");
const routerLabel = computed(() => {
if (expression.value) return "表达式判断(按分支顺序取首个为真)";
if (!props.node.routerKey) return "判断服务:未选择";
const r = ctx.routers.find((x) => x.routerKey === props.node.routerKey);
return r ? `判断服务:${r.name}(${props.node.routerKey})` : `判断服务:${props.node.routerKey}`;
@@ -25,19 +37,69 @@ const routerLabel = computed(() => {
function renameOutlet(i: number, v: string): void {
props.node.outlets[i] = v;
}
/** 出口条件就地编辑(EXPRESSION 模式;与 outlets 下标一一对应) */
function editCondition(i: number, v: string): void {
props.node.conditions = props.node.conditions || [];
while (props.node.conditions.length <= i) props.node.conditions.push("");
props.node.conditions[i] = v;
}
// B5 内联试算:把该出口表达式带进共享调试弹窗(ExprEvalModal)
const tryVisible = ref(false);
const tryExpr = ref("");
function openTry(i: number): void {
tryExpr.value = (props.node.conditions || [])[i] || "";
tryVisible.value = true;
}
// ==================== 可用变量 chips(2026-10-14 议题一 P0):内联提示 + 点击插入 ====================
/** 可用变量(表单字段聚合键 + 内置),来自编辑器上下文;未拉到为空数组则不展示 chips */
const availableVars = computed(() => ctx.availableVars || []);
const isBuiltin = (k: string): boolean => EXPR_BUILTIN_SET.has(k);
const condInputs = ref<Record<number, HTMLInputElement | null>>({});
function setCondInput(el: any, i: number): void {
condInputs.value[i] = (el as HTMLInputElement) || null;
}
/** chips 点击:插入到该出口表达式光标处(无输入框时追加到末尾) */
function insertChip(i: number, key: string): void {
const cur = (props.node.conditions || [])[i] || "";
const el = condInputs.value[i];
const s = el?.selectionStart ?? cur.length;
const e = el?.selectionEnd ?? cur.length;
editCondition(i, cur.slice(0, s) + key + cur.slice(e));
nextTick(() => {
const input = condInputs.value[i];
if (input) {
input.focus();
input.setSelectionRange(s + key.length, s + key.length);
}
});
}
/** 该出口表达式里不在可用列表中的标识符(疑似拼错提示,仅提示不拦截) */
function unknownVars(i: number): string[] {
const known = new Set<string>([...availableVars.value, ...EXPR_BUILTIN_SET]);
return extractExprVars((props.node.conditions || [])[i] || "").filter((v) => !known.has(v));
}
</script>
<template>
<div class="condition-box" :class="{ error: hasError }">
<div :id="'nd-' + node.uid" class="condition-box" :class="{ error: hasError }">
<div class="cb-title" @click="ctx.openNode(node.uid)">
条件分支(按出口互斥选路)
<span class="cb-hint">(点击标题配置判断服务)</span>
<span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span>
</div>
<div class="cb-router">{{ routerLabel }}</div>
<div class="cb-cols">
<div v-for="(branch, i) in node.branches" :key="i" class="cb-col">
<div class="cb-col-head">
<span class="cb-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
<span class="cb-col-no" :title="expression ? '表达式求值优先级' : undefined">
{{ BRANCH_LABELS[i] || i + 1 }}
</span>
<input
class="cb-outlet"
:value="node.outlets[i]"
@@ -57,13 +119,44 @@ function renameOutlet(i: number, v: string): void {
<span class="cb-col-del" title="删除分支">×</span>
</Poptip>
</div>
<div v-if="expression" class="cb-cond-row">
<input
:ref="(el) => setCondInput(el, i)"
class="cb-cond"
:value="(node.conditions || [])[i] || ''"
placeholder="条件表达式,如 amount > 1000"
maxlength="200"
@input="editCondition(i, ($event.target as HTMLInputElement).value)"
/>
<!-- B5 内联试算:用真实办件变量跑该出口表达式(同「条件路由」页弹窗,预填表达式) -->
<span class="cb-try" title="用真实办件变量试算该表达式" @click.stop="openTry(i)">试算</span>
</div>
<!-- 议题一 P0:可用变量 chips(表单键 + 内置),点击插入到表达式光标处 -->
<div v-if="expression && availableVars.length" class="cb-vars">
<span
v-for="k in availableVars.slice(0, 14)"
:key="k"
class="cb-var"
:class="{ bt: isBuiltin(k) }"
:title="isBuiltin(k) ? '内置变量(引擎运行时注入)' : '表单变量(来自近期办件 formData)'"
@click.stop="insertChip(i, k)"
>{{ k }}</span>
<span v-if="availableVars.length > 14" class="cb-vars-more" :title="availableVars.join('、')">
+{{ availableVars.length - 14 }}
</span>
</div>
<div v-for="(v, vi) in unknownVars(i)" :key="'u' + vi" class="cb-unknown">
⚠ 「{{ v }}」不在可用变量列表,若为动态变量可忽略
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
<div v-if="!branch.length" class="cb-empty">该出口直达汇合点(直接通过)</div>
</div>
</div>
<Button v-if="canAddBranch" class="cb-add" size="small" long @click="ctx.addBranch(node.uid)">
+ 添加分支
</Button>
<div v-for="(issue, k) in issues" :key="k" class="cb-issue" :class="issue.level">⚠ {{ issue.text }}</div>
<ExprEvalModal v-model="tryVisible" :preset-expression="tryExpr" />
</div>
</template>
@@ -98,6 +191,16 @@ function renameOutlet(i: number, v: string): void {
color: #9254de;
margin: 6px 0 10px;
}
.cb-empty {
margin: 4px 0 8px;
padding: 8px 6px;
border: 1px dashed #d3c6e8;
border-radius: 6px;
text-align: center;
font-size: 11px;
color: #9254de;
background: rgba(255, 255, 255, 0.6);
}
.cb-cols {
display: flex;
gap: 14px;
@@ -142,6 +245,96 @@ function renameOutlet(i: number, v: string): void {
outline: none;
border-color: #9254de;
}
.cb-cond {
width: 100%;
box-sizing: border-box;
border: 1px dashed #d3b8f9;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: #5b3fa8;
font-family: Consolas, Monaco, monospace;
margin-bottom: 8px;
}
.cb-cond:focus {
outline: none;
border-color: #9254de;
border-style: solid;
}
.cb-cond::placeholder {
color: #c5b3e8;
}
.cb-cond-row {
display: flex;
align-items: center;
gap: 6px;
}
.cb-cond-row .cb-cond {
flex: 1;
width: auto;
margin-bottom: 8px;
}
.cb-try {
flex: none;
cursor: pointer;
font-size: 12px;
color: #0b7d72;
border: 1px solid #b7e3dc;
border-radius: 10px;
padding: 0 8px;
line-height: 18px;
user-select: none;
}
.cb-try:hover {
background: #f2fbf9;
border-color: #0b7d72;
}
.cb-vars {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: -2px 0 8px;
}
.cb-var {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid #b7e3dc;
background: #f2fbf9;
color: #0b7d72;
cursor: pointer;
user-select: none;
font-family: Consolas, Monaco, monospace;
}
.cb-var:hover {
background: #0b7d72;
color: #fff;
border-color: #0b7d72;
}
.cb-var.bt {
border-color: #dcdee2;
background: #f5f7fa;
color: #4e5969;
}
.cb-var.bt:hover {
background: #4e5969;
color: #fff;
border-color: #4e5969;
}
.cb-vars-more {
font-size: 11px;
line-height: 16px;
padding: 0 8px;
color: #86909c;
cursor: default;
}
.cb-unknown {
text-align: center;
font-size: 11px;
color: #ff9900;
margin: 0 0 8px;
}
.cb-col-del {
flex: none;
cursor: pointer;
@@ -1,9 +1,9 @@
<script setup lang="ts">
// 条件分支配置抽屉(2026-09-24):结构与 NotifyDrawer 同源(本地表单 + 确定回写)。
// 只管节点名与判断服务(flw_router 下拉);出口命名/分支编排 ConditionBox 就地做。
// 管节点名与判断方式(2026-10-10 二选一:服务/表达式);出口命名/条件表达式/分支编排 ConditionBox 就地做。
import { computed, inject, reactive, watch } from "vue";
import { Message } from "view-ui-plus";
import { ConditionNode, EDITOR_CTX } from "../model";
import { ConditionNode, EDITOR_CTX, JudgeMode } from "../model";
const props = defineProps<{
modelValue: boolean;
@@ -21,6 +21,8 @@ const ctx = inject(EDITOR_CTX)!;
const form = reactive({
name: "",
routerKey: "",
// 2026-10-12 SERVICE 收口:新建缺省翻转为表达式(主路径);存量归一化见 syncFromNode(勿动)
judgeMode: "EXPRESSION" as JudgeMode,
});
function syncFromNode(): void {
@@ -28,6 +30,7 @@ function syncFromNode(): void {
if (!n) return;
form.name = n.name;
form.routerKey = n.routerKey || "";
form.judgeMode = n.judgeMode === "EXPRESSION" ? "EXPRESSION" : "SERVICE";
}
watch(
@@ -56,6 +59,16 @@ const routerHint = computed(() => {
return `调用 ${r.serviceKey}${r.path};返回出口名即分支走向`;
});
/** 切换判断方式:表达式模式下 conditions 补齐占位(与 outlets 同位) */
function switchJudgeMode(mode: JudgeMode): void {
form.judgeMode = mode;
const n = props.node;
if (n && mode === "EXPRESSION") {
n.conditions = n.conditions || [];
while (n.conditions.length < n.outlets.length) n.conditions.push("");
}
}
function onOk(): void {
const n = props.node;
if (!n) {
@@ -66,12 +79,17 @@ function onOk(): void {
Message.warning("节点名称不能为空");
return;
}
if (!form.routerKey) {
if (form.judgeMode === "SERVICE" && !form.routerKey) {
Message.warning("请选择判断服务(未配置的判断服务运行到该节点会挂起)");
return;
}
n.name = form.name.trim();
n.routerKey = form.routerKey;
n.routerKey = form.judgeMode === "SERVICE" ? form.routerKey : "";
n.judgeMode = form.judgeMode;
if (form.judgeMode === "EXPRESSION") {
n.conditions = n.conditions || [];
while (n.conditions.length < n.outlets.length) n.conditions.push("");
}
emit("update:modelValue", false);
}
@@ -96,6 +114,24 @@ function onRemove(): void {
</div>
<div class="fld">
<label>判断依据数据什么时候能拿到</label>
<RadioGroup :model-value="form.judgeMode" @on-change="switchJudgeMode">
<Radio label="EXPRESSION">
<span class="rd-t">提交时就已确定</span>
<span class="rd-d">用表达式判断(推荐)。变量随单据一起提交,编辑器内闭环,零对接。</span>
</Radio>
<Radio label="SERVICE">
<span class="rd-t">到达该节点时才能确定</span>
<span class="rd-d">需实时查库 / 问外部系统时使用。由子应用的判断服务返回出口。</span>
</Radio>
</RadioGroup>
<div class="hint">
原措辞为「EXPRESSION / SERVICE」——引擎实现细节,业务管理员难以判断,选错即返工;
改按「数据何时可知」提问,配置者无需理解引擎实现
</div>
</div>
<div v-if="form.judgeMode === 'SERVICE'" class="fld">
<label>判断服务</label>
<Select v-model="form.routerKey" filterable placeholder="请选择判断服务(flw_router 注册)">
<Option v-for="o in routerOptions" :key="o.value" :value="o.value" :label="o.label" />
@@ -106,6 +142,20 @@ function onRemove(): void {
<div v-else class="hint">
判断逻辑由业务方实现:引擎运行到该节点时同步调用判断接口,按返回出口名走对应分支
</div>
<div class="hint">
仅当判断依赖流程到达时点的实时业务数据时使用;简单条件请用"表达式"(apply 时 formData
自带判断数据)。判断端点与登记由
<code>yh-flow-callback-spring-boot-starter</code>(流程中心接入件)自动完成,见《子应用接入流程中心规范》§6.6。
</div>
</div>
<div v-else class="fld">
<label>表达式说明</label>
<div class="hint">
每个出口在分支列头部就地填写 AviatorScript 条件(如 amount &gt; 1000);按分支顺序取
首个为真的出口。变量=表单字段 + 内置(schId/bizType/bizId/submitterId/instanceId 等)。
变量未提供或条件全不为真时流程挂起(不配默认出口),可在"条件路由"页调试表达式
</div>
</div>
<div class="fld">
@@ -150,6 +200,19 @@ function onRemove(): void {
.hint.warn {
color: #ff9900;
}
/* 判断方式业务语言化(2026-10-15):标签 + 说明两行式,配置者按"数据何时可知"作答 */
.rd-t {
display: block;
font-weight: 600;
}
.rd-d {
display: block;
font-size: 12px;
color: #808695;
line-height: 1.5;
margin-top: 2px;
white-space: normal;
}
.dw-foot {
position: sticky;
bottom: -16px;
@@ -20,7 +20,7 @@ function onOpen(e: MouseEvent): void {
</script>
<template>
<div class="node-card" :class="{ error: hasError }" @click="onOpen">
<div :id="'nd-' + node.uid" class="node-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head">
<span class="nc-icon">审</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span>
@@ -55,7 +55,7 @@ function onOpen(e: MouseEvent): void {
transition: all 0.15s;
}
.node-card:hover {
border-color: #2d8cf0;
border-color: var(--brand);
}
.node-card.error {
border-color: #ed4014;
@@ -72,7 +72,7 @@ function onOpen(e: MouseEvent): void {
height: 26px;
border-radius: 6px;
background: #e8f3ff;
color: #2d8cf0;
color: var(--brand);
display: flex;
align-items: center;
justify-content: center;
@@ -5,6 +5,7 @@ import NodeCard from "./NodeCard.vue";
import NotifyCard from "./NotifyCard.vue";
import ParallelBox from "./ParallelBox.vue";
import ConditionBox from "./ConditionBox.vue";
import OutletLanes from "./OutletLanes.vue";
import PlusButton from "./PlusButton.vue";
defineProps<{
@@ -19,9 +20,10 @@ defineProps<{
<PlusButton :container="container" :index="0" />
<template v-for="(item, i) in items" :key="item.uid">
<NodeCard v-if="item.kind === 'approval'" :node="item" />
<NotifyCard v-else-if="item.kind === 'notify'" :node="item" />
<OutletLanes v-if="item.kind === 'approval' && item.outlets && item.outlets.length >= 2" :node="item" />
<NotifyCard v-if="item.kind === 'notify'" :node="item" />
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
<ConditionBox v-else :node="item" />
<ConditionBox v-else-if="item.kind === 'condition'" :node="item" />
<PlusButton :container="container" :index="i + 1" />
</template>
</div>
@@ -19,7 +19,7 @@ function onOpen(e: MouseEvent): void {
</script>
<template>
<div class="notify-card" :class="{ error: hasError }" @click="onOpen">
<div :id="'nd-' + node.uid" class="notify-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head">
<span class="nc-icon">抄</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span>
@@ -52,7 +52,7 @@ watch(
{ immediate: true }
);
/** 科室下拉项:mapList 聚合 + 当前值(可能已不在映射表中)兜底,避免选项丢失 */
/** 科室下拉项:科室管理列表 + 当前值(可能已删除)兜底,避免选项丢失 */
const deptOptions = computed(() => {
const list = ctx.depts.map((d) => ({
value: d.deptKey,
@@ -0,0 +1,198 @@
<script setup lang="ts">
// 多出口审批的出口泳道容器(2026-10-03 P2):蓝系区分条件紫。与条件分支容器完全同构——
// 无 JOIN 语义,办理人按下哪个出口按钮走哪条泳道,分支尾隐式汇合到容器后继。
// 出口名 Input 即 contentJson 里 APPROVAL 命名出边的 name(按钮文案,必填唯一)。
import { computed, inject } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ApprovalNode } from "../model";
import NodeList from "./NodeList.vue";
const props = defineProps<{ node: ApprovalNode }>();
const ctx = inject(EDITOR_CTX)!;
const issues = computed(() => ctx.issueOf(props.node.uid));
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
const canRemoveBranch = computed(() => (props.node.branches?.length || 0) > MIN_BRANCHES);
const canAddBranch = computed(() => (props.node.branches?.length || 0) < MAX_BRANCHES);
/** 出口名就地编辑(重复/空由校验提示;与 branches 下标一一对应) */
function renameOutlet(i: number, v: string): void {
props.node.outlets![i] = v;
}
</script>
<template>
<div class="outlet-lanes" :class="{ error: hasError }">
<div class="ol-head">
<div class="ol-title" @click="ctx.openNode(node.uid)">
出口路由(按下按钮选路)
<span class="ol-hint">(出口名即办理按钮文案;点击审批卡片配置审批人)</span>
</div>
<Poptip
confirm
title="退出多出口将删除出口②起的全部泳道节点,仅保留出口①泳道?"
ok-text="退出多出口"
cancel-text="取消"
transfer
@on-ok="ctx.exitOutlets(node.uid)"
>
<span class="ol-exit">恢复单出口</span>
</Poptip>
</div>
<div class="ol-cols">
<div v-for="(branch, i) in node.branches" :key="i" class="ol-col">
<div class="ol-col-head">
<span class="ol-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
<input
class="ol-outlet"
:value="node.outlets![i]"
placeholder="出口名(按钮文案,必填唯一)"
maxlength="32"
@input="renameOutlet(i, ($event.target as HTMLInputElement).value)"
/>
<Poptip
v-if="canRemoveBranch"
confirm
title="删除该出口及其泳道节点?"
ok-text="删除"
cancel-text="取消"
transfer
@on-ok="ctx.removeBranch(node.uid, i)"
>
<span class="ol-col-del" title="删除出口">×</span>
</Poptip>
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
</div>
</div>
<Button v-if="canAddBranch" class="ol-add" size="small" long @click="ctx.addBranch(node.uid)">
+ 添加出口
</Button>
<div v-for="(issue, k) in issues" :key="k" class="ol-issue" :class="issue.level">⚠ {{ issue.text }}</div>
</div>
</template>
<style scoped>
.outlet-lanes {
border: 1.5px dashed #5cadff;
border-radius: 10px;
padding: 14px 14px 12px;
background: rgba(240, 249, 255, 0.45);
}
.outlet-lanes.error {
border-color: #ed4014;
}
.ol-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.ol-title {
text-align: center;
color: var(--brand);
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.ol-title:hover {
text-decoration: underline;
}
.ol-hint {
font-weight: 400;
font-size: 11px;
color: #82a8d8;
}
.ol-exit {
flex: none;
cursor: pointer;
font-size: 12px;
color: #ed4014;
}
.ol-exit:hover {
text-decoration: underline;
}
.ol-cols {
display: flex;
gap: 14px;
align-items: flex-start;
justify-content: center;
margin-top: 10px;
}
.ol-col {
background: #fff;
border-radius: 8px;
padding: 12px 10px 14px;
min-width: 230px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
align-items: center;
}
.ol-col-head {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
margin-bottom: 8px;
padding: 0 2px;
}
.ol-col-no {
flex: none;
color: var(--brand);
font-weight: 600;
font-size: 13px;
}
.ol-outlet {
flex: 1;
min-width: 0;
border: 1px solid #dfe6ee;
border-radius: 6px;
padding: 2px 8px;
font-size: 12px;
color: var(--brand);
font-weight: 600;
}
.ol-outlet:focus {
outline: none;
border-color: var(--brand);
}
.ol-col-del {
flex: none;
cursor: pointer;
color: #c5c9d1;
font-size: 16px;
line-height: 1;
}
.ol-col-del:hover {
color: #ed4014;
}
.ol-add {
margin-top: 12px;
border-style: dashed;
color: var(--brand);
border-color: var(--brand);
background: transparent;
width: auto;
padding: 0 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
.ol-add:hover {
background: #f0faff;
color: #2b85e4;
border-color: var(--brand);
}
.ol-issue {
text-align: center;
font-size: 12px;
padding-top: 8px;
}
.ol-issue.error {
color: #ed4014;
}
.ol-issue.warn {
color: #ff9900;
}
</style>
@@ -23,6 +23,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
<div class="plus-btn" title="在此插入节点">+</div>
<template #content>
<div class="menu-group">常用节点</div>
<div class="menu-item" @click="pick('approval')">
<span class="mi-icon approval">审</span>
<span class="mi-text">
@@ -37,6 +38,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
<i>知会指定人,不产生待办、不阻塞流程</i>
</span>
</div>
<!-- 高级结构:只是分组展示,四类插入能力一个都没少,插入后的 JSON 语义完全不变 -->
<div class="menu-group adv">高级结构</div>
<div class="menu-item" @click="pick('parallel')">
<span class="mi-icon parallel">⇉</span>
<span class="mi-text">
@@ -75,8 +78,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
height: 22px;
border-radius: 50%;
background: #fff;
border: 1px solid #2d8cf0;
color: #2d8cf0;
border: 1px solid var(--brand);
color: var(--brand);
display: flex;
align-items: center;
justify-content: center;
@@ -90,10 +93,21 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
}
.plus-wrap:hover .plus-btn {
opacity: 1;
background: #2d8cf0;
background: var(--brand);
color: #fff;
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
}
.menu-group {
padding: 4px 10px 2px;
font-size: 11px;
color: #808695;
letter-spacing: 0.5px;
}
.menu-group.adv {
margin-top: 6px;
border-top: 1px dashed #e8eaec;
padding-top: 8px;
}
.menu-item {
display: flex;
gap: 10px;
@@ -117,7 +131,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
font-size: 12px;
}
.mi-icon.approval {
background: #2d8cf0;
background: var(--brand);
}
.mi-icon.notify {
background: #13a8a8;
+104 -18
View File
@@ -42,21 +42,42 @@ export function toContentJson(model: EditorModel): DefJson {
/**
* 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后
* 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。
* walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。
* firstEdgeCondition(2026-10-10):EXPRESSION 模式分支首边的条件表达式,与出口名同位写入。
* 空出口分支(2026-10-12,"直接通过"):items 为空时 walk 原样返回起点作为尾并携带出口名/条件,
* 汇合连线即"起点→汇合点"的命名直连边——与引擎 cond→汇合点直连边双向保真。
*/
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string): string[] => {
interface Tail {
id: string;
name?: string;
condition?: string;
}
const tailEdge = (from: string, to: string, t: Tail): DefEdgeJson => {
const e: DefEdgeJson = { from, to };
if (t.name) e.name = t.name;
if (t.condition !== undefined && t.condition !== null && t.condition !== "") {
e.condition = t.condition;
}
return e;
};
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string,
firstEdgeCondition?: string): Tail[] => {
let cursor: string | null = prev;
let pending: string[] = [];
let pending: Tail[] = [];
let first = true;
const linkTo = (toId: string): void => {
if (cursor !== null) {
if (first && firstEdgeName) {
edges.push({ from: cursor, to: toId, name: firstEdgeName });
const e: DefEdgeJson = { from: cursor, to: toId, name: firstEdgeName };
if (firstEdgeCondition !== undefined && firstEdgeCondition !== null
&& firstEdgeCondition !== "") {
e.condition = firstEdgeCondition;
}
edges.push(e);
} else {
edges.push({ from: cursor, to: toId });
}
}
pending.forEach((t) => edges.push({ from: t, to: toId }));
pending.forEach((t) => edges.push(tailEdge(t.id, toId, t)));
pending = [];
first = false;
};
@@ -71,9 +92,21 @@ 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 };
nodes.push(node);
linkTo(id);
if (item.outlets && item.outlets.length >= 2 && item.branches) {
// 多出口(2026-10-03 P2):APPROVAL 的多条命名出边即按钮选路,
// 分支尾隐式汇合到后继(与条件分支容器完全同构)
const tails: Tail[] = [];
item.branches.forEach((branch, bi) => {
tails.push(...walk(branch, id, item.outlets![bi]));
});
cursor = null;
pending = tails;
} else {
cursor = id;
}
return;
}
if (item.kind === "notify") {
@@ -91,16 +124,21 @@ export function toContentJson(model: EditorModel): DefJson {
}
if (item.kind === "condition") {
const condId = `${item.uid}c`;
nodes.push({
const node: DefNodeJson = {
id: condId,
type: "CONDITION",
name: item.name || "条件判断",
routerKey: item.routerKey || "",
});
};
// SERVICE 是后端缺省,不写字段保持最小 JSON;EXPRESSION 显式携带
if (item.judgeMode === "EXPRESSION") node.judgeMode = "EXPRESSION";
nodes.push(node);
linkTo(condId);
const tails: string[] = [];
const tails: Tail[] = [];
const expression = item.judgeMode === "EXPRESSION";
item.branches.forEach((branch, bi) => {
tails.push(...walk(branch, condId, item.outlets[bi]));
const cond = expression ? (item.conditions || [])[bi] || "" : undefined;
tails.push(...walk(branch, condId, item.outlets[bi], cond));
});
cursor = null;
pending = tails;
@@ -112,17 +150,27 @@ export function toContentJson(model: EditorModel): DefJson {
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
linkTo(split);
item.branches.forEach((branch) => {
walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join }));
walk(branch, split).forEach((tail) => edges.push(tailEdge(tail.id, join, tail)));
});
cursor = join;
});
const tails: string[] = [];
if (cursor !== null) tails.push(cursor);
const tails: Tail[] = [];
// 空出口分支:无任何节点消费(first 未翻转、cursor 停在起点)——出口名/条件随尾携带,
// 由汇合点连线落成命名直连边(非空分支的 first 边已由 linkTo 写过,此处 first=false 不受影响)
if (cursor !== null) {
const t: Tail = { id: cursor };
if (first && firstEdgeName) t.name = firstEdgeName;
if (first && firstEdgeCondition !== undefined && firstEdgeCondition !== null
&& firstEdgeCondition !== "") {
t.condition = firstEdgeCondition;
}
tails.push(t);
}
tails.push(...pending);
return tails;
};
walk(model.list, "start").forEach((tail) => edges.push({ from: tail, to: "end" }));
walk(model.list, "start").forEach((tail) => edges.push(tailEdge(tail.id, "end", tail)));
const endNode: DefNodeJson = { id: "end", type: "END" };
if (model.endName) endNode.name = model.endName;
nodes.push(endNode);
@@ -300,14 +348,23 @@ export function fromContentJson(def: any): ParsedDef {
// uid 复用 JSON id 并剥掉生成态的 "c" 尾缀:uid+"c" 是本编辑器的 JSON id 生成约定,
// 剥尾保证"图→树→图→树→图"的 id 收敛(否则每轮追加一个 c,往返不幂等)
const uidRaw = condId.endsWith("c") ? condId.slice(0, -1) : condId;
// 出口条件反解(2026-10-10):EXPRESSION 模式出边 condition 与 outlets 同位
const conditions = heads.map((head) => {
const e = edges.find((x) => x.from === condId && x.to === head);
return e && e.condition ? String(e.condition) : "";
});
return {
item: {
uid: UsedIds.take(uidRaw),
kind: "condition",
name: n.name || "条件判断",
routerKey: n.routerKey || "",
judgeMode: n.judgeMode === "EXPRESSION" ? "EXPRESSION" : "SERVICE",
conditions,
outlets,
branches: branches.map((b) => (b.length ? b : [createApproval()])),
// 空出口分支(2026-10-12):保留空数组("直接通过"直达汇合点),
// 不再造无审批人占位卡(曾导致打开即报"未选择科室映射"、保存还会凭空改语义)
branches,
},
mergeId,
};
@@ -323,11 +380,39 @@ export function fromContentJson(def: any): ParsedDef {
if (!n) throw new Error(`引用了不存在的节点:${cur}`);
if (n.type === "APPROVAL") {
if ((out[cur] || []).length !== 1) {
throw new Error(`审批节点「${n.name || cur}」的出边必须恰好 1 条`);
}
const heads = out[cur] || [];
if (heads.length === 1) {
list.push(toApprovalNode(n));
cur = out[cur][0];
cur = heads[0];
} else if (heads.length >= 2) {
// 多出口审批:出口名=出边 name(按钮文案),复用 CONDITION 的汇合点算法
const mergeId = findMerge(cur);
const branches = heads.map((head) => {
const r = walkList(head, mergeId);
if (r.endedAt !== mergeId) {
throw new Error("多出口审批未汇聚到同一个节点,无法还原为卡片视图");
}
return r.list;
});
const outlets = heads.map((head) => {
const e = edges.find((x) => x.from === cur && x.to === head);
return e && e.name ? String(e.name) : "";
});
if (outlets.some((o) => !o || !o.trim())) {
throw new Error(`多出口审批「${n.name || cur}」的每个出口必须命名(按钮文案)`);
}
const dup = outlets.find((o, i) => outlets.indexOf(o) !== i);
if (dup) {
throw new Error(`多出口审批出口名重复:「${dup}」(按钮文案必须唯一)`);
}
const node = toApprovalNode(n);
node.outlets = outlets;
node.branches = branches; // 空出口泳道保留(直达汇合点),不造占位卡
list.push(node);
cur = mergeId;
} else {
throw new Error(`审批节点「${n.name || cur}」出边数非法`);
}
} else if (n.type === "NOTIFY") {
if ((out[cur] || []).length !== 1) {
throw new Error(`抄送节点「${n.name || cur}」的出边必须恰好 1 条`);
@@ -403,6 +488,7 @@ function toApprovalNode(n: any): ApprovalNode {
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
countersign,
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
assignPolicy: n.assignPolicy ? { ...n.assignPolicy } : undefined,
};
}
File diff suppressed because it is too large Load Diff
+102 -8
View File
@@ -19,6 +19,14 @@ export interface AssigneeUsers {
}
export type Assignee = AssigneeDept | AssigneeUsers;
/** 发起时指派:mode SINGLE=恰 1 人|MULTI=min~max;required 仅子应用 UI 必选提示 */
export interface AssignPolicy {
mode: "SINGLE" | "MULTI";
min?: number | null;
max?: number | null;
required?: boolean;
}
export interface ApprovalNode {
uid: string;
kind: "approval";
@@ -28,6 +36,12 @@ export interface ApprovalNode {
countersign: Countersign;
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
deadlineHours: number | null;
/** 发起时指派策略:存在即允许指派;生成 JSON 时关闭则不写字段(与 countersign 省略风格一致) */
assignPolicy?: AssignPolicy | null;
/** 多出口(≥2 才有多出口语义):出口按钮文案,办理人按下哪个按钮走哪条泳道;单出口不写字段 */
outlets?: string[];
/** 各出口泳道(与 outlets 一一对应);分支尾隐式汇合到后继节点(同条件分支,无 JOIN) */
branches?: EditorItem[][];
}
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */
@@ -45,17 +59,25 @@ export interface ParallelNode {
branches: EditorItem[][];
}
/** CONDITION 判断方式(2026-10-10 四议题):SERVICE=外部判断服务(缺省)|EXPRESSION=内置表达式引擎 */
export type JudgeMode = "SERVICE" | "EXPRESSION";
/**
* 条件分支容器(2026-09-24 CONDITION):运行时同步调外部判断服务(flw_router,routerKey 寻址)
* 按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,分支尾隐式汇合到容器后继。
* outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),必须非空且唯一。
* 条件分支容器(2026-09-24 CONDITION):按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,
* 分支尾隐式汇合到容器后继。outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),
* 必须非空且唯一。判断方式二选一(2026-10-10):SERVICE 调 flw_router 注册的外部判断服务;
* EXPRESSION 用出边 condition 表达式(AviatorScript,按分支顺序取首个 true)。
*/
export interface ConditionNode {
uid: string;
kind: "condition";
name: string;
/** 判断服务注册键(flw_router.router_key,发布校验须已注册且启用) */
/** 判断服务注册键(flw_router.router_key,SERVICE 模式发布校验须已注册且启用) */
routerKey: string;
/** 判断方式:缺省 SERVICE(旧定义无此字段) */
judgeMode?: JudgeMode;
/** EXPRESSION 模式各出口条件表达式(与 outlets 一一对应;SERVICE 模式无意义) */
conditions?: string[];
/** 各分支出口名(与 branches 一一对应) */
outlets: string[];
branches: EditorItem[][];
@@ -100,15 +122,21 @@ export interface DefNodeJson {
receivers?: Assignee;
/** CONDITION 节点的判断服务注册键(flw_router.router_key) */
routerKey?: string;
/** CONDITION 判断方式:SERVICE(缺省)|EXPRESSION(出边 condition 表达式) */
judgeMode?: string;
countersign?: Countersign;
waitFor?: string;
deadlineHours?: number;
/** 发起时指派策略(审批节点可选,2026-10-13;关闭态不写字段) */
assignPolicy?: AssignPolicy;
}
export interface DefEdgeJson {
from: string;
to: string;
name?: string;
/** CONDITION EXPRESSION 模式的出口条件表达式(AviatorScript;SERVICE 模式不携带) */
condition?: string;
}
export interface DefJson {
@@ -132,6 +160,60 @@ export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"];
export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 };
// ==================== 表达式变量契约(2026-10-15 抽共享,消除三处漂移) ====================
/**
* 表达式内置变量白名单。
* 唯一权威来源是后端 `ConditionRouteService.builtinEnv`(运行时注入的同名集)。
* 前端两处消费者(ConditionBox 的 chips、validate 的未知变量比对)必须共用此常量,
* 改动时需同步后端 `DefValidator.EXPRESSION_BUILTINS` 与 `ConditionRouteService.builtinEnv`。
*/
export const EXPR_BUILTIN_VARS = [
"schId",
"instanceId",
"instanceVersion",
"bizType",
"bizId",
"nodeId",
"nodeName",
"submitterId",
] as const;
/** 内置变量快速判定集合 */
export const EXPR_BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
/**
* AviatorScript 关键字/字面量:不参与"未知变量"提示。
* 与后端表达式语法(AviatorScript)对齐,仅用于静态提取时的剔除。
*/
export const EXPR_KEYWORDS: ReadonlySet<string> = new Set([
"true", "false", "nil", "null", "and", "or", "not", "if", "else", "elsif", "let", "while",
"for", "return", "in", "like", "new", "end", "break", "continue", "seq", "lambda",
]);
/**
* 从表达式里静态提取"疑似变量名"(剔除字符串字面量、函数名、属性访问、关键字)。
* 与后端 `ExpressionEvaluator.variableNames()` 同口径的前端简化实现:
* 后端解析 AST 更精确,前端只做词法级粗筛——因此结果**只用于 warn 提示,绝不用于阻断**。
*/
export function extractExprVars(expression: string): string[] {
const cond = (expression || "").trim();
if (!cond) return [];
const stripped = cond
.replace(/'[^']*'|"[^"]*"/g, " ") // 字符串字面量
.replace(/[A-Za-z_][A-Za-z0-9_]*\s*\(/g, " ") // 函数名
.replace(/\.\s*[A-Za-z_][A-Za-z0-9_]*/g, " "); // 命名空间/属性访问
const out = new Set<string>();
(stripped.match(/[A-Za-z_][A-Za-z0-9_]*/g) || []).forEach((t) => {
if (!EXPR_KEYWORDS.has(t)) out.add(t);
});
return Array.from(out);
}
/** 表达式中不在「已知变量集合」里的标识符(疑似拼错;仅提示不拦截) */
export function unknownExprVars(expression: string, known: ReadonlySet<string>): string[] {
return extractExprVars(expression).filter((v) => !known.has(v));
}
// ==================== 工厂 ====================
let uidSeq = 0;
@@ -161,13 +243,15 @@ export function createParallel(): ParallelNode {
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
}
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断服务 */
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断方式(2026-10-12 收口:缺省表达式) */
export function createCondition(): ConditionNode {
return {
uid: newUid(),
kind: "condition",
name: "条件判断",
routerKey: "",
judgeMode: "EXPRESSION",
conditions: ["", ""],
outlets: ["出口一", "出口二"],
branches: [[createApproval()], [createApproval()]],
};
@@ -192,9 +276,12 @@ export interface ContainerRef {
items: EditorItem[];
}
/** 容器型节点(并行/条件分支)的分支数组取用(resolveContainer/removeItem 等共用口径) */
/** 容器型节点(并行/条件分支/多出口审批)的分支数组取用(resolveContainer/removeItem 等共用口径) */
export function branchesOf(item: EditorItem | null): EditorItem[][] | null {
if (item && (item.kind === "parallel" || item.kind === "condition")) return item.branches;
if (item && (item.kind === "parallel" || item.kind === "condition"
|| (item.kind === "approval" && item.branches && item.branches.length))) {
return item.branches;
}
return null;
}
@@ -286,7 +373,7 @@ export interface DeptOption {
count: number;
}
/** 审批人摘要文案(卡片第二行;deptOptions 来自 mapList,userNames 为 userId→姓名 快照) */
/** 审批人摘要文案(卡片第二行;deptOptions 来自科室管理列表,userNames 为 userId→姓名 快照) */
export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
if (!a) return "未配置审批人";
if (a.type === "DEPT_KEY") {
@@ -303,6 +390,7 @@ export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames:
/** 卡片标签行(会签/或签 + 时限) */
export function nodeTags(n: ApprovalNode): string[] {
const tags = [n.countersign === "ANY" ? "或签" : "会签"];
if (n.assignPolicy) tags.push("可指派");
if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`);
return tags;
}
@@ -327,8 +415,12 @@ export interface EditorCtx {
model: EditorModel;
depts: DeptOption[];
userNames: Record<number, string>;
/** 当前流程线 flowGuid(新建/尚未保存时为空):指派预览与已发布版本对账要用 */
flowGuid: string;
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
routers: RouterOption[];
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */
availableVars: string[];
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
openNode: (uid: string) => void;
removeNode: (uid: string) => void;
@@ -336,6 +428,8 @@ export interface EditorCtx {
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
addBranch: (containerUid: string) => void;
removeBranch: (containerUid: string, index: number) => void;
/** 退出多出口(多出口审批泳道头部按钮):仅保留出口①泳道内容 */
exitOutlets: (uid: string) => void;
}
export const EDITOR_CTX: InjectionKey<EditorCtx> = Symbol("flowDefEditorCtx");
+60 -4
View File
@@ -8,11 +8,13 @@
import {
BRANCH_LABELS,
DeptOption,
EXPR_BUILTIN_SET,
EditorItem,
EditorModel,
MAX_BRANCHES,
MIN_BRANCHES,
RouterOption,
unknownExprVars,
} from "./model";
export type IssueLevel = "error" | "warn";
@@ -28,11 +30,23 @@ export interface Issue {
text: string;
}
export function validateModel(model: EditorModel, depts: DeptOption[], routers: RouterOption[] = []): Issue[] {
/**
* @param availableVars 该归属应用近期办件 formData 顶层键聚合(编辑器已拉取)。
* 仅用于表达式变量的"疑似拼错"**警告**(2026-10-15 P0),不参与任何 error 级判定——
* 为空数组时退化为仅比对内置变量,宁少报不误杀(动态变量场景)。
*/
export function validateModel(
model: EditorModel,
depts: DeptOption[],
routers: RouterOption[] = [],
availableVars: string[] = []
): Issue[] {
const issues: Issue[] = [];
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
};
/** 已知变量集:formData 聚合键 + 内置变量(与 ConditionBox chips 同口径) */
const knownVars: ReadonlySet<string> = new Set<string>([...availableVars, ...EXPR_BUILTIN_SET]);
const rec = (items: EditorItem[], path: string): void => {
items.forEach((item) => {
@@ -55,6 +69,27 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
} else if (!a.userIds.length) {
push(item.uid, "error", path, "指定用户不能为空");
}
if (item.outlets && item.outlets.length >= 2) {
if (item.outlets.length > MAX_BRANCHES) {
push(item.uid, "error", path, `出口数最多 ${MAX_BRANCHES} 个`);
}
item.outlets.forEach((o, i) => {
if (!o.trim()) {
push(item.uid, "error", path, `第 ${i + 1} 个出口的按钮文案不能为空`);
} else if (item.outlets!.indexOf(o) !== i) {
push(item.uid, "error", path, `出口文案重复:「${o}」(按钮文案必须唯一)`);
}
});
(item.branches || []).forEach((branch, i) => {
const p = `${path}·出口「${item.outlets![i] || i + 1}」`;
if (!branch.length) {
// 空出口泳道(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
push(item.uid, "warn", p, "出口泳道为空:该出口直达汇合点(直接通过)");
return;
}
rec(branch, p);
});
}
return;
}
@@ -83,9 +118,29 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
if (item.kind === "condition") {
// 条件分支(2026-09-24):判断服务必选(未注册仅 warn,发布期后端权威拦截);
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)。
// EXPRESSION(2026-10-10):每出口条件非空(语法后端拦,前端不重复实现)
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
if (!item.routerKey) {
const expression = item.judgeMode === "EXPRESSION";
if (expression) {
item.outlets.forEach((o, i) => {
const cond = (item.conditions || [])[i] || "";
if (!cond.trim()) {
push(item.uid, "error", path,
`出口「${o || i + 1}」未配条件表达式(表达式模式下每个出口必须配置,发布会被拦截)`);
return;
}
// 变量引用静态校验(2026-10-15 P0):疑似拼错的键给 warn,**不阻断**发布。
// 理由:formData 键由子应用在运行期决定,动态变量场景误杀代价高于漏报;
// 服务端 DefValidator.warnBuiltinMisspelling 是权威口径(含 AST 级解析)。
const unknown = unknownExprVars(cond, knownVars);
if (unknown.length) {
push(item.uid, "warn", path,
`出口「${o || i + 1}」表达式引用了未在已知变量中的 ${unknown
.map((v) => `「${v}」`).join("、")},疑似拼错(若为动态变量可忽略)`);
}
});
} else if (!item.routerKey) {
push(item.uid, "error", path, "未选择判断服务(发布后运行到该节点会挂起)");
} else if (routers.length && !routers.some((r) => r.routerKey === item.routerKey && r.status === 1)) {
push(item.uid, "warn", path, `判断服务「${item.routerKey}」未注册或已停用,发布会被拦截`);
@@ -106,7 +161,8 @@ export function validateModel(model: EditorModel, depts: DeptOption[], routers:
item.branches.forEach((branch, i) => {
const branchPath = `${path}·出口「${item.outlets[i] || BRANCH_LABELS[i] || i + 1}」`;
if (!branch.length) {
push(item.uid, "error", branchPath, "分支为空,请添加节点");
// 空出口分支(2026-10-12):合法语义=该出口直达汇合点(直接通过),仅提示不阻断
push(item.uid, "warn", branchPath, "分支为空:该出口直达汇合点(直接通过)");
return;
}
rec(branch, branchPath);
+433
View File
@@ -0,0 +1,433 @@
<script setup lang="ts">
// 表达式调试弹窗(议题三,原型 S1-S5):三模式取变量 + 与运行时同一求值口径(/admin/router/eval)。
// 抽成共享组件供条件路由页与(B5)编辑器出口试算共用:外部只传 modelValue / presetExpression。
import { computed, ref, watch } from "vue";
import { Message, Tag } from "view-ui-plus";
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, flowApi } from "@/api/module/flow";
import { EXPR_BUILTIN_VARS } from "@/views/flow/admin/def-editor/model";
const props = defineProps<{ modelValue: boolean; presetExpression?: string }>();
const emit = defineEmits<{ (e: "update:modelValue", v: boolean): void }>();
type Mode = "instance" | "routelog" | "manual";
const mode = ref<Mode>("instance");
const expr = ref("");
const varsJson = ref("");
const varsParseError = ref(false);
const instances = ref<any[]>([]);
const instLoading = ref(false);
const pickedId = ref<any>(null);
const bizTypeFilter = ref("");
const traceRows = ref<any[]>([]);
const traceLoading = ref(false);
const traceInstanceId = ref<any>(null);
const result = ref<{ result: boolean; value?: string; error?: string } | null>(null);
const evaluating = ref(false);
const exprBox = ref<any>(null);
/**
* 内置变量(2026-10-15 抽共享:与 ConditionBox chips、后端 builtinEnv 同源)——
* 线上由引擎注入;schId 属管理端上下文不展示,nodeId/nodeName 需按调试目标自填。
* 展示顺序沿用原口径(schId 置末),故此处按名过滤而非直接复用数组顺序。
*/
const BUILTIN_KEYS = ["instanceId", "instanceVersion", "bizType", "bizId", "submitterId", "nodeId", "nodeName"];
const BUILTIN_SET: ReadonlySet<string> = new Set<string>(EXPR_BUILTIN_VARS);
const picked = computed(() => instances.value.find((r) => r.id === pickedId.value) || null);
const builtinRows = computed(() => {
const p = picked.value;
if (!p) return [];
return [
{ k: "instanceId", v: p.id },
{ k: "instanceVersion", v: p.version },
{ k: "bizType", v: p.bizType },
{ k: "bizId", v: p.bizId },
{ k: "submitterId", v: p.submitterId },
{ k: "nodeId", v: "", todo: true },
{ k: "nodeName", v: "", todo: true },
];
});
/** 当前变量 JSON 的顶层键(解析失败为空,不阻塞手动编辑) */
const formKeys = computed<string[]>(() => {
try {
const v = JSON.parse(varsJson.value || "{}");
return v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : [];
} catch {
return [];
}
});
/** 手动模式的键来源:最近 50 条办件 formData 聚合出现过的键(去重后截 40) */
const manualKeys = computed<string[]>(() => {
const set = new Set<string>();
instances.value.forEach((r) => {
try {
const v = JSON.parse(r.formDataJson || "{}");
if (v && typeof v === "object") Object.keys(v).forEach((k) => set.add(k));
} catch {
/* 脏 formData 跳过,不影响其它办件的键聚合 */
}
});
return Array.from(set).slice(0, 40);
});
/** chips:formData 键(手动模式取历史办件聚合键)+ 内置键;与键名重复时去重,内置键单独置灰标注 */
const chips = computed(() => Array.from(new Set([
...(mode.value === "manual" ? manualKeys.value : formKeys.value),
...BUILTIN_KEYS,
])));
/** bizType 筛选 chips(取当前页 distinct) */
const bizTypes = computed(() => Array.from(new Set(instances.value.map((r) => r.bizType).filter(Boolean))));
const shownInstances = computed(() =>
bizTypeFilter.value ? instances.value.filter((r) => r.bizType === bizTypeFilter.value) : instances.value
);
async function loadInstances(): Promise<void> {
instLoading.value = true;
try {
const res: any = await flowApi.adminInstances({ page: 1, size: 50 }).send();
instances.value = res?.content || [];
} catch (e: any) {
instances.value = [];
Message.error(e?.message || "办件列表拉取失败");
} finally {
instLoading.value = false;
}
}
/** 选中办件 → formData 解析后缩进填入;解析失败填原文并红字提示 */
function fillFromInstance(id: any): void {
const p = instances.value.find((r) => r.id === id);
if (!p) return;
result.value = null;
varsParseError.value = false;
try {
varsJson.value = JSON.stringify(JSON.parse(p.formDataJson || "{}"), null, 2);
} catch {
varsJson.value = String(p.formDataJson || "");
varsParseError.value = true;
}
if (mode.value === "routelog") void loadTrace(p.id);
}
async function loadTrace(instanceId: any): Promise<void> {
if (!instanceId) return;
traceLoading.value = true;
traceInstanceId.value = instanceId;
try {
const d: any = await flowApi.detail(instanceId).send();
traceRows.value = (d?.trace || []).filter((r: any) => r.kind === "ROUTE");
} catch (e: any) {
traceRows.value = [];
Message.error(e?.message || "办件详情拉取失败");
} finally {
traceLoading.value = false;
}
}
/** 载入该办件变量并复现(保留当前表达式) */
async function replayFromLog(): Promise<void> {
if (!traceInstanceId.value) {
Message.warning("请先选择办件");
return;
}
fillFromInstance(traceInstanceId.value);
await doEval();
}
function outletText(r: any): string {
return r.outlet ? r.outlet : "未命中";
}
/** chips 点击插入表达式光标处 */
function insertChip(key: string): void {
const input: HTMLInputElement | null = exprBox.value?.$el?.querySelector?.("input") || null;
const cur = expr.value;
if (!input) {
expr.value = cur ? `${cur} ${key}` : key;
return;
}
const at = input.selectionStart ?? cur.length;
const end = input.selectionEnd ?? at;
expr.value = cur.slice(0, at) + key + cur.slice(end);
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(at + key.length, at + key.length);
});
}
async function doEval(): Promise<void> {
if (!expr.value.trim()) {
Message.warning("请填写表达式");
return;
}
evaluating.value = true;
result.value = null;
try {
result.value = (await flowApi.routerEval({
expression: expr.value.trim(),
variablesJson: varsJson.value.trim(),
}).send()) || null;
} finally {
evaluating.value = false;
}
}
function reset(): void {
mode.value = "instance";
expr.value = props.presetExpression || "";
varsJson.value = "";
varsParseError.value = false;
pickedId.value = null;
bizTypeFilter.value = "";
traceRows.value = [];
traceInstanceId.value = null;
result.value = null;
void loadInstances();
}
watch(() => props.modelValue, (v) => {
if (v) reset();
});
</script>
<template>
<Modal :model-value="modelValue" title="表达式调试" :mask-closable="false" :width="700"
@update:model-value="(v: boolean) => emit('update:modelValue', v)">
<p class="em-note">
与 CONDITION 节点 EXPRESSION 模式同一套求值(AviatorScript 沙箱)。变量 = 样例 JSON 的键 +
线上另注入 schId/bizType/bizId/submitterId/instanceId/instanceVersion/nodeId/nodeName
(内置变量条仅展示,不参与求值,需在表达式里用到时请自行写入样例 JSON)。
变量未提供会报错——线上同口径(宁挂起不误放行,不按 nil 求值)。
</p>
<RadioGroup v-model="mode" type="button" button-style="solid" class="em-seg">
<Radio label="instance">从办件导入</Radio>
<Radio label="routelog">从路由日志导入</Radio>
<Radio label="manual">手动编写</Radio>
</RadioGroup>
<div class="em-fld">
<label>表达式<span class="em-sub">AviatorScript 沙箱 · 与运行时同一求值口径</span></label>
<Input ref="exprBox" v-model="expr" class="em-mono" placeholder="表达式,如 days >= 3" :maxlength="200" />
<div v-if="chips.length" class="em-chips">
<span v-for="k in chips" :key="k" class="em-chip" :class="{ bt: BUILTIN_SET.has(k) }"
@click="insertChip(k)">{{ k }}</span>
</div>
<div v-if="chips.length" class="em-sub">↑ 可用变量(点击插入表达式光标处;灰色=内置变量,由运行时自动注入)</div>
</div>
<!-- 模式1/2 共用办件选择器;路由日志模式选中即拉该办件 trace -->
<div v-if="mode !== 'manual'" class="em-fld">
<label>选择办件
<span class="em-sub">变量 = 该办件 formData 原样 + 内置变量;选完自动填充,可再手工改</span>
</label>
<Select v-model="pickedId" filterable clearable :loading="instLoading"
placeholder="按标题/业务键搜索最近 50 条办件" @on-change="fillFromInstance">
<Option v-for="r in shownInstances" :key="r.id" :value="r.id"
:label="`#${r.id} ${r.bizTitle || r.bizId || ''}`">
#{{ r.id }} · {{ r.bizType || "-" }}/{{ r.bizId || "-" }} · {{ r.bizTitle || "(无标题)" }}
· {{ r.submitterName || r.submitterId || "-" }} ·
{{ (r.submitTime || "").replace("T", " ").slice(0, 16) || "-" }}
<Tag :color="INSTANCE_STATUS_COLOR[r.status] || 'gray'" style="margin-left: 6px">
{{ INSTANCE_STATUS[r.status] || r.status }}
</Tag>
</Option>
</Select>
<div v-if="bizTypes.length" class="em-chips">
<span class="em-chip flt" :class="{ on: !bizTypeFilter }" @click="bizTypeFilter = ''">全部类型</span>
<span v-for="t in bizTypes" :key="t" class="em-chip flt" :class="{ on: bizTypeFilter === t }"
@click="bizTypeFilter = t">{{ t }}</span>
</div>
<div v-if="picked" class="em-bi">
<span class="em-bi-t">内置变量(线上由引擎注入,只读展示):</span>
<span v-for="b in builtinRows" :key="b.k" class="em-bi-i">
{{ b.k }}={{ b.todo ? "(按调试目标自填)" : b.v }}
</span>
</div>
</div>
<div v-if="mode === 'routelog'" class="em-fld">
<div v-if="traceLoading" class="em-sub">正在读取该办件的路由判断留痕…</div>
<template v-else-if="traceRows && traceRows.length">
<Table :columns="[
{ title: '节点名', key: 'nodeName', width: 140 },
{ title: '注册键', key: 'routerKey', width: 150 },
{ title: '命中出口', width: 120, render: (h, p) => outletText(p.row) },
{ title: 'attempt', key: 'attempt', width: 80 },
{ title: '时间', width: 140, render: (h, p) => (p.row.time || '').replace('T', ' ').slice(0, 19) || '-' },
]" :data="traceRows" size="small" :max-height="180" no-data-text="暂无数据" />
<div v-for="(r, i) in traceRows" :key="`raw${i}`" class="em-raw">
<span class="em-sub">{{ r.nodeName }} rawResponse({{ (r.rawResponse || "").length }} 字):</span>
<pre :title="r.rawResponse || ''">{{ (r.rawResponse || "(无留痕)").slice(0, 200)
}}{{ (r.rawResponse || "").length > 200 ? "…" : "" }}</pre>
</div>
<Button size="small" :loading="evaluating" @click="replayFromLog">载入该办件变量并复现</Button>
<div class="em-sub">复现后可改表达式定位问题,再到办件台账对该办件重跑路由。</div>
</template>
<div v-else class="em-sub">
{{ picked ? "该办件无条件判断记录" : "选择办件后展示其 flw_route_log 逐边留痕" }}
</div>
</div>
<div class="em-fld">
<label>样例变量 JSON(formData)
<span v-if="varsParseError" style="color: #ed4014">该办件 formData 不是合法 JSON,已填原文,请手工修正</span>
<span v-else-if="mode === 'manual'" class="em-sub">粘贴应用实际传递的 formData JSON;不知道传什么?切「从办件导入」取真实变量</span>
</label>
<Input v-model="varsJson" type="textarea" :rows="8" :class="{ 'em-bad': varsParseError }"
placeholder='如 {"days": 5, "bizType": "leave"}' />
</div>
<hr class="em-divider">
<div class="em-fld">
<label>求值结果</label>
<div v-if="!result" class="em-sub">点击「求值」查看结果(与 CONDITION 节点 EXPRESSION 模式同一求值口径)。</div>
<template v-else-if="result.error">
<div class="em-err">✗ 求值失败:{{ result.error }}</div>
<div class="em-sub">宁挂起不误放行——变量缺失/类型不符都会在此暴露,线上同口径挂起而非按 nil 放行</div>
</template>
<template v-else-if="result.result">
<div class="em-ok">✓ true(命中该出口)
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
</div>
<div class="em-sub">线上行为:条件节点按出口顺序取首个为真者走该分支</div>
</template>
<template v-else>
<div class="em-warn">✗ false(不命中)
<span v-if="result.value !== undefined" class="em-sub">求值值:{{ result.value }}</span>
</div>
<div class="em-sub">线上行为:该出口不走;全部出口不命中将挂起等管理端重跑路由</div>
</template>
</div>
<template #footer>
<span class="em-hint">变量缺失即报错——线上同口径(宁挂起不误放行,不按 nil 求值)</span>
<Button @click="emit('update:modelValue', false)">关闭</Button>
<Button type="primary" :loading="evaluating" @click="doEval">求值</Button>
</template>
</Modal>
</template>
<style scoped>
.em-note {
color: #808695;
font-size: 12px;
line-height: 1.7;
margin-bottom: 10px;
}
.em-seg {
margin-bottom: 12px;
}
.em-fld {
margin-bottom: 12px;
}
.em-fld > label {
display: block;
font-size: 12px;
color: #515a6e;
margin-bottom: 4px;
}
.em-sub {
color: #a5abb8;
font-size: 12px;
line-height: 1.6;
}
.em-fld > label .em-sub {
margin-left: 6px;
}
.em-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 4px;
}
.em-chip {
border: 1px solid #e3e6eb;
border-radius: 12px;
padding: 2px 10px;
font-size: 12px;
color: #0b7d72;
background: #f2fbf9;
cursor: pointer;
}
.em-chip.flt {
color: #515a6e;
background: #fff;
}
/* 内置变量 chip:与 formData 键区分(对齐原型 .chip.bt 口径) */
.em-chip.bt {
border-color: #dcdee2;
color: #515a6e;
background: #f5f7fa;
}
.em-chip.bt:hover {
color: #fff;
background: #515a6e;
border-color: #515a6e;
}
.em-chip.flt.on {
border-color: var(--brand);
color: var(--brand);
}
.em-bi {
margin-top: 8px;
background: #f7f9fc;
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.8;
}
.em-bi-t {
color: #0b7d72;
font-weight: 600;
}
.em-bi-i {
margin-right: 10px;
font-family: Consolas, Monaco, monospace;
color: #515a6e;
}
.em-mono :deep(input) {
font-family: Consolas, Monaco, monospace;
}
.em-bad :deep(.ivu-input) {
border-color: #ed4014;
}
.em-raw {
margin: 6px 0;
}
.em-raw pre {
margin: 2px 0 0;
padding: 6px 8px;
background: #fafbfd;
border: 1px solid #eef0f3;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.em-divider {
border: none;
border-top: 1px solid #eef0f3;
margin: 12px 0;
}
.em-ok {
color: #19be6b;
font-size: 13px;
}
.em-warn {
color: #ff9900;
font-size: 13px;
}
.em-err {
color: #ed4014;
font-size: 13px;
}
.em-hint {
float: left;
color: #b0b6c1;
font-size: 12px;
line-height: 32px;
}
</style>
+543
View File
@@ -0,0 +1,543 @@
<script setup lang="ts">
// 办件正文(共用组件,2026-10-11):嵌入页 /embed/instance 与管理面办件详情 /instance/:instanceId
// 数据源同出 InstanceDetail(OpenEmbedController 复用管理面出参),展示层共用本组件,样式单点维护。
// - embed(缺省,关键信息):基本信息 / 办理进度三视图 / 流程示意图折叠 / 业务信息(只认 summaryFields)
// - full(管理面,全量):另有 流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号)、
// 业务表单数据折叠、业务详情链接;业务信息只认子应用传递的 summaryFields,无则隐藏
// 口径:人员/科室切换 emit('mode-change') 由管理面写 query 持久化(嵌入页 URL 归子应用管,忽略即可)。
// 宽度:版式按嵌入场景调优(740 抽屉内容约 708px),full 下根类 max-width 封顶防宽屏拉散(见 .is-full)。
import { ref, computed, onMounted, nextTick } from "vue";
import { Tag } from "view-ui-plus";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import { fitDiagram } from "@/utils/diagramImg";
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
const props = defineProps<{
/** InstanceDetail 形状对象(两页同源) */
detail: any;
/** true=管理面全量(流程信息/业务表单数据/业务详情);缺省 embed 关键信息 */
full?: boolean;
/** 流程示意图地址(两页端点不同:token 图 / instanceId 图,由页面生成传入) */
diagramSrc: string;
/** 人员/科室口径初值(管理面从 query 恢复;缺省 user) */
initialMode?: string;
}>();
const emit = defineEmits<{ (e: "mode-change", val: string): void }>();
/** 办理进度视图:node=按节点(缺省)/ timeline=时间线 / list=列表 */
const viewMode = ref<string>("node");
function switchView(val: string) {
viewMode.value = val;
}
/** 显示口径:dept=科室视图 / user=人员视图(缺省)。切换只 emit,落不落 URL 由页面决定 */
const traceMode = ref<string>(props.initialMode === "dept" ? "dept" : "user");
function switchMode(val: string) {
traceMode.value = val;
emit("mode-change", val);
}
const diagramEl = ref<HTMLImageElement | null>(null);
function onDiagramLoad() {
fitDiagram(diagramEl.value);
}
// 折叠时容器 clientWidth=0,fitDiagram 取不到收缩上限;展开这一刻再适配一次
function onDiagramToggle(e: Event) {
if ((e.target as HTMLDetailsElement).open) fitDiagram(diagramEl.value);
}
/** 业务信息唯一内容 = 约定键 bizSnapshot.summaryFields;由发起流程的子应用决定展示字段与中文 label。
* 形状错 / 无值 ⇒ [],不再 fallback 平铺 bizSnapshot 原始英文 key(避免详情页出现 itemTitle / deptName 等)。 */
const summaryFields = computed(() => {
const raw = props.detail?.bizSnapshot;
if (!raw) return [];
let obj: any;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [];
}
const sf = obj?.summaryFields;
if (!Array.isArray(sf) || !sf.length) return [];
return sf
.filter((x: any) => x && x.label !== null && x.label !== undefined)
.slice(0, 6)
.map((x: any) => ({
label: String(x.label),
value: Array.isArray(x.value)
? (x.value.join("、") || "-")
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
}));
});
/** 业务信息最终渲染行:只认子应用传递的 summaryFields */
const bizRows = computed(() => summaryFields.value);
/** full 专用:流程信息五项(专业字段只进管理面,嵌入页不外露) */
const flowInfoRows = computed(() => {
const d = props.detail || {};
return [
{ label: "流程编码", value: d.defCode || "-" },
{ label: "流程版本", value: d.defVersion !== null && d.defVersion !== undefined ? `v${d.defVersion}` : "-" },
{ label: "业务类型", value: d.bizType || "-" },
{ label: "业务编号", value: d.bizId !== null && d.bizId !== undefined && d.bizId !== "" ? `#${d.bizId}` : "-" },
{ label: "实例号", value: d.instanceId ? `#${d.instanceId}` : "-" },
];
});
/** full 专用:业务表单数据(解析失败降级一行原文;无 formData 时整块隐藏) */
const formRows = computed(() => {
if (!props.full) return [];
const raw = props.detail?.formDataJson;
if (raw === null || raw === undefined || raw === "") return [];
let obj: any = null;
try {
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
return [{ label: "表单原文", value: String(raw) }];
}
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return Object.entries(obj).map(([k, v]) => ({
label: k,
value: v == null ? "-"
: Array.isArray(v)
? (v.map((item: any) => item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item)).join("、") || "-")
: (typeof v === "object" ? JSON.stringify(v) : String(v)),
}));
}
return [{ label: "表单原文", value: String(raw) }];
});
/** 业务详情链接(仅管理面):bizUrl 是裸相对路径,必须用后端解析好的绝对地址(bizUrlAbs),
* 否则会落到流程中心自己的 origin 上;解析不出就不给入口,不给打不开的链接。 */
const bizUrlAbs = computed(() => {
const abs = props.detail?.bizUrlAbs;
if (abs) return abs;
const raw = props.detail?.bizUrl;
return /^https?:\/\//i.test(raw || "") ? raw : "";
});
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG) */
const RECORD_KIND_TAG: Record<string, string> = {
SUBMIT: "green",
RESUBMIT: "green",
WITHDRAW: "orange",
APPROVE: "green",
REJECT: "red",
RETURN: "orange",
TRANSFER: "cyan",
INVALID: "default",
PENDING: "blue",
CC: "cyan",
ROUTE: "purple",
};
/** 留痕事件行:不参与节点聚合,每行独立成一步(口径同流程中心 toProgressVo) */
const EVENT_KINDS = ["SUBMIT", "RESUBMIT", "WITHDRAW", "CC", "ROUTE"];
/** 多轮才显示"轮次"列(单轮不打扰,口径同 TraceTimeline 的 multiRound) */
const multiRound = computed(() =>
(props.detail?.trace || []).some((t: any) => (t.round || 1) > 1));
function fmtTime(v: string | null | undefined) {
return v ? String(v).replace("T", " ").replace(/\.\d+$/, "") : "";
}
/** 行发生时间:留痕行取 time,任务行取 handleTime,未办取 arriveTime */
function rowTime(r: any): string {
return r.time || r.handleTime || r.arriveTime || "";
}
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人"、
* 抄送显"抄送对象";发起/撤回/重提显"提交人") */
function recordActor(t: any): string {
if (t.kind === "ROUTE") return "流程引擎";
if (traceMode.value !== "dept") {
return t.assigneeName || t.assigneeId || "-";
}
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
if (t.kind === "CC") {
return ccActorText(t);
}
if (t.assigneeType === "DEPT_KEY") {
return t.deptName || t.deptKey || t.assigneeName || "-";
}
if (t.assigneeType === "USERS") {
return "指定办理人";
}
return t.kind === "SUBMIT" || t.kind === "RESUBMIT" || t.kind === "WITHDRAW"
? "提交人"
: (t.assigneeName || "-");
}
/** 展示层状态文案(设计图口径:已提交 / 已同意 / 待审核…)。
* ⚠️ 刻意不复用全局 TASK_STATUS(0 待办 / 1 同意 / …)——它被 TraceTimeline 与其他页面共用,
* 改它会把全站文案一起改掉;此处只做本组件的展示层映射,三视图共用一份。 */
const TRACE_STATUS_TEXT: Record<number, string> = {
0: "待审核", 1: "已同意", 2: "已拒绝", 3: "失效",
4: "已退回", 5: "已移交", 6: "已提交", 7: "已撤回",
};
/** 动作标签(口径同 TraceTimeline.actionText:CC/ROUTE 专用文案,任务行查展示层映射) */
function recordAction(t: any): string {
if (t.kind === "CC") return "已抄送";
if (t.kind === "ROUTE") return "系统判断";
return TRACE_STATUS_TEXT[t.status] || String(t.status ?? "");
}
/** 节点步结论:任一拒绝 > 任一同意 > 全失效 > 待审核(口径=流程中心 OapiFlowService.stepLabel) */
function stepLabel(rows: any[]): string {
let denied = false, approved = false, invalid = true;
rows.forEach((r) => {
const s = r.status === null || r.status === undefined ? -1 : r.status;
if (s === 2) denied = true;
if (s === 1) approved = true;
if (s === 0 || s === 1 || s === 2 || s === 4 || s === 5) invalid = false;
});
if (denied) return "已拒绝";
if (approved) return "已同意";
return invalid ? "失效" : "待审核";
}
function labelTagClass(label: string): string {
if (label === "已同意") return "green";
if (label === "已拒绝") return "red";
if (label === "待审核") return "blue";
return "default";
}
/** dept 档下抄送行折叠为一行(N 个接收人 → "抄送对象 N 人",不出现人名);user 档原样。
* 节点视图/列表视图共用;时间线视图由 TraceTimeline 内部折叠(口径同源) */
const displayTrace = computed(() => collapseCcRows(props.detail?.trace || [], traceMode.value));
/**
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
*/
const nodeSteps = computed(() => {
const trace: any[] = displayTrace.value;
const act: string[] = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
const steps: any[] = [];
const byNode: Record<string, any> = {};
rows.forEach((r) => {
if (EVENT_KINDS.includes(r.kind)) {
steps.push({ type: "event", nodeId: r.nodeId, nodeName: r.nodeName, rows: [r], t: r._t });
return;
}
let s = byNode[r.nodeId];
if (!s) {
s = { type: "node", nodeId: r.nodeId, nodeName: r.nodeName, rows: [], t: r._t };
byNode[r.nodeId] = s;
steps.push(s);
}
s.rows.push(r);
if ((r._t || "") > (s.t || "")) s.t = r._t;
});
steps.forEach((s) => {
if (s.type === "node") {
s.active = act.includes(s.nodeId);
s.label = s.active ? "待审核" : stepLabel(s.rows);
} else {
s.label = recordAction(s.rows[0]);
}
});
return steps.sort((a, b) => (a.t || "").localeCompare(b.t || ""));
});
function stepClass(s: any): string {
if (s.type !== "node") return "";
if (s.active) return "is-active";
if (s.label === "已拒绝") return "is-reject";
if (s.label === "已同意") return "is-done";
return "";
}
function stepTagColor(s: any): string {
return s.type === "node" ? labelTagClass(s.label) : (RECORD_KIND_TAG[s.rows[0].kind] || "default");
}
/** 办理人行色:1 绿 / 2 红 / 3 失效置灰删除线 / 0 待办 */
function rowClass(r: any): string {
if (r.status === 1) return "is-done";
if (r.status === 2) return "is-reject";
if (r.status === 3) return "is-invalid";
return "";
}
function rowTimeText(r: any): string {
if (r.handleTime) return `办理于 ${fmtTime(r.handleTime)}`;
if (r.arriveTime) return `到达 ${fmtTime(r.arriveTime)}`;
return fmtTime(rowTime(r));
}
/** 基本信息「当前节点」:currentNodes 命中的节点名 + 办理中后缀 */
const currentNodeText = computed(() => {
const act = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
const names = nodeSteps.value
.filter((s) => s.type === "node" && act.includes(s.nodeId))
.map((s) => s.nodeName || s.nodeId);
return names.length ? `${names.join("、")}(办理中)` : "-";
});
const metaItems = computed(() => {
const d = props.detail || {};
const items = [
{ k: "提交人", v: d.submitterName || "-" },
{ k: "提交时间", v: fmtTime(d.submitTime) || "-" },
{ k: "办结时间", v: fmtTime(d.finishTime) || "-" },
];
if (d.currentNodes) items.push({ k: "当前节点", v: currentNodeText.value });
return items;
});
/** 表格列:Drawer iframe 宽约 708px(740 抽屉)按此设计,full 下容器有 max-width 封顶不会拉散;
* 轮次列仅多轮时出现 */
const recordColumns = computed(() => {
const cols: any[] = [
{ title: "时间", width: 150, render: (h: any, p: any) => h("span", fmtTime(p.row.time)) },
];
if (multiRound.value) {
cols.push({
title: "轮次", width: 80, align: "center",
render: (h: any, p: any) => h("span", `第 ${p.row.round || 1} 轮`),
});
}
cols.push(
{
title: "节点", minWidth: 120,
render: (h: any, p: any) => h("span", p.row.nodeName || p.row.nodeId || "-"),
},
{ title: "办理人", width: 110, render: (h: any, p: any) => h("span", recordActor(p.row)) },
{
title: "动作", width: 90, align: "center",
render: (h: any, p: any) =>
h(Tag, { size: "small", color: RECORD_KIND_TAG[p.row.kind] || "default" },
() => recordAction(p.row)),
},
{
title: "意见 / 去向", minWidth: 180,
render: (h: any, p: any) => {
const parts: any[] = [];
if (p.row.opinion) parts.push(h("span", `「${p.row.opinion}」`));
if (p.row.edgeName) parts.push(h("span", `去向:${p.row.edgeName}`));
if (p.row.kind === "ROUTE") {
// outlet 空=判断失败挂起(同 TraceTimeline 的 route-hung 文案),等管理端重跑路由
parts.push(p.row.outlet
? h("span", `→ ${p.row.outlet}`)
: h("span", { style: "color:#ed4014" }, `判断失败已挂起(第 ${p.row.attempt || 1} 次)`));
}
return parts.length ? h("span", parts) : h("span", { style: "color:#999" }, "-");
},
},
);
return cols;
});
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
onMounted(async () => {
await nextTick();
if (diagramEl.value?.complete) {
fitDiagram(diagramEl.value);
}
});
</script>
<template>
<div class="embed-body" :class="{ 'is-full': full }">
<!-- 流程信息(仅管理面):专业字段不外露给子应用嵌入页 -->
<div v-if="full" class="embed-sec">
<div class="embed-sec-h"><span>流程信息</span></div>
<div class="sf">
<div v-for="r in flowInfoRows" :key="r.label" class="sf-row">
<span class="sf-k">{{ r.label }}</span>
<span class="sf-v">{{ r.value }}</span>
</div>
</div>
</div>
<div class="embed-sec">
<div class="embed-sec-h"><span>基本信息</span></div>
<div class="embed-meta">
<div v-for="m in metaItems" :key="m.k" class="embed-meta-i">
<div class="embed-meta-k">{{ m.k }}</div>
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
</div>
<div v-if="full && bizUrlAbs" class="embed-meta-i">
<div class="embed-meta-k">业务详情</div>
<div class="embed-meta-v"><a :href="bizUrlAbs" target="_blank">查看业务详情</a></div>
</div>
</div>
</div>
<div class="embed-sec">
<div class="embed-sec-h">
<span>办理进度</span>
<RadioGroup class="embed-view-seg" :model-value="viewMode" size="small" type="button" @on-change="switchView">
<Radio label="node">按节点</Radio>
<Radio label="timeline">时间线</Radio>
<Radio label="list">列表</Radio>
</RadioGroup>
<!-- 口径对三视图均生效(时间线走 :mode、列表走办理人列)⇒ 与视图切换器同排常驻 -->
<RadioGroup class="embed-mode-seg" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
<Radio label="user">人员视图</Radio>
<Radio label="dept">科室视图</Radio>
</RadioGroup>
</div>
<!-- ① 按节点:同节点多任务每人一行,步内保持后端任务序 -->
<template v-if="viewMode === 'node'">
<div class="embed-steps" v-if="nodeSteps.length">
<div v-for="(s, i) in nodeSteps" :key="`${s.type}-${s.nodeId || 'x'}-${i}`"
class="embed-step" :class="stepClass(s)">
<span class="embed-step-dot"></span>
<div class="embed-step-body">
<div class="embed-step-head">
<span class="embed-step-name">{{ s.nodeName || s.nodeId || "—" }}</span>
<Tag size="small" :color="stepTagColor(s)">{{ s.label }}</Tag>
<Tag v-if="s.active" size="small" color="blue">办理中</Tag>
</div>
<div v-for="(r, j) in s.rows" :key="r.rowKey || j" class="embed-assignee" :class="rowClass(r)">
<span class="embed-an">{{ recordActor(r) }}</span>
<span class="embed-am">{{ recordAction(r) }}</span>
<span class="embed-am">{{ rowTimeText(r) }}</span>
<span class="embed-ao" v-if="r.opinion">
{{ r.kind === "REJECT" ? "拒绝意见" : "意见" }}:{{ r.opinion }}
</span>
<span class="embed-ao" v-if="r.edgeName">去向:{{ r.edgeName }}</span>
<span class="embed-ao route-hung" v-if="r.kind === 'ROUTE' && !r.outlet">
判断失败已挂起(第 {{ r.attempt || 1 }} 次),等待管理端重跑路由
</span>
</div>
</div>
</div>
</div>
<div class="muted" v-else>暂无轨迹</div>
</template>
<!-- ② 时间线:扁平行 + 多轮分隔(复用共用组件,口径不变) -->
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
:status-text="TRACE_STATUS_TEXT" />
<!-- ③ 列表:整个流程周期的完整事件流水(dept 档抄送行已折叠,见 displayTrace) -->
<Table v-else size="small" :columns="recordColumns" :data="displayTrace" />
</div>
<details class="embed-fold" @toggle="onDiagramToggle">
<summary>流程示意图<slot name="diagram-link" /></summary>
<div class="embed-fold-b">
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
</div>
</details>
<!-- 业务信息:只认子应用传递的 summaryFields,无值整块隐藏 -->
<details v-if="bizRows.length" class="embed-fold">
<summary>业务信息</summary>
<div class="embed-fold-b">
<div class="sf">
<div v-for="f in bizRows" :key="f.label" class="sf-row">
<span class="sf-k">{{ f.label }}</span>
<span class="sf-v">{{ f.value }}</span>
</div>
</div>
</div>
</details>
<!-- 业务表单数据(仅管理面):嵌入页不外露原始表单 -->
<details v-if="formRows.length" class="embed-fold">
<summary>业务表单数据</summary>
<div class="embed-fold-b">
<div class="sf">
<div v-for="f in formRows" :key="f.label" class="sf-row">
<span class="sf-k">{{ f.label }}</span>
<span class="sf-v">{{ f.value }}</span>
</div>
</div>
</div>
</details>
</div>
</template>
<style scoped>
/* 根类不用 .page/.block:UnoCSS 原子类同名会以更高优先级盖掉 scoped 样式(铁律) */
/* 宽度适配:版式按嵌入场景调优(740 抽屉内容约 708px),管理面内容区更宽,full 下封顶防拉散;
嵌入页不加限制(iframe 内 100%,行为不变),窄窗口两场景都自然收缩 */
.embed-body.is-full { max-width: 900px; }
.muted { color: #999; }
/* 区块标题:左侧蓝条 + 右侧控件 */
.embed-sec { margin-bottom: 18px; }
.embed-sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
.embed-sec-h > span:first-child { position: relative; padding-left: 9px; }
.embed-sec-h > span:first-child::before {
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
}
/* 两处切换器(视图 / 口径)同排在标题行,同为 iView small button group、几何与配色一致
(活动态 #0d9488、非活动 #515a6e/#dcdee2)。⚠️ 两者都落在 .embed-sec-h(600) 内 ⇒ 会继承粗体,
必须显式回常规(管理面同款控件 .trace-mode 也是这个写法)。 */
.embed-view-seg { margin-left: auto; font-weight: normal; }
/* 口径紧随视图切换器:与 .embed-sec-h 的 10px gap 叠加为 24px,避免两组按钮被看成一排五选一 */
.embed-mode-seg { margin-left: 14px; font-weight: normal; }
/* 基本信息:四列等宽(740 抽屉实测放得下时间戳,超出省略号 + title 兜底;full 下业务详情作第 5 格换行) */
.embed-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #f2f4f7; border-radius: 8px; overflow: hidden; }
.embed-meta-i { padding: 11px 14px; border-right: 1px solid #e8eaec; min-width: 0; }
.embed-meta-i:last-child { border-right: none; }
.embed-meta-k { font-size: 12px; color: #808695; margin-bottom: 4px; }
.embed-meta-v { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 按节点视图 */
.embed-steps { position: relative; }
.embed-step { display: flex; gap: 11px; padding-bottom: 16px; position: relative; }
.embed-step:last-child { padding-bottom: 2px; }
.embed-step:not(:last-child)::before {
content: ""; position: absolute; left: 5px; top: 16px; bottom: 0; width: 2px; background: #f2f4f7;
}
.embed-step-dot {
width: 12px; height: 12px; border-radius: 50%; background: #c8ced8; margin-top: 5px; flex-shrink: 0;
z-index: 1; border: 2px solid #fff; box-shadow: 0 0 0 1px #e8eaec;
}
.embed-step.is-done .embed-step-dot { background: #19be6b; box-shadow: 0 0 0 1px #b7ebc9; }
.embed-step.is-reject .embed-step-dot { background: #ed4014; box-shadow: 0 0 0 1px #ffd2c9; }
.embed-step.is-active .embed-step-dot { background: var(--brand); box-shadow: 0 0 0 3px rgba(45, 140, 240, .18); }
.embed-step-body { flex: 1; min-width: 0; }
.embed-step-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; flex-wrap: wrap; }
.embed-step-name { font-size: 14px; font-weight: 600; }
.embed-assignee {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px;
padding: 6px 10px; margin-bottom: 5px; border-radius: 6px; background: #fafbfc; border: 1px solid #f0f2f5;
}
.embed-assignee:last-child { margin-bottom: 0; }
.embed-an { font-weight: 600; }
.embed-assignee.is-done .embed-an { color: #19be6b; }
.embed-assignee.is-reject .embed-an { color: #ed4014; }
.embed-am { color: #808695; font-size: 12px; }
.embed-ao { color: #515a6e; width: 100%; padding-top: 2px; }
.embed-ao.route-hung { color: #ed4014; }
.embed-assignee.is-invalid { opacity: .5; }
.embed-assignee.is-invalid .embed-an { text-decoration: line-through; text-decoration-color: #b9bcc4; }
/* 折叠块:流程示意图 / 业务信息 / 业务表单数据,默认收缩 */
.embed-fold { border: 1px solid #f2f4f7; border-radius: 8px; background: #fff; margin-bottom: 18px; }
.embed-fold > summary {
cursor: pointer; list-style: none; padding: 12px 16px; display: flex; align-items: center; gap: 10px;
font-size: 14px; font-weight: 600; color: #17233d;
}
.embed-fold > summary::-webkit-details-marker { display: none; }
.embed-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; transition: .2s; font-size: 12px; }
.embed-fold[open] > summary::after { transform: rotate(180deg); }
.embed-fold-b { padding: 2px 16px 16px; }
.diagram-img {
max-width: 100%; height: auto; display: block; margin: 0 auto;
border: 1px solid #f2f4f7; border-radius: 6px;
}
/* 行式 k/v(业务信息 / 流程信息 / 业务表单数据共用):折叠块内再收一层卡,与行成组(设计基线口径) */
.sf { border: 1px solid #e6eefb; border-radius: 8px; overflow: hidden; background: #fcfdff; }
.sf-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #eef3fb; font-size: 13px; }
.sf-row:last-child { border-bottom: none; }
.sf-k { width: 84px; flex-shrink: 0; color: #808695; }
.sf-v { flex: 1; min-width: 0; word-break: break-all; font-weight: 500; }
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
// 办件详情抽屉(管理面列表页复用):非 iframe,内部直接复用 InstanceBody(full 口径)。
// 宽度与 Notice 审批记录抽屉对齐 740px;关闭后清空状态,下次打开重新加载。
import { ref, computed, watch } from "vue";
import { useRouter } from "vue-router";
import { Drawer, Tag } from "view-ui-plus";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
import config from "@/assets/config/host.config";
import { toAppHashUrl } from "@/utils/appUrl";
const router = useRouter();
const props = defineProps<{
modelValue: boolean;
instanceId?: string | number;
}>();
const emit = defineEmits<{ (e: "update:modelValue", val: boolean): void }>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
const loading = ref(false);
const detail = ref<any>(null);
const mode = ref("user");
async function load() {
const id = props.instanceId;
if (!id || !visible.value) return;
loading.value = true;
try {
detail.value = await flowApi.detail(String(id)).send();
} finally {
loading.value = false;
}
}
watch(() => [props.instanceId, visible.value], load, { immediate: true });
watch(visible, (val) => {
if (!val) detail.value = null;
});
function switchMode(val: string) {
mode.value = val;
}
const diagramSrc = computed(() => {
if (!detail.value?.instanceId) return "";
return `${config.serviceHost}/api/flow/diagram.png?instanceId=${detail.value.instanceId}&mode=${mode.value}`;
});
function openDiagram() {
if (!detail.value?.instanceId) return;
// 原写法 `${origin}${BASE_URL}#/diagram?…` 里 BASE_URL 构建期是 "./",拼出
// `https://host./#/…`(域名尾点);改为按当前真实地址丢 hash 再接目标 hash
const href = router.resolve({
path: "/diagram",
query: { instanceId: String(detail.value.instanceId), mode: mode.value },
}).href;
window.open(toAppHashUrl(href), "_blank");
}
function statusName(s: number) {
return INSTANCE_STATUS[s] || s;
}
function statusColor(s: number) {
return INSTANCE_STATUS_COLOR[s] || "gray";
}
</script>
<template>
<Drawer
v-model="visible"
:title="detail?.bizTitle || '办件详情'"
:width="740"
:transfer="false"
:styles="{ body: { padding: '0' } }"
>
<div v-if="loading" class="drawer-loading muted">加载中…</div>
<template v-else-if="detail">
<div class="drawer-head">
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
</div>
<div class="drawer-body">
<InstanceBody
full
:detail="detail"
:diagram-src="diagramSrc"
:initial-mode="mode"
@mode-change="switchMode"
>
<template #diagram-link>
<a class="fold-link" @click.stop.prevent="openDiagram">查看大图</a>
</template>
</InstanceBody>
</div>
</template>
</Drawer>
</template>
<style scoped>
.drawer-head { padding: 12px 16px 8px; }
.drawer-body { padding: 0 16px 16px; }
.drawer-loading { padding: 48px 16px; text-align: center; }
.fold-link { font-size: 12px; font-weight: normal; }
</style>
+15 -6
View File
@@ -2,16 +2,19 @@
// 流程轨迹时间线(2026-09-18 新增,办件详情页与办理页共用一个实现):
// ① 时间流:后端已按"该行实际发生的时间"升序返回(time 字段),此处不再排序,只按 round 分轮展示;
// ② 两档显示:user=按人员(默认,显示办理人姓名);dept=按科室(DEPT_KEY 节点显科室名、USERS 节点显
// "指定办理人"、发起/撤回/重提显"提交人"),口径与流程示意图的科室视图一致;
// "指定办理人"、发起/撤回/重提显"提交人"、抄送显"抄送对象"),口径与流程示意图的科室视图一致;
// ③ 撤回重走留痕:kind=WITHDRAW/RESUBMIT 的行由后端留痕事件行提供,重提处插入"第 N 轮"分隔。
import { computed } from "vue";
import { TASK_STATUS } from "@/api/module/flow";
import { collapseCcRows, ccActorText } from "@/utils/flowTrace";
const props = defineProps<{
/** 后端 detail.trace(已按时间升序,含 kind/time/round/科室富化字段) */
trace: any[];
/** user=按人员(缺省)|dept=按科室 */
mode?: string;
/** 展示层状态文案(缺省用全局 TASK_STATUS);嵌入页传自己的口径,管理面不传即维持原样 */
statusText?: Record<number, string>;
}>();
/** 事件类型 → 时间线圆点颜色 */
@@ -44,7 +47,8 @@ const KIND_TAG: Record<string, string> = {
ROUTE: "purple",
};
const rows = computed(() => props.trace || []);
/** dept 档下抄送行折叠为一行(N 个接收人 → "抄送对象 N 人");user 档原样 */
const rows = computed(() => collapseCcRows(props.trace || [], props.mode));
/** 多轮才有轮次分隔(单轮不打扰) */
const multiRound = computed(() => rows.value.some((t: any) => (t.round || 1) > 1));
@@ -61,12 +65,16 @@ function roundTitle(round: number): string {
return at ? `第 ${round} 轮审批(重新提交于 ${at})` : `第 ${round} 轮审批`;
}
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人/抄送对象(不暴露具体人名) */
function actor(t: any): string {
if (t.kind === "ROUTE") return "流程引擎";
if (props.mode !== "dept") {
return t.assigneeName || t.assigneeId || "-";
}
// 抄送行先判:科室抄送显科室名、指定人抄送显"抄送对象",均不回落人名
if (t.kind === "CC") {
return ccActorText(t);
}
if (t.assigneeType === "DEPT_KEY") {
return t.deptName || t.deptKey || t.assigneeName || "-";
}
@@ -84,7 +92,8 @@ function actor(t: any): string {
function actionText(t: any): string {
if (t.kind === "CC") return "已抄送";
if (t.kind === "ROUTE") return "系统判断";
return TASK_STATUS[t.status] || String(t.status ?? "");
const text = props.statusText || TASK_STATUS;
return text[t.status] || String(t.status ?? "");
}
function fmtTime(v: string | null | undefined): string {
@@ -126,9 +135,9 @@ function fmtTime(v: string | null | undefined): string {
padding: 2px 8px;
display: inline-block;
font-size: 12px;
color: #2d8cf0;
color: var(--brand);
background: #f0f7ff;
border-left: 3px solid #2d8cf0;
border-left: 3px solid var(--brand);
border-radius: 2px;
}
.line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
+48
View File
@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { toAppHashUrl } from "../src/utils/appUrl";
/** 整页跳转地址单测:只锁「最终只有一个 hash」这条线上缺陷口径,不替代浏览器实测 */
test("办理页入口:resolve 出的 #/diagram 不再追加到当前 hash 之后", () => {
const out = toAppHashUrl(
"#/diagram?instanceId=107&mode=user",
"https://base.yhxt.com.cn/flow-center/front/index.html#/task/339",
);
assert.equal(out, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=107&mode=user");
assert.equal(out.split("#").length - 1, 1);
});
test("参数原样保留:instanceId 与 mode 不因去 hash 而丢失", () => {
const out = toAppHashUrl("#/diagram?instanceId=107&mode=dept", "https://h/flow-center/front/index.html#/task/339");
assert.match(out, /instanceId=107/);
assert.match(out, /mode=dept/);
});
test("无 hash 的入口地址:直接接上目标 hash", () => {
assert.equal(
toAppHashUrl("#/diagram?instanceId=1", "https://h/flow-center/front/index.html"),
"https://h/flow-center/front/index.html#/diagram?instanceId=1",
);
});
test("真实 query 保留在 hash 之前(部署带 ?v= 缓存位时不被吞)", () => {
assert.equal(
toAppHashUrl("#/diagram?instanceId=1", "https://h/index.html?v=9#/task/339"),
"https://h/index.html?v=9#/diagram?instanceId=1",
);
});
test("不带前导 # 的 resolve 结果同样归一,且不出现双 hash", () => {
const out = toAppHashUrl("/diagram?instanceId=1", "https://h/index.html#/task/339");
assert.equal(out, "https://h/index.html#/diagram?instanceId=1");
});
test("查看大图:不再用 origin+BASE_URL 拼出 host 尾点地址", () => {
// 旧写法 origin + "./" + "#/diagram?…" 会得到 https://base.yhxt.com.cn./#/diagram?…
const legacy = `${"https://base.yhxt.com.cn"}${"./"}#/diagram?instanceId=1`;
assert.match(legacy, /com\.cn\.\//);
const fixed = toAppHashUrl("#/diagram?instanceId=1", "https://base.yhxt.com.cn/flow-center/front/index.html#/admin/instances");
assert.ok(!/\.cn\.\//.test(fixed), fixed);
assert.equal(fixed, "https://base.yhxt.com.cn/flow-center/front/index.html#/diagram?instanceId=1");
});
+162
View File
@@ -0,0 +1,162 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveNodePreview } from "../src/views/flow/admin/def-editor/assignPreview";
import type { PreviewApi, PreviewDept, PreviewNodeConfig } from "../src/views/flow/admin/def-editor/assignPreview";
/**
* 编辑器指派预览解析单测:断言的是「用什么接口、给什么结论、失败怎么讲」。
* 数据用桩,不代表真实科室数据与线上服务已验证。
*/
const depts: PreviewDept[] = [
{ deptKey: "41", deptName: "校长室", count: 2 },
{ deptKey: "42", deptName: "空科室", count: 0 },
];
function cfg(over: Partial<PreviewNodeConfig> = {}): PreviewNodeConfig {
return {
uid: "u3",
name: "科室审核",
type: "DEPT_KEY",
deptKey: "41",
userIds: [],
countersign: "ANY",
assignPolicy: null,
...over,
};
}
function stubApi(over: Partial<PreviewApi> = {}): PreviewApi & { calls: string[] } {
const calls: string[] = [];
return {
calls,
deptMembers: async (deptId) => {
calls.push(`deptMembers:${deptId}`);
if (deptId === "41") {
return [
{ userId: 101, userName: "陈星", status: 0 },
{ userId: 102, userName: "刘文娟", status: 0 },
{ userId: 103, userName: "测试教师10", status: 1 },
];
}
return [];
},
lookupUser: async (userId) => {
calls.push(`lookupUser:${userId}`);
if (userId === 101) return { id: 101, name: "陈星", status: 1 };
if (userId === 999) return null;
return { id: userId, name: `用户${userId}`, status: 0 };
},
applyPreview: async (flowGuid) => {
calls.push(`applyPreview:${flowGuid}`);
return { flowGuid, defVersion: 3, nodes: [] };
},
...over,
};
}
test("科室映射节点:读既有科室成员接口,启用成员进候选、停用成员标注不计入", async () => {
const api = stubApi();
const out = await resolveNodePreview(cfg(), depts, api);
assert.equal(out.ok, true);
assert.ok(out.headline.includes("校长室"));
assert.ok(out.headline.includes("2 名启用办理人"));
assert.deepEqual(api.calls.filter((c) => c.startsWith("deptMembers")), ["deptMembers:41"]);
assert.equal(out.rows.length, 3);
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "刘文娟", "测试教师10"]);
assert.equal(out.rows[2].warn, "该成员已停用,不计入候选");
assert.ok(out.notes.some((n) => n.includes("预览只读")));
});
test("未选科室=明确的不可预览原因,而不是空列表里什么都不说", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "" }), depts, stubApi());
assert.equal(out.ok, false);
assert.equal(out.blocked, "尚未选择科室映射,请先在「科室映射」里选一个科室。");
});
test("科室已被删除:点名到映射键并说明发布会被拦截", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "99" }), depts, stubApi());
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("「99」"));
assert.ok(out.blocked?.includes("发布会被拦截"));
});
test("科室 0 名启用成员:给出补人路径,不误报成功", async () => {
const out = await resolveNodePreview(cfg({ deptKey: "42" }), depts, stubApi());
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("0 名启用办理人"));
assert.ok(out.blocked?.includes("办理人映射"));
});
test("指定用户节点:逐个走 UserBase 检索,禁用账号标注服务端会拒绝", async () => {
const api = stubApi();
const out = await resolveNodePreview(
cfg({ type: "USERS", deptKey: "", userIds: [101, 102, 999] }),
depts,
api,
);
assert.equal(out.ok, true);
assert.deepEqual(out.rows.map((r) => r.userName), ["陈星", "用户102", "#999"]);
assert.ok(out.rows[1].warn?.includes("非启用"));
assert.ok(out.rows[2].warn?.includes("UserBase 查不到"));
});
test("指定用户检索失败(服务不可达):原因可读,堆栈类原文不外泄", async () => {
const api = stubApi({
lookupUser: async () => {
throw new Error("java.lang.RuntimeException: connect timed out at com.yunhai.UserClient");
},
});
const out = await resolveNodePreview(cfg({ type: "USERS", deptKey: "", userIds: [101] }), depts, api);
assert.equal(out.ok, false);
assert.ok(out.blocked?.includes("发布会被拦截"));
assert.ok(!out.rows[0].warn?.includes("java."));
assert.ok(out.rows[0].warn?.includes("服务未能处理本次请求"));
});
test("新建未保存线:已发布版本对账说明「尚未保存」,不假装有一版可比", async () => {
const out = await resolveNodePreview(cfg(), depts, stubApi(), "");
assert.equal(out.published?.state, "unavailable");
assert.ok(out.published?.text.includes("尚未保存"));
});
test("草稿节点不在已发布预览中:区分「未开启指派」与「尚未发布」", async () => {
const out = await resolveNodePreview(cfg(), depts, stubApi(), "g1");
assert.equal(out.published?.state, "missing");
assert.ok(out.published?.text.includes("v3"));
assert.ok(out.published?.text.includes("未开启发起时指派"));
});
test("已发布版本有本节点:候选人按服务端解析结果展示(含提单人剔除后的集)", async () => {
const api = stubApi({
applyPreview: async () => ({
defVersion: 4,
nodes: [{ nodeId: "u3", nodeName: "科室审核", candidates: [{ userId: 102, userName: "刘文娟" }] }],
}),
});
const out = await resolveNodePreview(cfg(), depts, api, "g1");
assert.equal(out.published?.state, "ok");
assert.equal(out.published?.defVersion, 4);
assert.deepEqual(out.published?.rows.map((r) => r.userName), ["刘文娟"]);
});
test("已发布预览被服务端拒绝(如流程线已停发):把拒绝原因原样讲清,不静默", async () => {
const api = stubApi({
applyPreview: async () => {
throw new Error("流程线已停发,不可发起新办件");
},
});
const out = await resolveNodePreview(cfg(), depts, api, "g1");
assert.equal(out.published?.state, "unavailable");
assert.ok(out.published?.text.includes("流程线已停发,不可发起新办件"));
});
test("会签/发起时指派口径进说明文案(分层后信息不丢)", async () => {
const out = await resolveNodePreview(
cfg({ countersign: "ALL", assignPolicy: { mode: "SINGLE", required: true } }),
depts,
stubApi(),
);
assert.ok(out.notes.some((n) => n.includes("会签(全部同意才通过)")));
assert.ok(out.notes.some((n) => n.includes("单选 1 人") && n.includes("必选")));
});
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus from "view-ui-plus";
import { nextTick, reactive } from "vue";
const { flowApiMock } = vi.hoisted(() => ({
flowApiMock: {
adminTeacherTree: vi.fn(),
deptMembers: vi.fn(),
mapLookupUser: vi.fn(),
applyPreview: vi.fn(),
},
}));
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
import ApprovalDrawer from "@/views/flow/admin/def-editor/components/ApprovalDrawer.vue";
import AssigneePreviewModal from "@/views/flow/admin/def-editor/components/AssigneePreviewModal.vue";
import { EDITOR_CTX, createApproval, type ApprovalNode, type EditorItem } from "@/views/flow/admin/def-editor/model";
/**
* 审批节点抽屉的组件测试:验收 P2-1 的两条硬指标——
* ① 面板里有可发现的「预览候选人」入口,点开是真接口解析结果;
* ② 核心/高级分层只挪展示,不改字段与数据语义。
* 注:iView Drawer/Modal 内容 teleport 到 body,一律按真实 DOM 断言。
*/
enableAutoUnmount(afterEach);
function ok<T>(value: T) {
return { send: async () => value };
}
function makeNode(over: Partial<ApprovalNode> = {}): ApprovalNode {
return { ...createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" }), ...over } as ApprovalNode;
}
function baseCtx(over: Record<string, unknown> = {}) {
const model = reactive({
code: "c",
name: "n",
list: [] as EditorItem[],
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
});
return {
model,
depts: [
{ deptKey: "41", deptName: "校长室", count: 2 },
{ deptKey: "42", deptName: "空科室", count: 0 },
],
userNames: { 101: "陈星", 102: "刘文娟" },
routers: [],
availableVars: [],
issueOf: () => [],
openNode: () => undefined,
removeNode: () => undefined,
insertItem: () => undefined,
addBranch: () => undefined,
removeBranch: () => undefined,
exitOutlets: () => undefined,
flowGuid: "g-line-1",
...over,
};
}
function mountDrawer(node: ApprovalNode, ctxOver: Record<string, unknown> = {}): VueWrapper {
return mount(ApprovalDrawer, {
props: { modelValue: true, node },
global: {
plugins: [ViewUIPlus],
provide: { [EDITOR_CTX as unknown as symbol]: baseCtx(ctxOver) },
},
attachTo: document.body,
});
}
const bodyText = () => document.body.textContent || "";
const q = (sel: string) => document.querySelector(sel) as HTMLElement | null;
const hiddenByStyle = (el: HTMLElement | null) => (el?.getAttribute("style") || "").includes("display: none");
async function clickEl(el: HTMLElement | null) {
expect(el, "目标元素不存在").toBeTruthy();
el!.click();
await nextTick();
await flushPromises();
}
function buttonNamed(text: string) {
return (Array.from(document.querySelectorAll("button")) as HTMLButtonElement[])
.find((b) => (b.textContent || "").includes(text)) || null;
}
beforeEach(() => {
document.body.innerHTML = "";
flowApiMock.adminTeacherTree.mockImplementation(() => ok([]));
flowApiMock.deptMembers.mockImplementation((deptId: string) =>
ok(deptId === "41"
? [
{ userId: 101, userName: "陈星", status: 0 },
{ userId: 102, userName: "刘文娟", status: 0 },
{ userId: 103, userName: "测试教师10", status: 1 },
]
: []),
);
flowApiMock.mapLookupUser.mockImplementation((id: number) => ok({ id, name: `用户${id}`, status: 1 }));
flowApiMock.applyPreview.mockImplementation(() => ok({ defVersion: 3, nodes: [] }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe("审批节点抽屉 · 核心/高级分层", () => {
it("核心区直接可见:节点名称、审批人、预览入口;高级项默认收起", async () => {
mountDrawer(makeNode());
await flushPromises();
const text = bodyText();
expect(text).toContain("节点名称");
expect(text).toContain("审批人类型");
expect(text).toContain("科室映射");
expect(text).toContain("预览候选人");
expect(text).toContain("高级设置");
// 收起只是隐藏,不删除能力
expect(hiddenByStyle(q(".adv-body"))).toBe(true);
});
it("高级能力一个都没少:展开后会签/发起时指派/时限/多出口都在原位置", async () => {
mountDrawer(makeNode());
await flushPromises();
await clickEl(q(".adv-head"));
const adv = q(".adv-body");
expect(hiddenByStyle(adv)).toBe(false);
const text = adv?.textContent || "";
expect(text).toContain("多人审批方式");
expect(text).toContain("发起时指派");
expect(text).toContain("办理时限(小时)");
expect(text).toContain("出口路由");
});
it("已配置高级项的节点:打开抽屉自动展开,收起态也标出配了哪几项", async () => {
mountDrawer(makeNode({ countersign: "ALL", deadlineHours: 24 }));
await flushPromises();
expect(hiddenByStyle(q(".adv-body"))).toBe(false);
await clickEl(q(".adv-head"));
expect(q(".adv-badge")?.textContent).toContain("会签");
expect(q(".adv-badge")?.textContent).toContain("办理时限");
});
it("分层不改数据:折叠/展开来回,节点字段值不变", async () => {
const node = makeNode({ countersign: "ALL", deadlineHours: 8, outlets: ["同意", "退回"] });
const snapshot = JSON.stringify(node);
mountDrawer(node);
await flushPromises();
await clickEl(q(".adv-head"));
await clickEl(q(".adv-head"));
expect(JSON.stringify(node)).toEqual(snapshot);
});
});
describe("审批节点抽屉 · 预览候选人入口", () => {
async function openPreview() {
await clickEl(buttonNamed("预览候选人"));
}
it("点入口即按当前节点配置走真实接口(科室成员 + 发起预览),不是静态示例", async () => {
mountDrawer(makeNode());
await flushPromises();
await openPreview();
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("41");
expect(flowApiMock.applyPreview).toHaveBeenCalledWith("g-line-1");
const text = bodyText();
expect(text).toContain("校长室");
expect(text).toContain("陈星");
expect(text).toContain("刘文娟");
expect(text).toContain("该成员已停用,不计入候选");
expect(text).toContain("预览只读");
});
it("预览读的是抽屉里的未保存改动(换科室后立刻按新科室解析)", async () => {
mountDrawer(makeNode({ assignee: { type: "DEPT_KEY", deptKey: "42" } }));
await flushPromises();
await openPreview();
expect(flowApiMock.deptMembers).toHaveBeenCalledWith("42");
const text = bodyText();
expect(text).toContain("0 名启用办理人");
expect(text).toContain("发布会被拦截");
});
it("指定用户节点按 UserBase 检索解析,非启用账号如实标注", async () => {
flowApiMock.mapLookupUser.mockImplementation((id: number) =>
ok(id === 101 ? { id: 101, name: "陈星", status: 1 } : { id, name: `用户${id}`, status: 0 }),
);
mountDrawer(makeNode({ assignee: { type: "USERS", userIds: [101, 102] } }));
await flushPromises();
await openPreview();
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(101);
expect(flowApiMock.mapLookupUser).toHaveBeenCalledWith(102);
const text = bodyText();
expect(text).toContain("陈星");
expect(text).toContain("非启用");
});
it("流程线尚未保存时说明没有可比对的已发布版本,不假装有一版", async () => {
mountDrawer(makeNode(), { flowGuid: "" });
await flushPromises();
await openPreview();
expect(flowApiMock.applyPreview).not.toHaveBeenCalled();
expect(bodyText()).toContain("尚未保存");
});
it("预览只读:打开再关闭不写回节点、不产生任何接口写入", async () => {
const node = makeNode();
const before = JSON.stringify(node);
const wrapper = mountDrawer(node);
await flushPromises();
await openPreview();
await clickEl(buttonNamed("关闭"));
expect(JSON.stringify(node)).toEqual(before);
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
// 预览的数据源只有读接口:科室成员 / 用户检索 / 发起预览
expect(flowApiMock.deptMembers).toHaveBeenCalled();
expect(flowApiMock.applyPreview).toHaveBeenCalled();
});
it("预览弹窗组件确实挂在抽屉内(入口不是死链)", async () => {
const wrapper = mountDrawer(makeNode());
await flushPromises();
expect(wrapper.findComponent(AssigneePreviewModal).exists()).toBe(true);
});
});
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus from "view-ui-plus";
import { nextTick } from "vue";
import FlowAssignEmbed from "@/components/FlowAssignEmbed.vue";
/**
* FL 侧选人 iframe 宿主的协议测试(自动化测试宿主,不是产品入口)。
* 覆盖:地址构造(单 hash)、四道校验、监听器生命周期、取消保留旧选择、确认后 B 替换 A。
*/
enableAutoUnmount(afterEach);
const PROTO = "flow";
const VERSION = 1;
const APP_ORIGIN = window.location.origin;
function baseData(over: Record<string, unknown> = {}) {
return {
proto: PROTO,
v: VERSION,
type: "assign-confirmed",
contextId: "",
flowGuid: "g-embed",
defVersion: 3,
assigns: [{ nodeId: "u2", userIds: [101] }],
...over,
};
}
/** view-ui-plus Modal 默认把内容 teleport 到 body,组件子树里查不到——按真实 DOM 取 */
function frameEl(): HTMLIFrameElement | null {
return document.querySelector("iframe") as HTMLIFrameElement | null;
}
/** 父页收到消息的唯一入口:直接派发 MessageEvent,绕开真实 iframe 加载 */
function post(wrapper: VueWrapper, data: unknown, opts: { origin?: string; source?: unknown } = {}) {
const frame = frameEl() as HTMLIFrameElement;
const ev = new MessageEvent("message", {
origin: opts.origin ?? APP_ORIGIN,
source: (opts.source ?? frame.contentWindow) as Window | null,
data,
});
window.dispatchEvent(ev);
return nextTick();
}
function mountHost(props: Record<string, unknown> = {}) {
return mount(FlowAssignEmbed, {
props: { flowGuid: "g-embed", ...props },
global: { plugins: [ViewUIPlus] },
attachTo: document.body,
});
}
/** 开框后 jsdom 仍不会真正加载子文档:给 iframe 挂一个显式窗口桩,父侧按它核 event.source */
async function openHost(wrapper: VueWrapper) {
(wrapper.vm as any).open();
await nextTick();
await flushPromises();
const frame = frameEl() as HTMLIFrameElement;
Object.defineProperty(frame, "contentWindow", { value: { __frame: true }, configurable: true });
}
beforeEach(() => {
document.body.innerHTML = "";
});
describe("FlowAssignEmbed 地址与会话", () => {
it("open 生成单 hash 的 iframe 地址,带 flowGuid 与本次 contextId", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const src = (frameEl() as HTMLIFrameElement).getAttribute("src") || "";
expect(src.split("#").length - 1).toBe(1);
expect(src).toContain(`${APP_ORIGIN}/#/assign-preview?`);
expect(src).toContain("flowGuid=g-embed");
expect(src).toContain("contextId=");
expect((wrapper.vm as any).contextId).toBeTruthy();
});
it("缺少 flowGuid 时不开框、不监听", async () => {
const addSpy = vi.spyOn(window, "addEventListener");
const wrapper = mountHost({ flowGuid: "" });
(wrapper.vm as any).open();
await nextTick();
expect(frameEl()).toBeNull();
expect(addSpy.mock.calls.filter((c) => c[0] === "message").length).toBe(0);
addSpy.mockRestore();
});
});
describe("FlowAssignEmbed 消息校验", () => {
it("协议相符的确认消息:回传最终选择与版本锚", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
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");
});
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");
expect(wrapper.emitted("confirmed")).toBeUndefined();
expect(wrapper.emitted("cancelled")).toBeUndefined();
});
it("确认消息缺 assigns 时形状校验拦下(不落成空选择)", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, assigns: undefined }));
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
});
describe("FlowAssignEmbed 生命周期与选择语义", () => {
it("取消:只通知宿主,不产生确认,旧选择不被覆盖", async () => {
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
await post(wrapper, baseData({ contextId: cid, type: "assign-cancelled" }));
expect(wrapper.emitted("cancelled")).toHaveLength(1);
expect(wrapper.emitted("confirmed")).toBeUndefined();
});
it("确认后同一会话的迟到消息不再触发(监听器已摘)", async () => {
const wrapper = mountHost();
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] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(1);
expect((events[0][0] as any).assigns[0].userIds).toEqual([101]);
});
it("宿主自己关框(未答复):按取消处理并摘监听", async () => {
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();
});
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] }] }));
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);
await post(wrapper, baseData({ contextId: cid2, assigns: [{ nodeId: "u2", userIds: [102, 103] }] }));
const events = wrapper.emitted("confirmed");
expect(events).toHaveLength(2);
expect((events[1][0] as any).assigns[0].userIds).toEqual([102, 103]);
});
it("卸载后监听器摘掉,后续消息不再回传", async () => {
const removeSpy = vi.spyOn(window, "removeEventListener");
const wrapper = mountHost();
await openHost(wrapper);
const cid = (wrapper.vm as any).contextId;
const frame = frameEl() as HTMLIFrameElement;
wrapper.unmount();
expect(removeSpy.mock.calls.filter((c) => c[0] === "message").length).toBeGreaterThan(0);
window.dispatchEvent(new MessageEvent("message", {
origin: APP_ORIGIN,
source: frame.contentWindow as Window,
data: baseData({ contextId: cid }),
}));
await flushPromises();
expect(wrapper.emitted("confirmed")).toBeUndefined();
removeSpy.mockRestore();
});
});
@@ -0,0 +1,326 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
import { mount, flushPromises, enableAutoUnmount, type VueWrapper } from "@vue/test-utils";
import ViewUIPlus, { Message } from "view-ui-plus";
import { nextTick } from "vue";
const { flowApiMock } = vi.hoisted(() => ({
flowApiMock: {
defList: vi.fn(),
defInflight: vi.fn(),
appList: vi.fn(),
defOfflineLine: vi.fn(),
defEnableLine: vi.fn(),
defPublish: vi.fn(),
defOffline: vi.fn(),
defEnable: vi.fn(),
defUpdateMeta: vi.fn(),
defVersions: vi.fn(),
},
}));
vi.mock("@/api/module/flow", () => ({ flowApi: flowApiMock }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/components/MyConfirm", () => ({ default: vi.fn() }));
vi.mock("@/views/flow/components/SourceDrawer.vue", () => ({ default: { template: "<div />" } }));
import MyConfirm from "@/components/MyConfirm";
import Defs from "@/views/flow/admin/Defs.vue";
/** 组件级测试:真实挂载 Defs.vue(view-ui-plus Table/操作列),只 mock HTTP 客户端、路由与确认框。 */
const confirmMock = MyConfirm as unknown as Mock;
enableAutoUnmount(afterEach);
function ok<T>(value: T) {
return { send: async () => value };
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function baseRows() {
return [
{
id: 1, flowGuid: "g-normal", name: "正常线", code: "c1", version: 1,
status: 1, lineOffline: 0, hasPublished: true, publishTime: "2026-10-20T10:00:00",
},
{
id: 2, flowGuid: "g-off", name: "停发线", code: "c2", version: 1,
status: 1, lineOffline: 1, hasPublished: true, publishTime: "2026-10-20T10:00:00",
},
{
id: 3, flowGuid: "", name: "历史脏行", code: "c3", version: 1,
status: 0, lineOffline: 0, hasPublished: false, publishTime: null,
},
{
id: 4, flowGuid: "g-version-off", name: "版本停用线", code: "c4", version: 2,
status: 2, lineOffline: 0, hasPublished: false, publishTime: "2026-10-19T10:00:00",
},
];
}
function offlineRows() {
return baseRows().map((r) => (r.flowGuid === "g-normal" ? { ...r, lineOffline: 1 } : r));
}
async function mountDefs() {
flowApiMock.defList.mockImplementation(() => ok(baseRows()));
flowApiMock.defInflight.mockImplementation(() => ok({ "g-normal": 2 }));
flowApiMock.appList.mockImplementation(() => ok([]));
const wrapper = mount(Defs, {
global: { plugins: [ViewUIPlus] },
attachTo: document.body,
});
await flushPromises();
return wrapper;
}
function rowsNamed(wrapper: VueWrapper, name: string) {
return wrapper.findAll("tr.ivu-table-row").filter((r) => r.text().includes(name));
}
function lineLink(wrapper: VueWrapper, name: string, label: string) {
for (const row of rowsNamed(wrapper, name)) {
const link = row.findAll(".operating-link").find((l) => l.text() === label);
if (link) return link;
}
return undefined;
}
function lastConfirmOptions(): any {
expect(confirmMock).toHaveBeenCalled();
return confirmMock.mock.calls[confirmMock.mock.calls.length - 1][0];
}
let successSpy: Mock;
let warningSpy: Mock;
let errorSpy: Mock;
let infoSpy: Mock;
beforeEach(() => {
vi.clearAllMocks();
successSpy = vi.spyOn(Message, "success").mockImplementation((() => {}) as any);
warningSpy = vi.spyOn(Message, "warning").mockImplementation((() => {}) as any);
errorSpy = vi.spyOn(Message, "error").mockImplementation((() => {}) as any);
infoSpy = vi.spyOn(Message, "info").mockImplementation((() => {}) as any);
});
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("Defs 流程线状态展示(分组视图)", () => {
it("线状态与版本状态独立展示,入口随线状态切换", async () => {
const wrapper = await mountDefs();
expect(wrapper.text()).toContain("线状态");
const normal = rowsNamed(wrapper, "正常线")[0]?.text() || "";
expect(normal).toContain("正常");
expect(normal).toContain("已发布");
expect(normal).toContain("停发流程线");
expect(normal).not.toContain("恢复流程线");
const offline = rowsNamed(wrapper, "停发线")[0]?.text() || "";
expect(offline).toContain("已停发");
expect(offline).toContain("已发布");
expect(offline).toContain("恢复流程线");
expect(offline).not.toContain("停发流程线");
// 版本已停用但线正常:线状态不得被版本 status 带偏
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
expect(versionOff).toContain("正常");
expect(versionOff).toContain("停用");
expect(versionOff).toContain("停发流程线");
});
it("空 flowGuid 历史行显示 —,不出现任何线级操作入口", async () => {
const wrapper = await mountDefs();
const rows = rowsNamed(wrapper, "历史脏行");
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
expect(row.text()).toContain("—");
expect(row.text()).not.toContain("停发流程线");
expect(row.text()).not.toContain("恢复流程线");
}
expect(lineLink(wrapper, "历史脏行", "停发流程线")).toBeUndefined();
expect(lineLink(wrapper, "历史脏行", "恢复流程线")).toBeUndefined();
});
});
describe("Defs 流程线状态展示(平铺视图)", () => {
it("每个版本行展示共享线状态,线级入口与版本级停用并存", async () => {
const wrapper = await mountDefs();
const radios = wrapper.findAll("input[type=radio]");
expect(radios.length).toBeGreaterThan(1);
await radios[1].setValue(true);
await flushPromises();
expect(wrapper.find(".fix").text()).toContain("平铺所有版本行");
expect(rowsNamed(wrapper, "正常线")[0]?.text()).toContain("正常");
expect(rowsNamed(wrapper, "停发线")[0]?.text()).toContain("已停发");
const versionOff = rowsNamed(wrapper, "版本停用线")[0]?.text() || "";
expect(versionOff).toContain("正常");
expect(versionOff).toContain("停用");
// 平铺视图长操作集进入「更多」,菜单内容仍由同一 lineActions 过滤逻辑产生
const menuItems = Array.from(document.querySelectorAll(".ivu-dropdown-item"))
.map((e) => e.textContent || "");
expect(menuItems).toContain("停发流程线");
expect(menuItems).toContain("恢复流程线");
expect(menuItems).toContain("停用");
});
});
describe("Defs 流程线停发/恢复交互", () => {
it("停发:确认文案含在途数,请求期间显示处理中并禁用入口,成功后刷新列表与在途", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ok(offlineRows()));
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
const opts = lastConfirmOptions();
expect(opts.title).toBe("停发流程线");
expect(opts.msg).toContain("正常线");
expect(opts.info).toContain("在途办件不受影响");
expect(opts.info).toContain("当前在途 2 件");
const pending = opts.onOk();
await nextTick();
await flushPromises();
expect(wrapper.text()).toContain("处理中…");
expect(lineLink(wrapper, "正常线", "停发流程线")!.classes()).toContain("disabled");
expect(flowApiMock.defOfflineLine).toHaveBeenCalledWith("g-normal", { silentBizToast: true });
gate.resolve(2);
await pending;
await flushPromises();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(2 个版本"));
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
expect(flowApiMock.defInflight).toHaveBeenCalledTimes(2);
expect(wrapper.text()).toContain("已停发");
expect(lineLink(wrapper, "正常线", "恢复流程线")).toBeTruthy();
expect(wrapper.text()).not.toContain("处理中…");
});
it("在途期间重复确认/重复点击不会产生第二次请求", async () => {
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defOfflineLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
const opts = lastConfirmOptions();
const first = opts.onOk();
await nextTick();
// 入口已置灰(点击回调不再触发);再次确认也走 busy 守卫
const disabledLink = lineLink(wrapper, "正常线", "停发流程线")!;
expect(disabledLink.classes()).toContain("disabled");
await disabledLink.trigger("click");
expect(confirmMock).toHaveBeenCalledTimes(1);
await opts.onOk();
expect(flowApiMock.defOfflineLine).toHaveBeenCalledTimes(1);
expect(infoSpy).toHaveBeenCalledWith("该流程线操作正在处理中,请稍候");
gate.resolve(1);
await first;
await flushPromises();
});
it("恢复:已停发线只调 enableLine,成功后刷新并切换回停发入口", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ok(baseRows()));
const wrapper = await mountDefs();
const gate = deferred<number>();
flowApiMock.defEnableLine.mockReturnValue({ send: () => gate.promise });
await lineLink(wrapper, "停发线", "恢复流程线")!.trigger("click");
const opts = lastConfirmOptions();
expect(opts.title).toBe("恢复流程线");
expect(opts.info).toContain("仅恢复流程线可用性");
const pending = opts.onOk();
await nextTick();
expect(wrapper.text()).toContain("处理中…");
expect(lineLink(wrapper, "停发线", "恢复流程线")!.classes()).toContain("disabled");
gate.resolve(1);
await pending;
await flushPromises();
expect(flowApiMock.defEnableLine).toHaveBeenCalledWith("g-off", { silentBizToast: true });
expect(flowApiMock.defOfflineLine).not.toHaveBeenCalled();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已恢复(1 个版本"));
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
});
it("接口失败/无权限/网络异常:明确报错,不刷新,不误报成功", async () => {
const cases = [
{ error: "无流程管理权限", expected: /无流程管理权限/ },
{ error: "网络错误", expected: /网络异常/ },
];
for (const c of cases) {
vi.clearAllMocks();
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue({
send: () => Promise.reject(new Error(c.error)),
});
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching(c.expected));
expect(successSpy).not.toHaveBeenCalled();
expect(flowApiMock.defList).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toContain("正常");
wrapper.unmount();
}
});
it("接口返回 0 行:提示状态未更新并要求刷新确认", async () => {
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue(ok(0));
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(warningSpy).toHaveBeenCalledWith("未更新任何版本,流程线状态可能已变化,请刷新后确认");
expect(successSpy).not.toHaveBeenCalled();
expect(flowApiMock.defList).toHaveBeenCalledTimes(2);
});
it("操作成功但列表刷新失败:保留成功结果并明确提示刷新失败", async () => {
flowApiMock.defList
.mockImplementationOnce(() => ok(baseRows()))
.mockImplementationOnce(() => ({ send: async () => { throw new Error("网络异常"); } }));
const wrapper = await mountDefs();
flowApiMock.defOfflineLine.mockReturnValue(ok(1));
await lineLink(wrapper, "正常线", "停发流程线")!.trigger("click");
await lastConfirmOptions().onOk();
await flushPromises();
expect(successSpy).toHaveBeenCalledWith(expect.stringContaining("流程线已停发(1 个版本"));
expect(warningSpy).toHaveBeenCalledWith("操作已提交,但列表刷新失败,请手动刷新后确认最新状态");
expect(errorSpy).not.toHaveBeenCalled();
});
});
+31
View File
@@ -0,0 +1,31 @@
// jsdom 缺少浏览器布局/观察器能力:只补组件运行所需的最小桩,不替业务逻辑兜底。
if (!("ResizeObserver" in globalThis)) {
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = ResizeObserverStub;
}
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
})) as any;
}
if (!navigator.clipboard) {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async () => undefined },
});
}
+145
View File
@@ -0,0 +1,145 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { toContentJson, fromContentJson } from "../src/views/flow/admin/def-editor/convert";
import { createApproval, createCondition, createNotify, createParallel } from "../src/views/flow/admin/def-editor/model";
import type { EditorItem, EditorModel } from "../src/views/flow/admin/def-editor/model";
/**
* 流程定义序列化回归闸门(任务书 §6.8「检查流程定义序列化」)。
* 本轮只动编辑器面板的**展示分层**,这里钉住:结构语义不因这些改动漂移。
* 对比口径按语义集合比(节点字段集 + 边三元组集),不比数组顺序——空泳道会产生纯顺序差。
*/
type Json = ReturnType<typeof toContentJson>;
function model(items: EditorItem[]): EditorModel {
return {
code: "rt-demo",
name: "往返回归",
list: items,
settings: { rejectPolicy: "END_REJECTED", urge: { enabled: true, intervalHours: 24, maxTimes: 3 } },
};
}
const clone = (o: unknown) => JSON.parse(JSON.stringify(o));
function fullItems(): EditorItem[] {
const approval = createApproval("科室审核", { type: "DEPT_KEY", deptKey: "41" });
approval.countersign = "ALL";
approval.deadlineHours = 8;
approval.assignPolicy = { mode: "SINGLE", required: true };
const outletNode = createApproval("多出口", { type: "USERS", userIds: [101, 102] });
outletNode.outlets = ["同意", "退回", "加签"];
outletNode.branches = [[createApproval("分支一")], [], [createNotify("分支三抄送")]];
const cond = createCondition();
cond.judgeMode = "EXPRESSION";
cond.outlets = ["大额", "小额"];
cond.conditions = ["amount > 1000", ""];
cond.branches = [[createApproval("大额复核")], [createApproval("小额直过")]];
const par = createParallel();
par.branches = [[createNotify("并行抄送A")], [createApproval("并行复核B", { type: "DEPT_KEY", deptKey: "42" })]];
return [approval, cond, par, outletNode];
}
/** 已知缺陷用:非首位并行容器重解析后 split/join 会换成新 id(见最后一条用例) */
function parallelDerivedIds(json: Json): string[] {
return json.nodes.filter((n) => n.type === "PARALLEL_SPLIT" || n.type === "PARALLEL_JOIN").map((n) => n.id);
}
function roundTrip(times: number, json: Json): Json {
let cur = json;
for (let i = 0; i < times; i++) {
cur = toContentJson({ ...model([]), ...fromContentJson(clone(cur)) } as EditorModel);
}
return cur;
}
test("结构往返两轮:每个节点的配置语义逐项一致(并行派生 id 另用例专门记录)", () => {
const first = toContentJson(model(fullItems()));
const second = roundTrip(1, first);
const third = roundTrip(2, first);
const byId = (json: Json) => {
const m = new Map<string, string>();
json.nodes.forEach((n) => m.set(n.id, JSON.stringify(n)));
return m;
};
const a = byId(first);
const b = byId(second);
const c = byId(third);
// 配置类节点(审批/抄送/条件)的 id 与内容必须逐字不变
["u1", "u2", "u3", "u4", "u5c", "u8", "u9", "start", "end"].forEach((id) => {
assert.equal(b.get(id), a.get(id), `节点 ${id} 内容在第二轮发生变化`);
assert.equal(c.get(id), a.get(id), `节点 ${id} 内容在第三轮发生变化`);
});
assert.deepEqual(second.settings, first.settings);
assert.deepEqual(third.settings, first.settings);
// 并行分支内的两名办理人同样不变
const branchIds = ["u13", "u14"];
branchIds.forEach((id) => assert.equal(b.get(id), a.get(id), `并行分支节点 ${id} 变化`));
// 出口边(含条件表达式)在两轮都存在
const edgeSet = (json: Json) => new Set(json.edges.map((e) => `${e.from}->${e.to}${e.name ? `(${e.name})` : ""}${e.condition ? `[${e.condition}]` : ""}`));
const ea = edgeSet(first);
const eb = edgeSet(second);
["u2->u3(同意)", "u2->u4(加签)", "u2->end(退回)", "u5c->u8(大额)[amount > 1000]", "u5c->u9(小额)"].forEach((e) => {
assert.ok(ea.has(e), `第一轮缺少边 ${e}`);
assert.ok(eb.has(e), `第二轮缺少边 ${e}`);
});
});
test("关键语义字段全部活过往返,不被展示层分层影响", () => {
const first = toContentJson(model(fullItems()));
const parsed = fromContentJson(clone(first));
const second = toContentJson({ ...model([]), ...parsed } as EditorModel);
// 会签 ALL 是后端缺省值,生成 JSON 时按设计省略(model.ts 注释);口径以解析回编辑态为准
const approvalItem = parsed.list.find((it) => it.kind === "approval" && it.assignee?.type === "DEPT_KEY");
assert.ok(approvalItem && approvalItem.kind === "approval");
assert.equal(approvalItem.countersign, "ALL", "会签语义在解析端丢失");
assert.equal(approvalItem.deadlineHours, 8, "办理时限丢失");
assert.deepEqual(approvalItem.assignPolicy, { mode: "SINGLE", required: true }, "发起时指派策略丢失");
assert.ok(second.nodes.some((n) => n.type === "NOTIFY" && n.receivers?.type === "USERS"), "抄送对象丢失");
assert.ok(second.nodes.some((n) => n.judgeMode === "EXPRESSION"), "条件判断方式丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_SPLIT"), "并行拆分丢失");
assert.ok(second.nodes.some((n) => n.type === "PARALLEL_JOIN" && n.waitFor === "ALL"), "并行汇聚语义丢失");
assert.ok(second.nodes.some((n) => n.id === "start") && second.nodes.some((n) => n.id === "end"), "端点丢失");
assert.ok(second.edges.filter((e) => e.name).length >= 3, "多出口/条件分支的出口名丢失");
});
test("设置与端点名不被丢:rejectPolicy/urge/start-end name 原样回写", () => {
const m = model([createApproval("单节点")]);
m.startName = "提交申请";
m.endName = "办结";
const json = toContentJson(m);
assert.equal(json.settings.rejectPolicy, "END_REJECTED");
assert.equal(json.settings.urge.intervalHours, 24);
assert.equal((json.nodes.find((n) => n.type === "START") || {}).name, "提交申请");
assert.equal((json.nodes.find((n) => n.type === "END") || {}).name, "办结");
});
test("已知缺陷(本轮不修,修它等于改序列化契约):并行容器重解析后 split/join 换成新 id", () => {
const first = toContentJson(model([createApproval("a"), createParallel()]));
const before = parallelDerivedIds(first);
const rt = roundTrip(1, first);
const after = parallelDerivedIds(rt);
assert.equal(after.length, before.length, "并行拆/合节点数量发生变化");
assert.notDeepEqual(after, before, "并行 id 改名行为已变化——若已被修好,请把本用例改成断言 id 稳定并同步终审");
// 改名只涉及 id 字面,汇聚语义不变
const join = rt.nodes.find((n) => n.type === "PARALLEL_JOIN");
assert.equal(rt.nodes.filter((n) => n.type === "PARALLEL_SPLIT").length, 1);
assert.equal(join?.waitFor, "ALL", "并行汇聚 waitFor 语义丢失");
// 拆分/汇聚与分支节点的连接关系仍然闭合(每个分支节点都连到某条 split/join)
const anchors = new Set(after);
const linked = rt.edges.filter((e) => anchors.has(e.from) || anchors.has(e.to));
assert.ok(linked.length >= 4, `并行连线丢失,只剩 ${linked.length} 条`);
});
+55
View File
@@ -0,0 +1,55 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { describeFlowError, flowFailTip, FLOW_GENERIC_FALLBACK, FLOW_NETWORK_FALLBACK } from "../src/utils/flowError";
/** 审批失败文案单测:口径=具体业务话原样透出,通用/异常形态换兜底话术且不外泄内部信息 */
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("成功"));
});
test("契约内的具体业务 msg 原样透出,不改写不误导", () => {
assert.equal(describeFlowError("流程线不存在:8d07f049"), "流程线不存在:8d07f049");
assert.equal(describeFlowError("该任务已被他人办理"), "该任务已被他人办理");
assert.equal(
describeFlowError("该待办缺少流程推进记录,需管理员回填后办理"),
"该待办缺少流程推进记录,需管理员回填后办理",
);
});
test("异常/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);
assert.equal(out, FLOW_GENERIC_FALLBACK, raw);
assert.ok(!/java\.|Exception|SQL|Bearer|<html>/i.test(out));
}
});
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);
});
test("Error 对象可直接传入(取 message)", () => {
assert.equal(describeFlowError(new Error("流程线不存在:g1")), "流程线不存在:g1");
assert.equal(describeFlowError(new Error("内部服务错误")), FLOW_GENERIC_FALLBACK);
});
test("页面内联提示带动作名,明确「未生效」而不是成功", () => {
const tip = flowFailTip("同意", new Error("内部服务错误"));
assert.ok(tip.startsWith("同意未完成:"));
assert.ok(tip.includes(FLOW_GENERIC_FALLBACK));
assert.ok(!tip.includes("已同意"));
});
+157
View File
@@ -0,0 +1,157 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { lineActionErrorDetail, runLineAction } from "../src/utils/lineOffline";
/** 线级启停纯逻辑单测:不发真实请求,边界=请求/刷新编排,不代表后端或真实 UI 已验证 */
test("停发成功:按 flowGuid 调一次接口,按返回行数刷新", async () => {
const calls: string[] = [];
let reloads = 0;
const out = await runLineAction({
flowGuid: " g1 ",
lineOffline: 0,
action: "offline",
request: async (guid) => { calls.push(guid); return 3; },
reload: async () => { reloads++; },
});
assert.deepEqual(calls, ["g1"]);
assert.equal(out.kind, "updated");
assert.equal(out.affected, 3);
assert.equal(out.reloadFailed, false);
assert.equal(reloads, 1);
});
test("恢复成功:已停发线才发恢复请求", async () => {
let requested = "";
let reloads = 0;
const out = await runLineAction({
flowGuid: "g2",
lineOffline: 1,
action: "enable",
request: async (guid) => { requested = guid; return 2; },
reload: async () => { reloads++; },
});
assert.equal(requested, "g2");
assert.equal(out.kind, "updated");
assert.equal(out.affected, 2);
assert.equal(reloads, 1);
});
test("重复停发被前端状态守卫拦下:不发请求、不刷新", async () => {
let requests = 0;
let reloads = 0;
const out = await runLineAction({
flowGuid: "g3",
lineOffline: 1,
action: "offline",
request: async () => { requests++; return 1; },
reload: async () => { reloads++; },
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "already-offline");
assert.equal(requests, 0);
assert.equal(reloads, 0);
});
test("恢复一条未停发的线被拦下:不发请求、不刷新", async () => {
let requests = 0;
const out = await runLineAction({
flowGuid: "g4",
lineOffline: 0,
action: "enable",
request: async () => { requests++; return 1; },
reload: async () => {},
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "not-offline");
assert.equal(requests, 0);
});
test("空 flowGuid 不发请求(历史脏行防误操作整线)", async () => {
let requests = 0;
const out = await runLineAction({
flowGuid: "",
lineOffline: 0,
action: "offline",
request: async () => { requests++; return 1; },
reload: async () => {},
});
assert.equal(out.kind, "skipped");
assert.equal(out.reason, "no-guid");
assert.equal(requests, 0);
});
test("接口返回 0 行:报 empty 但仍刷新列表以同步真实状态", async () => {
let reloads = 0;
const out = await runLineAction({
flowGuid: "g5",
lineOffline: 0,
action: "offline",
request: async () => 0,
reload: async () => { reloads++; },
});
assert.equal(out.kind, "empty");
assert.equal(out.affected, 0);
assert.equal(reloads, 1);
});
test("接口失败向上抛出,且不触发刷新", async () => {
let reloads = 0;
await assert.rejects(() => runLineAction({
flowGuid: "g6",
lineOffline: 0,
action: "offline",
request: async () => { throw new Error("无流程管理权限"); },
reload: async () => { reloads++; },
}), /无流程管理权限/);
assert.equal(reloads, 0);
});
test("操作成功但刷新失败:保留成功结果并标记 reloadFailed", async () => {
const out = await runLineAction({
flowGuid: "g7",
lineOffline: 1,
action: "enable",
request: async () => 1,
reload: async () => { throw new Error("网络异常"); },
});
assert.equal(out.kind, "updated");
assert.equal(out.affected, 1);
assert.equal(out.reloadFailed, true);
});
test("错误文案:权限/网络异常与后端业务错误分开提示", () => {
assert.match(lineActionErrorDetail("网络错误"), /权限/);
assert.match(lineActionErrorDetail("Failed to fetch"), /网络异常/);
assert.match(lineActionErrorDetail(""), /网络异常/);
assert.equal(lineActionErrorDetail("流程线不存在:g1"), "流程线不存在:g1");
});
test("静态接线:Defs.vue 使用线级接口,不混入 listAll 或重复查询", () => {
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
assert.match(defs, /defOfflineLine/);
assert.match(defs, /defEnableLine/);
assert.match(defs, /lineOffline/);
assert.match(defs, /停发流程线/);
assert.match(defs, /恢复流程线/);
assert.match(defs, /versionActions/);
assert.doesNotMatch(defs, /listAll/);
});
test("静态接线:API 客户端使用既有 admin 端点与 flowGuid 请求体", () => {
const api = readFileSync(resolve(process.cwd(), "src/api/module/flow.ts"), "utf8");
assert.match(api, /`\$\{ROOT\}\/admin\/def\/offlineLine`, \{ flowGuid \}, config/);
assert.match(api, /`\$\{ROOT\}\/admin\/def\/enableLine`, \{ flowGuid \}, config/);
});
test("静态接线:线级启停失败只有一条错误面(调用方带 silentBizToast,全局 toast 认这个旗标)", () => {
const defs = readFileSync(resolve(process.cwd(), "src/views/flow/admin/Defs.vue"), "utf8");
const core = readFileSync(resolve(process.cwd(), "src/utils/alova/alova-core.ts"), "utf8");
assert.match(defs, /defOfflineLine\(guid, \{ silentBizToast: true \}\)/);
assert.match(defs, /defEnableLine\(guid, \{ silentBizToast: true \}\)/);
assert.match(core, /silentBizToast/);
assert.match(core, /if \(!isSilentBizToast\(method\)\) onBizError/);
assert.match(core, /if \(!isSilentBizToast\(method\)\) onHttpError/);
});
+13
View File
@@ -106,6 +106,19 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
target: "http://localhost:8085",
changeOrigin: true,
},
// 免签面(嵌入页数据 /open/flow/embed/data、示意图 /open/flow/diagram/img):
// 生产前后端同域(Caddy /flow-center 下同源相对路径即达),本地 dev 前后端分端口,
// 同理需代理——serviceHost 为空时浏览器请求落在 5173/8088 上无此路由
"/open": {
target: "http://localhost:8085",
changeOrigin: true,
},
// YschTheme 主题通道 dev 代理(A 类接入配套):dev 无 __APP_CONFIG__,loader 落到
// 同源相对 /static → 由此代理兜到通道;默认线上通道,可指本地 serve:static
"/static": {
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
changeOrigin: true,
},
},
},
// css预处理器
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
import { resolve } from "node:path";
// 组件测试专用配置:只加载 Vue SFC 编译与 @ 别名,不引入 html/mock/svg 构建插件。
// 真实构建仍走 vite.config.ts;两者互不替代。
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { "@": resolve(process.cwd(), "src") },
},
test: {
environment: "jsdom",
include: ["tests/**/*.component.test.ts"],
setupFiles: ["tests/components/setup.ts"],
css: false,
testTimeout: 15000,
hookTimeout: 15000,
},
});