
凡是做过企业应用的开发者,都绕不开审批流:请假要审批、报销要审批、采购要审批。自己实现一套审批流引擎,涉及流程定义、节点流转、待办推送、回退转发、会签加签,没有一个月下不来,还容易埋雷。geejing WebBuilder 快速开发平台把工作流做成了内置引擎:流程用 .flw 文件定义,业务模块只关心"表单长什么样、数据存哪里"两件事,其余的流转、调度、状态管理全部由引擎接管。本篇通过 example/workflow 目录下的请假流程(leave)与手动启动(manual-start)示例,把一条审批流从发起到归档的完整生命周期走一遍。
打开 example/workflow/leave 目录,你会看到一组职责分明的模块:start-form.xwl(发起表单)、handle-form.xwl(审批表单)、before-action.xwl 与 after-action.xwl(动作前后钩子)、actions.xwl(业务数据服务端)。再配上 example/leave.flw 流程定义文件和一个 reimburse(报销)目录的同构实现。这套文件结构本身就是最好的架构文档:
换句话说,平台把"流程"从业务代码里抽了出来——业务模块不感知流程走向,流程文件不包含业务逻辑,两者通过约定的接口(init、afterAction 这些钩子)对接。这是所有 BPM 设计的核心理念,但在这里它被简化成了一组浅显的文件命名约定。这份简单是有代价换来的:引擎需要预设大量约定(表单叫 start-form、数据绑定 flow_id、钩子叫 before/after-action),约定生效后开发者就不用为每个流程重复写调度代码。有团队会担心"约定不够灵活",从我们的经验看,真正会撞墙的是少数——流程引擎的价值不在能画出多复杂的流程图,而在把稳定性要求最高的那部分(状态一致性、待办正确性)从你的代码里拿走。复杂的会签、子流程、条件路由,引擎层面都有对应能力;真正要警惕的反而是把业务逻辑塞进流程定义里,让 .flw 文件变成第二个代码仓库。

