简介:JS工作流与审批流程开发资源包,面向需要构建Web端审批功能的前端及全栈开发者,覆盖流程建模、任务分配、状态流转、审批意见处理等关键环节,可帮助快速理解工作流引擎与状态机设计。压缩包内共49个文件,约69KB,以js脚本、html页面、xml流程定义为主,另含aspx/cs服务端示例、css样式、演示图片等,前端交互、流程配置与数据持久化均能对照学习。其中,HTML页面负责展示审批操作界面,XML文件配置流程节点与分支条件,JS脚本处理交互和状态判断,aspx/cs则演示服务端数据存取。已有1285人学习下载。资源包含可运行的审批页面与流程示例,以及语言包、右键菜单、步骤移动等辅助脚本,并附带服务端逻辑,便于掌握前后端协同推进审批节点的方法。对于希望优化既有系统审批体验、实现流程回退与审计追踪的开发者,是一份轻量而完整的实践素材。
1. 用 JavaScript 自己搭一套审批流:为什么说前端能独立承接
提到 js 工作流与审批流,很多团队的第一反应是上 Flowable、Activiti 这类后端工作流引擎,前端只负责画页面。但真实业务里大量审批需求是“节点不超过 10 个、每天几百单、主要规则就是按条件找人”,这种规模下用 JavaScript 自己实现一套审批流完全可行,而且迭代成本会明显低于集成重型引擎。它不是一个低代码平台,而是由流程定义 JSON、状态机引擎和拖拽设计器组成的前端审批流体系,适合 B 端项目组、中后台产品团队,以及想把“写死的审批代码”改成配置化的小团队。下面从设计模型开始,一步步讲到能跑的引擎和踩坑排查。
2. 审批流的前端设计模型:状态机、节点表与条件路由
2.1 用状态机映射审批状态:六种状态与合法的状态迁移
审批流最容易被低估的部分是状态。很多初版实现把状态写成字符串字段,页面上到处if (status === 'PENDING'),等驳回、撤回、终止叠加进来,状态就开始乱套。把状态收拢成状态机,是所有设计的基础。
我一般会预先定义六个状态:
| 状态码 | 含义 | 是否终态 | 触发动作 |
|---|---|---|---|
| DRAFT | 发起人草稿 | 否 | submit |
| PENDING | 审批中 | 否 | approve / reject / cancel / terminate |
| APPROVED | 已通过 | 是 | 无 |
| REJECTED | 已驳回 | 否 | resubmit |
| CANCELLED | 发起人撤回 | 是 | 无 |
| TERMINATED | 管理员终止 | 是 | 无 |
考虑到会签会存在“需要多人逐个同意”的情况,PENDING -> APPROVED不能设计成“调一次性同意就完成”,而是要由引擎内部的子任务完成度来决定。因此状态机里只描述宏观状态,真正的审批移动逻辑放在节点层:
const STATE_MACHINE = { DRAFT: { submit: 'PENDING' }, PENDING: { reject: 'REJECTED', cancel: 'CANCELLED', terminate: 'TERMINATED' }, REJECTED: { resubmit: 'PENDING' }, APPROVED: {}, CANCELLED: {}, TERMINATED:{} }; function canTransition(current, action) { return Object.prototype.hasOwnProperty.call(STATE_MACHINE[current] || {}, action); }这段代码的要点是:STATE_MACHINE里没有approve动作,因为它不是宏观状态迁移条件。会签节点可能需要 5 个人逐一操作,只有最后一个子任务完成才把实例从PENDING推入APPROVED。把所有非法迁移掐死在状态机层,界面上的按钮显隐、接口的权限校验,都可以直接基于canTransition生成,不会出现“某个按钮该出现但没出现”的玄学问题。
2.2 节点类型与动作拆分:审批、抄送、条件分支、会签怎么建模
流程定义里节点类型不宜多,常见的四类即可覆盖绝大多数 OA 审批需求,加一个起止节点:
| 节点类型 | 含义 | 典型配置 | 出口 |
|---|---|---|---|
| start | 流程发起 | 发起人 | 固定 next |
| approval | 审批 | 指定人/角色/表单字段选人 | 同意后走 next,驳回即终态 |
| cc | 抄送 | 被抄送人列表 | 自动走 next |
| condition | 条件分支 | conditions 数组 + defaultNext | 按规则挑一条边 |
| end | 结束 | 无 | 无 |
节点上的动作也要拆成枚举,不能把逻辑散到按钮的 onClick 里。我一般固定为六个:submit、approve、reject、transfer、cancel、terminate,再加一个urge催办动作,它不改变状态只发通知。
会签和或签这里要说得细一点:不要把会签建模成“一个人审批后生成一条新链路”,那样会签汇聚时会算错。正确做法是把会签当成一个节点的一张子任务表,每个审批人生成一个子任务,所有人完成才推进。这个模型在后面实现approve时会反复出现。
2.3 用 JSON 描述流程:一份可执行的工作流编码 Schema
相比 BPMN 2.0 的 XML 和完整规范,前端团队更适合用一份精简 JSON 表达流程,这就是把业务工作流编成一组纯数据,前后端、设计器、执行引擎之间只认这份 JSON。我给请假审批定义的最小 Schema 长这样:
{ "flowKey": "leave", "name": "请假审批", "fields": [ { "name": "leaveType", "label": "请假类型", "type": "select" }, { "name": "days", "label": "请假天数", "type": "number" }, { "name": "reason", "label": "请假原因", "type": "textarea" } ], "nodes": [ { "id": "start", "type": "start", "next": "leader" }, { "id": "leader", "type": "approval", "assignee": { "type": "role", "key": "leader" }, "next": "checkDays" }, { "id": "checkDays", "type": "condition", "conditions": [ { "field": "days", "op": ">=", "value": 3, "next": "hr" } ], "defaultNext": "end" }, { "id": "hr", "type": "approval", "assignee": { "type": "role", "key": "hr" }, "next": "end" }, { "id": "end", "type": "end" } ] }这份 JSON 的设计取舍是:所有节点都用“固定 next + 条件覆盖特殊路径”的方式,而不是完整的有向图边集合。好处是大部分审批流程是树状或单链的,读起来直观;坏处是遇到并行分支会暴露短板,后面会讲会签问题。字段fields是给表单渲染用的,引擎执行时只读nodes,这样表单改版不会污染流程逻辑。
注意:条件路由的
conditions数组顺序就是优先级,第一条命中即生效,所有条件都不命中才走defaultNext。不要设计隐式“最后一个 else”规则,让流程定义在界面上就能看懂。
3. 实现最小可跑的 JS 审批流引擎:状态机驱动、分支解析与 SDK 封装
3.1 初始化流程实例:从流程定义创建待办的第一步
引擎的对外 API 越少越好,至少能覆盖创建、审批、撤回、催办四件事。先看createInstance,它接收流程定义和表单数据,生成一个流程实例并落到数据层:
class ApprovalEngine { constructor({ flowDef, dataStore }) { this.flowDef = flowDef; this.dataStore = dataStore; // 抽象数据层,只需 get / save } async createInstance(formData, starter) { const now = Date.now(); const instance = { id: generateId('inst'), flowKey: this.flowDef.flowKey, status: 'PENDING', currentNodeId: null, formData, starter, logs: [], createdAt: now, updatedAt: now }; const first = this._nextNode(this.flowDef.nodes.find(n => n.type === 'start').id, formData); instance.currentNodeId = first.id; await this._createTodo(instance, first); await this.dataStore.save(instance); return instance; } }这个方法的逻辑是:实例创建后先把状态置成PENDING,然后用_nextNode解析 start 节点后面的真实首个节点,给它生成待办。currentNodeId不是 start 的 id,而是第一个真正要处理的人的节点 id,这样前端拿到的数据永远可以渲染成“当前待办是谁”。
参数说明:formData是表单提交原始数据,不要和流程实例字段混在一起,后续条件分支要读它;dataStore抽象成 get/save 两个方法,是为了把 localStorage、IndexedDB、后端接口或内存数组都当成同一个存储实现。演示场景里可以用 localStorage,但生产环境如果纯前端跑,建议至少走 IndexedDB 或服务端接口持久化,否则清缓存等于丢流程。
3.2 核心审批动作:同意、驳回、转交与轨迹记录
approve是引擎最核心的方法。它要解决三件事:检查操作人是否有权限、记录审批轨迹、根据动作决定节点移动方向。
async approve(instanceId, operator, action, comment = '', transferTo = null) { const instance = await this.dataStore.get(instanceId); const node = this._findNode(instance.currentNodeId); const assignee = this._resolveAssignee(node, instance); if (assignee.type === 'person' && assignee.id !== operator) { throw new Error('非当前处理人,不能审批'); } instance.logs.push({ at: Date.now(), nodeId: node.id, operator, action, comment }); instance.updatedAt = Date.now(); if (action === 'reject') { instance.status = 'REJECTED'; instance.currentNodeId = null; await this.dataStore.save(instance); return { ok: true, terminal: 'REJECTED' }; } if (action === 'transfer') { const targetId = transferTo || comment.replace(/^@/, ''); node.assignee = { type: 'person', id: targetId }; await this._createTodo(instance, node); await this.dataStore.save(instance); return { ok: true, node: node.id }; } const next = this._nextNode(node.id, instance.formData); if (next.type === 'end') { instance.status = 'APPROVED'; instance.currentNodeId = null; } else { instance.currentNodeId = next.id; await this._createTodo(instance, next); } await this.dataStore.save(instance); return { ok: true, next: next.id }; }逻辑说明:reject直接进入终态并清空当前节点,因为业务上驳回后要么发起人重新提交要么作废,不会继续往下走;transfer把当前节点的 assignee 改掉,再重新生成一次待办;正常情况下approve走_nextNode找下游。
参数说明:action只接收固定枚举,界面按钮不要传自由字符串;transferTo独立成参数而不是从 comment 里解析,是为了避免审批意见里出现@userId这种需要二次解析的格式。会签节点的多人处理不在这个方法里展开,它会在后续子任务模型处理。
3.3 条件分支解析:比较运算符、函数分支与默认出口兜底
_nextNode的实现决定了流程能不能正确到达下一个审批人。条件节点的 rule 不是if/else写死,而是由配置数据驱动:
_nextNode(nodeId, formData) { const node = this._findNode(nodeId); if (node.type === 'condition') { for (const cond of node.conditions) { if (this._match(cond, formData)) { return this._findNode(cond.next); } } return this._findNode(node.defaultNext); } return this._findNode(node.next); } _match(cond, formData) { if (cond.op === 'func' && typeof cond.handler === 'function') { return cond.handler(formData); } const actual = formData[cond.field]; switch (cond.op) { case '==': return String(actual) === String(cond.value); case '>': return Number(actual) > Number(cond.value); case '>=': return Number(actual) >= Number(cond.value); case 'in': return cond.value.includes(actual); default: return false; } }这里要强调条件分支的执行顺序:conditions数组按优先级从上到下匹配,第一条命中的生效;全都不命中时defaultNext是最后的兜底,不能省略。实际项目里常见一个坑是只配置了条件边没配默认边,流程运行时直接抛“找不到下一个节点”,所以要养成在流程定义校验阶段就强制检查defaultNext的习惯。
op === 'func'是我比较喜欢留的口子:当常规比较满足不了,比如“请假日期跨过节假日”或“金额包含在多个区间”,流程定义里挂一个函数名或函数引用,由前端实现,避免为了一个特殊情况改引擎结构。
3.4 封装成前端 SDK:在 React 与 Vue 里只暴露四个方法
引擎本身不依赖任何框架,接入层把引擎实例化和四个业务动作暴露给组件即可。以 React 为例:
// approvalSdk.js let engine = null; export function initApproval(config) { engine = new ApprovalEngine(config); } export function useApproval() { return { submit: (formData) => engine.createInstance(formData, currentUser()), approve: (instanceId) => engine.approve(instanceId, currentUser(), 'approve'), reject: (instanceId, comment) => engine.approve(instanceId, currentUser(), 'reject', comment), cancel: (instanceId) => engine.approve(instanceId, currentUser(), 'cancel') }; }接入 Vue 时可以用provide/inject把同一个实例下发,逻辑完全一致。核心原则是流程实例的状态永远在引擎里,组件只负责把用户动作传进去。很多人把 instance 塞进组件 state,单据换个页面打开状态就丢了,这是审批流项目最常见的架构性翻车点。
提示:不要在组件里
new ApprovalEngine()。一个流程定义对应一个引擎实例,应用启动时初始化一次,跨页面共享,否则待办列表和详情页会各持一份状态,互相覆盖。
4. 给审批流装上可视化设计器:拖拽编排、JSON 序列化与流程预览
4.1 手写 JSON、自研设计器、开源图形库怎么选
流程定义如果只给开发维护,手写 JSON 尚且能接受;但审批流的维护者往往是产品甚至行政,就必须有可视化设计器。常见做法有三种:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 手写 JSON | 零依赖、可控 | 非技术人员无法维护 | 流程固定且不常改 |
| 自研拖拽设计器 | 完全可控、交互贴合业务 | 开发量中等 | 节点类型会持续增加 |
| 引入开源前端图形库 | 拖拽、连线、画布现成 | 样式和语义要二次封装 | 团队人力不足或场景标准 |
如果团队从零开始,我的建议是:先别急着引完整 BPMN 建模器,BPMN 的网关、事件、泳道概念对审批业务是过度设计,反而让非技术维护者困惑。自研一个“节点面板 + 画布 + 属性侧栏”的迷你设计器,通常只需要 5 到 7 天。
4.2 最小可跑的拖拽实现:节点落画布、连线与属性面板
拖拽的关键是区分“从面板拿节点类型”和“在画布上创建节点”两个阶段。用 HTML5 原生拖放就能跑通:
const paletteItems = document.querySelectorAll('[draggable="true"]'); paletteItems.forEach(item => { item.addEventListener('dragstart', e => { e.dataTransfer.setData('nodeType', item.dataset.nodeType); }); }); canvas.addEventListener('dragover', e => e.preventDefault()); canvas.addEventListener('drop', e => { e.preventDefault(); const type = e.dataTransfer.getData('nodeType'); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const node = createNode(type, x, y); designerModel.nodes.push(node); renderNode(node); });这里最容易踩的坑是坐标偏移:drop事件的clientX/clientY是相对视口的,必须减去画布本身的getBoundingClientRect().left/top,否则节点会随着页面滚动位置乱跑。如果画布内部还有缩放或滚动,坐标还要再除以缩放比例。连线的实现可以用 SVG 在两个节点之间画箭头,记录source和target两个节点 id,不要在连线上保存坐标,坐标只属于节点。
属性侧栏在选中节点时展示对应配置:审批节点填审批人类型、条件节点填条件和出口、抄送节点填抄送人。所有配置先写进节点的config字段,后续序列化阶段再提取成引擎要的结构。
4.3 从设计器到引擎可运行 JSON:清理坐标与还原条件出口
设计器数据不能直接交给引擎执行。它里面有x/y、宽高、样式这类渲染信息,还有连线的坐标点位,引擎只关心节点类型、审批人和分支规则。所以要做一次清洗转换,我习惯用toEngineDef负责这件事:
function toEngineDef(designerJson) { const edges = designerJson.edges; const nodes = designerJson.nodes.map(n => { const outEdges = edges.filter(e => e.source === n.id).map(e => e.target); const base = { id: n.id, type: n.type }; if (n.type === 'condition') { return { ...base, conditions: n.config.conditions.map(cond => ({ ...cond, next: cond.next })), defaultNext: n.config.defaultNext || outEdges[0] }; } return { ...base, assignee: n.config.assignee, next: outEdges[0] }; }); return { flowKey: designerJson.flowKey, name: designerJson.name, nodes }; }这个函数的边界条件值得讲讲:条件节点的conditions里每一项的next是产品在侧栏手选的出口,和连线的 target 可能不一致,转换时以config里的显式配置优先;普通节点的next取当前节点唯一出线。如果一个普通审批节点画出两条线,这里就会因为outEdges[0]只取第一条而产生隐患,所以设计器本身要限制普通节点只能有一条出线,条件节点要求连线数量等于条件数加默认数。
完成这一步后,之前第 2.3 节的 JSON Schema 就是设计器落库的标准产物。设计器可以存在草稿状态,流程发布时再执行toEngineDef,避免“画到一半的流程被线上实例读到”。
4.4 审批前预览:当前节点高亮与历史轨迹渲染
流程预览不是静态图。用户在待办列表打开一张单子,要能一眼看到流程走到哪一步、下一步是谁。常见做法是根据实例的currentNodeId和logs渲染节点状态:
function renderFlowStatus(flowDef, instance) { const doneNodeIds = new Set(instance.logs.map(log => log.nodeId)); return flowDef.nodes.map(node => { const isCurrent = node.id === instance.currentNodeId; const isDone = doneNodeIds.has(node.id); return { id: node.id, type: node.type, cls: isCurrent ? 'current' : isDone ? 'done' : 'todo', history: instance.logs.filter(log => log.nodeId === node.id) }; }); }renderFlowStatus返回的节点数组可以直接驱动任何组件渲染。点击某个节点时,把该节点的history展示为“谁、什么时间、同意或驳回、意见是什么”。当前节点用明显颜色高亮,已完成节点显示对勾或半透明,未到达节点灰色。如果流程预览嵌入的是 iframe 页面,可以用window.postMessage把“点击节点查看轨迹”的事件抛给父页面,父页面再去打开单据详情,不需要把整张大图做成业务组件。
注意:高亮的是“当前节点”,不是“当前节点的人”。会签节点会有多个人在处理中,节点状态仍然是
current,此时应把子任务里已完成的人数列到节点气泡上,而不是把节点标记成完成。
5. 审批流常见翻车现场:5 个资深前端都踩过的状态、条件与汇聚坑
5.1 用本地时间判断超时,改一次系统时间流程就乱跳
现象:审批节点设了 48 小时超时自动转交给上级,前端用setTimeout或Date.now()计算结果。某位同事把电脑系统时间往后改了几天,待办列表里出现大量“已超时转交”的记录,流程状态和实际处理时间完全对不上。
原因:审批流是有状态约束的业务,时间必须是统一权威来源。浏览器本地时间可以因用户改时区、调系统时间、休眠唤醒而漂移,任何依赖它的状态迁移都不可靠。
解决:所有超时判断改由服务端时间戳驱动。前端收到待办时记录下serverTime,轮询或每次操作都用服务端时间;纯前端落地方案也要把“当前时间”抽象成一个timeProvider()函数,统一从接口或可信时钟源取,而不是散落各处的new Date()。
5.2 表单字段改名后,历史审批单的条件全失灵
现象:上线三个月后产品把表单里的“请假天数”从days改成了duration,正在审批的单子走到条件节点时全部默认走defaultNext,该送人事审批的没送。
原因:流程定义里的condition.field是硬编码字符串,字段重命名不会触发同步。这种事等线上单子出问题才被发现,因为历史实例的formData里存的是旧字段名。
解决:表单字段引入fieldCode作为持久层标识,展示名随意换,code 一经发布不允许修改。流程定义和实例表单都引用fieldCode,引擎匹配时不碰label。已经上线并埋了雷的旧实例,写一个迁移脚本扫描所有未完成的流程实例,把days映射回duration再落到formData,一次跑完不留存量。
5.3 驳回后重新提交直接复用旧实例,状态和轨迹全乱
现象:单据被驳回,发起人修改后再次提交,代码直接复制原 instance 把 status 从REJECTED改回PENDING。界面显示正常,但历史日志里保留着一条reject记录,审计时说不清这次到底是通过还是驳回过。
原因:把“流程实例”当成了“一次业务单据”。一张请假单可以有多次审批生命周期,每次重新提交意味着一个新的执行回合,原实例的状态语义已经被污染。
解决:给 instance 加revision字段,每次重新提交revision + 1,旧日志通过revision分段保存;或者坚持“驳回即重新发起新实例”,用业务单号把多段实例串起来。我一般选前者,因为用户可以继续查看同一张单据的完整修改历史,体验更好,但痕迹结构必须按revision分开渲染。
5.4 会签节点五个人全同意,流程却不推进了
现象:会签节点配置了 5 名审批人,5 个人先后都点了同意,节点气泡里的计数也显示 5/5,但流程就是停在原地,没有任何报错。
原因:这是最经典的汇聚判断错误。把会签实现成“每个人各自 create 一条子链路”或者“所有人的操作都覆盖同一个 count 字段”,最后一个人写入时要么没触发完成逻辑,要么并发写把计数覆盖掉了。会签节点的推进条件不是“有人同意”,而是“所有子任务全部完成”。
解决:把会签节点的处理人改为子任务集合,每个子任务有自己的approver / status / handledAt。节点完成判定改为“查看所有子任务的 status 是否全部等于 agreed”,全部完成才调用节点的_nextNode。前端要注意幂等:第 5 个子任务完成时,节点推进逻辑只能执行一次。可以给 instance 加一个processedAction标记,处理完立刻落库,防止重复触发。
5.5 撤销与终止共用一个终态,单子死在半路没人追责
现象:发起人想撤回自己的申请,页面按钮写的是“终止”;管理员也想“终止”一张异常单,两个动作共用TERMINATED状态。事后查历史,分不清单子是主动撤回还是被干预终止,业务方互相甩锅。
原因:状态模型里偷懒把语义不同的两个终态合并成一个,按钮的权限也没有按角色收敛。撤销是发起人的权利,终止是管理员的处置权,它们应该从状态、权限和轨迹三个层面隔离。
解决:状态拆分CANCELLED与TERMINATED,cancel动作校验操作人是发起人且当前节点尚未被处理;terminate动作要求管理员身份且必填终止原因。前端按钮显隐用第 2 章的状态机迁移表自动生成,而不是写死在组件里,这样以后新增终态也不会漏改按钮。
6. 给审批流做体检:状态机单测、轨迹回放与幂等提交
审批流这类组件测试收益极高,因为它是纯状态逻辑。至少覆盖四类用例:状态迁移合法性、条件路由是否命中正确出口、会签汇聚是否只推进一次、同一节点重复操作是否被幂等拦截。
| 测试场景 | 输入 | 期望结果 |
|---|---|---|
| 正常审批链 | days=1 提交 | 最终得到 APPROVED |
| 条件分支命中 | days=5 提交 | 经过 hr 节点后 APPROVED |
| 会签汇聚 | 5 人逐一同意 | 第 5 人操作后恰好推进一次 |
| 幂等重复 | 同一 operateKey 重复 approve | 第二次直接返回当前实例 |
轨迹回放是排错利器。instance.logs 本身就是一条完整时间线,我一般会做一版只读的“流程回放”视图,按时间把“谁在哪个节点执行了哪个动作、带了什么意见”渲染成列表。线上单子出问题时,打开回放就能定位是不是条件设计错了,而不是去翻数据库。
幂等提交值得单独写一句。审批按钮很容易被用户双击,接口慢时重试也会重复提交。在 approve 入口检查instance.processedAction === opKey,命中就直接返回实例不执行逻辑。这个opKey每次客户端操作生成一次,写入时和实例一起落库,比在按钮上做 disabled 更可靠。
我最后的习惯是:先把状态机画在白板上,再写流程 JSON,最后才碰界面。有一次线上超时异常,查了三小时发现是浏览器休眠挂起了计时器,从那以后所有和状态相关的判断都收进引擎,界面只做展示和触发。希望帮到你。
本文还有配套的精品资源,点击获取