截图是 start-form.xwl 打开后的样子:一个"Leave application"窗口,五个必填字段——标题、开始日期、结束日期、请假天数、事由。表单控件的定义也延续了系列一贯的简洁:required: 'true' 标必填,日期字段用 Wb.Date 自带日历下拉,请假天数用 Wb.Number 限定整数(decimalCount: '0')加最大长度,引擎的 Wb.verify 会在提交前统一把关。这些控件细节在第 9、10 篇里都讲过,此处只需知道:审批表单不是特殊物种,就是普通表单 + 工作流约定。它的客户端初始化代码值得细读:
Wb.apply(app, {
autoShow: false,
init(flowId, type, record) {
if (flowId) {
Wb.ajax({
url: xpath + '/../actions&xaction=selectLeave',
params: { flowId },
json: true,
success(resp) {
Wb.setValue(app.main, resp);
app.main.show();
}
});
} else {
app.main.show();
}
}
});init(flowId, type, record) 是平台工作流与业务表单之间最重要的约定:同一个表单模块会被引擎以三种形态调用——type 为 start 时是发起人填写的新表单(flowId 为空,直接显示空表);为 handle 时是审批人看到的表单(flowId 有值,先 ajax 拉取业务数据 Wb.setValue 回填再显示);为 view 时是查阅者的只读视图。一个模块服务三种视角,不需要写三个页面,这依赖的正是第 15 篇讲的"模块即单元"的加载机制:引擎按需加载 start-form,传不同参数,得到不同表现。
再看服务端部分。start-form 的 serverScript 里定义了 afterAction(action, flow)——它在"发起"、"通过"等动作执行后被引擎回调:
afterAction(action, flow) {
if (flow.restarting) {
Params.$flow_id = flow.flowId;
Wb.sync({ tableName: 'wb_leave', update: Params, whereFields: 'flow_id' });
} else {
Wb.apply(Params, { sid: Wb.getId(), flow_id: flow.flowId, user_id: flow.handleUser });
Wb.sync({ tableName: 'wb_leave', insert: Params });
}
}这段代码处理了业务数据落库的两种情形:流程首次发起(else 分支),生成主键 sid、绑定流程实例号 flow_id 与发起人 flow.handleUser,插入 wb_leave 表;流程被驳回后修改重提(flow.restarting 为 true),改为按 whereFields: 'flow_id' 更新原记录。注意业务表与流程实例的关联方式——一张业务表加一个 flow_id 字段就完成了与引擎的对接,引擎侧的流程状态、审批记录都存在引擎自己的表里,业务表干干净净只有业务字段。tags: '{container: false}' 的声明则告诉引擎这个模块不作为容器加载。
审批环节的 handle-form.xwl 展示了引擎提供的动作 API 全家福。先看初始化:
Wb.apply(app, {
hideActionButtons: true,
leaveData: _$leaveData$_,
init(flowId, type, record) {
Wb.setValue(app.main, app.leaveData);
app.radioGroup1.visible = type == 'handle';
Wb.log(record.data.node_name);
},
...三处细节:hideActionButtons: true 隐藏引擎默认的"通过/驳回"按钮,改由表单自定义 UI;leaveData: _$leaveData$_ 是平台的模板占位语法——引擎加载模块时会把审批单数据直接注入源码占位处,比 ajax 回取少一次往返(两种取数方式并存,按场景选);app.radioGroup1.visible = type == 'handle' 让操作选项只在审批视角出现,查阅视角自动隐藏。
核心的 onAction 是一个 switch 分发,每个 case 调用一个 doXxx 方法:
switch (app.radioGroup1.value) {
case 0: app.doPass(); break; // 通过
case 1: value = app.nodeSelect.value;
if (value) app.doBack(value); // 退回到指定节点
else { app.nodeSelect.focus(); Wb.tipSelect(); }
break;
case 2: value = app.userSelect.value;
if (value) app.doTransfer(value); // 转办给指定人
break;
case 3: app.doReject(); break; // 驳回终止
case 4: value = app.userSelect.value;
if (value) app.doSignBefore(value); // 前加签
break;
}doPass、doBack(节点)、doTransfer(用户)、doReject、doSignBefore(用户) 五个方法由引擎注入到审批表单的作用域,覆盖了审批操作的主干:通过、退回、转办、驳回、加签。方法内部自动处理流转逻辑——更新引擎状态表、计算下一节点处理人、写审批记录,业务代码只在动作完成后通过 afterAction 钩子拿到通知。这五个动作与 .flw 文件里的节点定义是联动的:doBack 能退到哪些节点,取决于流程图里画了哪些可回退路径;doTransfer 的候选用户来自节点上配置的处理人规则。表单代码只负责"发起动作调用",规则解释权始终在引擎手里——这也是审批系统审计合规的关键:动作从来不是页面说了算,页面只是触发器。特别注意示例里的参数校验写法:选了"退回"但没选目标节点时,app.nodeSelect.focus() 聚焦控件 + Wb.tipSelect() 弹本地化提示,第 17 篇讲的反馈体系在这里派上了用场。
动作执行的前后还有两个全局钩子模块 before-action.xwl 与 after-action.xwl,内容极简却是理解引擎的关键:
Wb.info('before flow action: ' + app.action); // action name
Wb.info('before flow id: ' + app.flow.flowId); // workflow idapp.action 是动作名,app.flow.flowId 是流程实例号,两个钩子在每个动作执行前后都会被调用(示例里只做了日志,注释显示也可以 return 值给客户端)。发放通知、写操作日志、联动外部系统,都挂在这里,一处配置、全流程生效。钩子还提供了一个鲜为人知的扩展点:before 钩子在某些场景下可以中断流程——比如发现金额超限需要走另一条审批线时,可以在钩子里改写流转参数。这类能力要用得克制:钩子里的逻辑越重,流程行为就越难预测,一般原则是"钩子做观察与记录,实在必要才做干预"。

前面讲的都是"用户在界面上填表发起",manual-start.xwl 演示了另一条路:代码直接发起流程。页面只有一个按钮,服务端逻辑在 start.xwl:
flow = new Wb.Workflow({ file: 'example/leave.flw', title: 'My leave application' });
flow.start();
Wb.sync({
tableName: 'wb_leave', insert: {
sid: Wb.getId(), begin_date: now.addDay(2), end_date: now.addDay(5),
flow_id: flow.flowId, user_id: Wb.userid, leave_days: 8, leave_reason: 'Go on vacation'
}
});new Wb.Workflow({ file, title }) 指定流程定义文件并实例化,flow.start() 启动实例,flow.flowId 拿到实例号写进业务表——发起一个流程只需要这三步,和 new 一个普通对象没有任何区别。示例数据里的 user_id: Wb.userid 取当前登录用户,now.addDay(2) 展示了平台对 Date 的扩展方法(addDay、dateText 这些在原生 JS 里都要自己写)。这条路径的典型用途是系统自动发起的流程:合同到期自动触发续签审批、库存低于阈值自动发起采购申请——没有人在界面上点"发起",流程照样转起来。从工程视角看,"API 发起"与"表单发起"走的是同一条引擎管道,后续的审批、回退、归档行为完全一致,你不需要为自动发起的流程单独维护一套处理逻辑——很多自研工作流恰恰是在这条边界上裂开的:人工发起的流程好好的,定时任务发起的流程就丢待办、丢通知。
客户端拿到结果后的引导也讲究:Wb.tip('The workflow started, click to view it.', null, f => Wb.openNormal({ url: 'my-flow', success(scope) { scope.grid1.reload(); } }))——提示条本身就是可点击的,点一下直接跳到"我的流程"页面并刷新列表。反馈与导航合二为一,比弹一个"操作成功"再让用户自己找入口高明得多。
把四个模块的要点串成一张落地清单,照着做就能搭出自己的第一条流程。清单里的每一步都对应本篇讲过的示例文件,做的时候把示例源码摆在旁边对照,比任何文档都直观:
flow_id 字段,其余全是业务字段;init(flowId, type, record) 三态处理 + 服务端 afterAction 落库(insert 与 restarting 两个分支);_$data$_ 或 ajax 取数;Wb.Workflow 在业务代码里自动发起。三条实践忠告配套奉上:第一,业务表里的 flow_id 字段从第一天就要建索引,流程列表页的查询全靠它;第二,afterAction 里的落库逻辑要幂等——重试、重复回调在企业环境里是常态而不是异常;第三,流程定义文件的版本管理要像代码一样严肃,改 .flw 不留记录,三个月后没人说得清"为什么这一步要抄送总监"。另外,示例目录里的 reimburse(报销)流程与 leave 结构完全同构,读会了请假流程,报销流程就是免费的复习课——顺便观察它多出的金额字段与额度校验,看看同一套骨架如何承载不同的业务约束。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。