简介:面向需要实现网页端工作流与审批功能的前端开发者,该资源包提供了一套基于JavaScript构建的工作流示例。压缩包共49个文件、约69KB,核心包含8个js脚本、8个html页面、6个xml流程定义文件,另有aspx/cs后端处理代码、css样式、db数据文件及gif演示动画,可完整覆盖流程建模、任务调度、审批界面展示与数据持久化等环节。包内示例涉及webflow.html审批页面、flow.aspx服务端逻辑、fly.xml流程定义,以及main_zh、actiondialog_zh等中文本地化配置,使用者可据此理解BPMN图形化建模、状态机流转、角色权限分配等核心实现思路。资源已有1285人学习,适合想快速掌握前端工作流框架搭建与审批交互设计的中级开发者参考与改造。
1. 上手这套 js 工作流资源前,先想清楚你要接的到底是什么活
一提起 js 工作流、审批流,很多前端的第一反应是“这得接一个重引擎”。实际上大多数后台系统要的只是“能画节点、能点同意/驳回、能查历史”这三件事,离引擎差着十万八千里。这份资源给的就是那一层够用的东西:一套用 JavaScript 写的工作流运行时,包含了流程定义解析、状态机转跳、审批动作函数、历史记录组件和一份可直接导入的数据库脚本。它能帮你从零搭出请假、采购、报销这类单线条审批场景,而不是去啃 BPMN 规范。适合三类人:被安排开发 OA 模块但没接触过工作流的后端/前端、想把现有业务流程从硬编码改成可配置、以及接私活时需要快速交付演示原型的人。下面按我拆解这类项目的顺序,把原理、用法和踩坑一次性讲透。
2. 把审批流拆成三层模型:节点、动作、状态机,谁先懂谁少改需求
2.1 审批流的三个核心抽象:节点、连线、流程实例
工作流跑不起来,绝大多数原因是把“流程图”和“流程运行”混成了一件事。画出来的图是静态的,运行中的流程是动态的,中间隔着一层“实例状态”。这份资源里的核心抽象正好是三个:节点(node)、连线(edge)和实例(instance)。
节点描述“有哪些审批环节”。常见类型包括开始节点、审批节点、条件节点、结束节点。连线描述“从哪个环节走到哪个环节”,线上可以挂条件表达式。实例则是某一次具体业务请求的运行快照,比如张三提交的那一条请假记录,它在某一时刻停留在“部门经理审批”这个节点上,状态是“审批中”。
{ "processKey": "leave", "name": "请假审批流程", "nodes": [ { "id": "start", "type": "start", "name": "开始" }, { "id": "apply", "type": "approval", "name": "提交申请", "assignee": "${submitter}" }, { "id": "manager", "type": "approval", "name": "部门经理审批", "assignee": "managerGroup" }, { "id": "hr", "type": "approval", "name": "人事复核", "assignee": "hrGroup" }, { "id": "end", "type": "end", "name": "结束" } ], "edges": [ { "from": "start", "to": "apply" }, { "from": "apply", "to": "manager" }, { "from": "manager", "to": "hr", "condition": "vacationDays > 3" }, { "from": "manager", "to": "end", "condition": "vacationDays <= 3" }, { "from": "hr", "to": "end" } ] }这份 JSON 是资源的流程定义文件,核心在于三个字段:processKey 是流程唯一标识,nodes 是节点数组,edges 是连线数组。条件表达式放在 edges 的 condition 字段里,表达式里引用的是表单变量,比如 vacationDays。这个设计比很多方案都简洁——你不需要引入规则引擎,一个表达式解析器就能跑通。
实际项目中常见的误用是:把审批人写死在节点上,每个节点都要改代码。正确做法是用 assignee 字段指向角色、用户组或者表达式变量,这样换人审批时改配置不改代码。资源中的 mock 流程文件里有两套流程定义,一套走单线审批,一套带条件分支,适合对照着看。
2.2 为什么“流程图画得出来,代码跑不通”:状态机才是主心骨
我见过不少人拿着画好的流程图去设计数据库,结果表结构建了一堆流程父子关系,代码里全是 if else 判断当前节点是什么、点了按钮要往哪跳。这样写三天能跑通单线流程,三周都跑不通带驳回和撤回的流程。原因在于:审批流本质是一个状态机,不是一棵树。
状态机的核心是一个“当前状态 + 事件 → 下一个状态”的映射表。在这份资源里,这个映射表放到了 stateMachine.js 中,key 由“当前状态 + 冒号 + 操作动作”组成,value 是跳转后的新状态。
// src/engine/stateMachine.js const TRANSITIONS = { 'DRAFT:submit': { next: 'APPROVING', action: 'submit' }, 'APPROVING:agree': { next: 'APPROVING', action: 'agree' }, 'APPROVING:reject': { next: 'REJECTED', action: 'reject' }, 'REJECTED:resubmit': { next: 'APPROVING', action: 'resubmit' }, 'APPROVING:revoke': { next: 'DRAFT', action: 'revoke' }, 'APPROVING:end': { next: 'APPROVED', action: 'complete' }, }; function getNextState(currentState, event) { const transition = TRANSITIONS[`${currentState}:${event}`]; if (!transition) { throw new Error(`非法状态流转: ${currentState} -> ${event}`); } return transition.next; }这里最关键的是最后那个 throw。很多简化版的状态机不拦非法流转,结果就是用户能对一条已结束的流程继续点“同意”,数据全乱了。资源里这个函数会在非法操作时直接抛错,前端弹提示,后端记日志,宁可让操作失败也不让状态脏掉。
参数说明:第一个参数 currentState 是流程实例当前状态,第二个参数 event 是操作事件。这两个参数不是任意组合都能用,必须提前在 TRANSITIONS 里声明。如果后续要新增“加签”“转办”这类动作,改这一份映射表就够,不用动业务页面代码。
2.3 资源中的目录结构与核心文件定位
拆开这份资源,目录结构并不复杂,但每个文件的作用要分清,否则容易改错地方。前端入口、流程引擎、组件、mock 数据、后端接口、初始化 SQL 分布在六个目录下。
| 路径 | 作用 | 你需要改它的场景 |
|---|---|---|
| src/engine/stateMachine.js | 状态机映射与流转校验 | 新增/调整状态时 |
| src/engine/flowParser.js | 解析流程定义 JSON,构建节点与连线的索引 | 流程定义结构变化时 |
| src/engine/actions.js | 封装 submit/agree/reject/revoke/transfer 五个动作 | 需要加“加签”等扩展动作时 |
| src/components/FlowNode.vue | 审批节点卡片,展示当前审批人、操作按钮 | 调整审批按钮样式/权限时 |
| src/components/HistoryTimeline.vue | 审批历史时间线 | 需要展示操作耗时、修改评论样式时 |
| src/mock/leave.json | 请假流程定义示例 | 学习定义格式时的参考样板 |
| server/workflowRouter.js | 后端路由,接收前端动作请求 | 对接真实后端时替换 |
| server/init.sql | 流程实例与任务表初始化脚本 | 首次建表时执行 |
我一般会建议先读 stateMachine.js 和 flowParser.js 两个文件,它们加起来不到 400 行,看完你对这套资源的整体设计就心里有数了。组件和路由文件反而是其次,因为那是业务层的壳,最终要按你自己的技术栈替换。
3. 跑通完整链路:从静态 Demo 到能提交、审批、驳回的可用原型
3.1 初始化流程引擎:把流程定义 JSON 加载进内存
下载解压之后,不要急着在浏览器里点按钮。要先把这个静态资源里的流程引擎当成一个 npm 包理解的模块,在你的项目入口处初始化。初始化要做三件事:加载流程定义、解析节点与连线、创建实例存储容器。
// main.js 初始化入口 import { FlowParser } from './src/engine/flowParser'; import { WorkflowInstance } from './src/engine/workflowInstance'; import leaveFlow from './src/mock/leave.json'; // 1. 解析流程定义 const flowParser = new FlowParser(leaveFlow); flowParser.buildIndex(); // 内部构建 nodeMap 和 edgeMap,便于 O(1) 查询 // 2. 创建新的流程实例,初始状态为 DRAFT const instance = new WorkflowInstance({ processKey: leaveFlow.processKey, businessKey: 'BIZ_20250101_001', submitterId: 'u10086', variables: { vacationDays: 2, reason: '家庭事假' } }); // 3. 把实例注册到引擎,后续所有操作都通过实例ID进行 workflowEngine.register(instance);FlowParser 的 buildIndex 方法值得多说一句:它会把 nodes 数组转成以 nodeId 为 key 的 Map,把 edges 数组转成从 from 节点出发的子节点列表。这样运行时查“下一个节点是谁”就不用每次都遍历整个 JSON 数组。
WorkflowInstance 构造函数接收的四个参数中,businessKey 是业务单据号,用来关联你自己的业务表。variables 是表单变量,会参与条件表达式的计算。这一步做完,流程引擎就已经在内存里等事件了,但你还没有任何可视化界面。
3.2 状态机表驱动:用一张映射表扛住所有审批动作
审批过程中的每一次操作,本质上都是“给当前状态发一个事件”。前端按钮的事件绑定不要写死“点击同意就调这个接口”,而是统一走一个 execute 方法,由它去查状态机映射表,决定实例是往前跳、往回跳还是原地报错。
async function executeAction(instanceId, action, payload = {}) { const instance = workflowEngine.getInstance(instanceId); if (!instance) throw new Error('流程实例不存在'); // 1. 查状态机:当前状态 + 动作 -> 下一状态 const nextState = getNextState(instance.status, action); // 2. 执行业务侧动作(如写审批记录、推送通知) await actions[action](instance, payload); // 3. 更新实例状态并记录历史 instance.status = nextState; instance.currentNodeId = payload.nextNodeId || flowParser.getFirstApprovalNode(); historyRecorder.append(instanceId, action, payload); return instance; }逻辑说明:第一步是状态机校验,第二部是执行业务动作,第三步是写状态与历史。顺序不能反,先执行业务动作再更新状态,这样如果业务动作抛异常,实例状态不会被污染。
参数说明:action 允许的值就是 actions.js 里定义的那几种——submit、agree、reject、revoke、transfer。每个动作函数接收 instance 和 payload 两个参数,payload 里一般带 comment(审批意见)、nextNodeId(指定跳到哪个节点)、assigneeId(转办给谁)。
这里有个细节:agree 动作执行完后,需要判断当前节点是不是最后一个审批节点。资源里通过 flowParser.getNextNodes(instance.currentNodeId, instance.variables) 拿到后续节点列表,如果列表为空,就触发 end 事件,状态变成 APPROVED。这个判断逻辑放在 actions.js 里,不要放在组件里。
3.3 审批历史与操作按钮的动态渲染
流程跑通之后,页面上要展示两块内容:当前节点信息和历史时间线。当前节点信息包括标题、当前审批人、操作按钮组。操作按钮组不是写死的,而是根据当前节点类型和实例状态算出来的。
<template> <div class="approval-panel"> <h3>{{ currentNode.name }}</h3> <p>审批人:{{ currentNode.assignee }}</p> <div class="action-buttons"> <button v-for="action in availableActions" :key="action.code" @click="handleAction(action.code)" :disabled="action.disabled" > {{ action.label }} </button> </div> <history-timeline :records="historyRecords" /> </div> </template>availableActions 的计算方式是:根据 instance.status 去查一个按钮配置表。比如当前状态是 APPROVING,当前节点类型是 approval,按钮就是“同意、驳回、转办”;如果当前状态是 DRAFT,按钮就只有“提交审批”。这个配置放在组件同级目录的 buttonConfig.js 里。
// buttonConfig.js export const BUTTON_CONFIG = { 'DRAFT': [ { code: 'submit', label: '提交审批' } ], 'APPROVING': [ { code: 'agree', label: '同意' }, { code: 'reject', label: '驳回' }, { code: 'transfer', label: '转办' } ], 'REJECTED': [ { code: 'resubmit', label: '重新提交' }, { code: 'revoke', label: '撤回' } ] };这套配置的收益在于:新增一种状态时,不用到处找“哪个页面调了哪个按钮”,所有按钮可见性集中在同一张表里。配合前面说的状态机映射表,整个审批流最复杂的逻辑其实就两张表,一张管状态迁跃,一张管按钮渲染。
4. 前后端接口与数据表约定:落地不是把页面画出来,而是把状态存起来
4.1 审批动作统一签名:submit、agree、reject、revoke、transfer
前端资源里附带的 server/workflowRouter.js,定义了五个与 actions.js 一一对应的后端接口。接口请求体统一用一套签名格式,这是一个很重要的设计决策:审批系统最忌讳每个动作单独约定参数,光前后端对齐字段就要耗掉一整天。
// POST /api/workflow/instances/:instanceId/actions { "action": "agree", "operatorId": "u10086", "comment": "同意,转人事复核", "variables": { "vacationDays": 3 }, "nextNodeId": "hr", "assigneeId": "u10087" }字段说明:action 是动作码,必须与状态机映射表里的事件名一致;operatorId 是当前操作人;comment 是审批意见;variables 是流程变量,用于条件表达式计算;nextNodeId 用于人工指定跳转目标;assigneeId 仅在 transfer 动作时必填。
后端路由收到这个请求后,要做三件事:校验操作人是否有权执行当前动作、从数据库加载流程实例、调用与前端同一套状态机逻辑计算下一状态。前后端共用状态机映射表是可行的,把 stateMachine.js 同时放在前端和后端,后端用一个 JS 运行时执行同一份代码,可以避免“前端显示已通过、后端还停在审批中”的尴尬。
4.2 业务表单与工作流实例的关联方式
让业务表单和工作流实例关联,常见的做法是在各自的业务表里加字段互指,但这里建议只保留一条单向引用:业务表里存 processInstanceId,流程实例表里存 businessKey。理由是查询路径通常是“从业务单据进入流程”,而不是反向查。
// 提交审批时生成关联 const businessData = await saveBusinessForm(formData); const instance = await createWorkflowInstance({ processKey: 'purchase', businessKey: businessData.id, variables: { totalAmount: formData.totalAmount } }); await updateBusinessRow(businessData.id, { processInstanceId: instance.id });保存顺序有讲究:先保存业务表单,拿到业务主键;再创建流程实例,把业务主键作为 businessKey 传入;最后回填流程实例 ID 到业务表。如果先创建流程实例再保存业务表单,一旦业务保存失败,就会产生一条没有任何业务关联的孤儿实例。
这条资源里的 mock 业务模块没有包含具体业务表单,但接口封装里保留了这种调用顺序的注释说明。实际对接时,你只需要把 saveBusinessForm 换成你自己的保存逻辑。
4.3 数据表设计建议与反向对接清单
资源里附带的 init.sql 是最精简的版本,两张表:流程实例表和审批任务表。不要在这张表上强行扩展业务字段,业务字段留在你自己的业务表里,流程表只管状态。
| 表名 | 核心字段 | 说明 |
|---|---|---|
| wf_instance | id, process_key, business_key, current_node_id, status, submitter_id, created_at | 流程实例主表 |
| wf_task | id, instance_id, node_id, assignee_id, status, comment, created_at | 每个审批节点产生的任务记录 |
CREATE TABLE wf_instance ( id VARCHAR(32) PRIMARY KEY, process_key VARCHAR(64) NOT NULL, business_key VARCHAR(64) NOT NULL, current_node_id VARCHAR(32), status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', submitter_id VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE wf_task ( id VARCHAR(32) PRIMARY KEY, instance_id VARCHAR(32) NOT NULL, node_id VARCHAR(32) NOT NULL, assignee_id VARCHAR(32), status VARCHAR(20) DEFAULT 'PENDING', comment VARCHAR(500), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );状态字段的取值要与前端状态机映射表严格保持一致:DRAFT、APPROVING、APPROVED、REJECTED,再加一个 CANCELLED。我见过最典型的翻车场景是前端用 APPROVING、后端用 PENDING,两个词说的是同一个状态,但前后端对照时永远对不上。拿到这张初始化脚本后,第一件事就是把枚举值统一成一张常量表,前端后端共用。
反向对接时要注意:审批任务表里的 assignee_id 在驳回场景下会变成上一个节点的审批人,因此不要用任务表反查“谁审批过”,历史的完整记录要靠专门的历史表,或者直接用 wf_task 表本身存历史快照。
5. 避坑:状态机流转最容易踩的五个翻车现场与排查命令
5.1 翻车现场一:并发点击让同意提交了两遍
现象:用户双击“同意”按钮,浏览器发了两个请求,后端创建了两条审批记录,流程实例的当前节点直接跳过了下一级。
原因:前端按钮没有做防抖,后端也没有做幂等校验。两个请求到达时间相差几十毫秒,都查到了同一个“审批中”状态,于是都通过了状态机校验。
解决:前端在按钮点击后立即置为 loading 或 disabled;后端在请求处理入口处加一个 actionId 去重字段,前端每次点击生成一个全局唯一的 actionId,后端用 Redis 或唯一索引保存已处理过的 actionId。资源里的 historyRecorder 已经预留了记录唯一动作编号的位置,直接启用即可。
5.2 翻车现场二:驳回后重新提交,状态回不到“审批中”
现象:审批人驳回了申请,申请人点了“重新提交”,前端提示成功,但实例状态还是 REJECTED,后端查询待办列表里也查不到这条。
原因:状态机映射表里漏了 REJECTED 到 APPROVING 的转换分支。常见的写法是有 DRAFT 提交、APPROVING 同意/驳回,但没有人记得在映射表里补上“重新提交”这个事件。
解决:对照状态机映射表逐行检查——每个状态都要能回答三个问题:能从哪来、能往哪去、不能去哪。至少需要满足 DRAFT -> APPROVING -> APPROVED/REJECTED,REJECTED -> APPROVING 的闭环。补上'REJECTED:resubmit': { next: 'APPROVING', action: 'resubmit' }即可。
注意:resubmit 与 submit 虽然最终都走到 APPROVING,但业务动作不同。submit 面向首次提交,resubmit 要附加一句“已修正上次驳回问题”的说明,所以两个事件要分开保留。
5.3 翻车现场三:审批历史里时间显示对不上
现象:前端历史时间线显示的操作时间比实际时间早了 8 个小时,有的记录又显示正常。
原因:数据库存的是 UTC 时间,前端直接用 new Date() 解析并显示,没有做时区转换。因为部分服务器默认时区是 UTC,部分应用服务器是东八区,导致同一张表里的数据既有 UTC 又有本地区时。
解决:后端写入时统一使用 UTC 时间戳,前端在显示前先做一次格式化,用 dayjs 或 moment 的 utc 转本地时间。资源里的 HistoryTimeline 组件封装了一个 formatTime 函数,默认按本地时区解析,接入时确认接口返回的时间字段是 ISO 格式字符串即可。
5.4 翻车现场四:条件分支的“条件”没生效,所有人都走同一路线
现象:请假 1 天和请假 7 天都走“部门经理审批 -> 人事复核 -> 结束”,条件分支完全没生效。
原因:condition 字段的表达式键名与提交表单时的 variables 键名不一致。比如流程定义里写的是 vacationDays,前端提交时传的变量名是 days,表达式解析器查不到对应值,走到了默认分支。
解决:排查时先打印实例的 variables 和流程定义中的条件表达式,逐个比对键名。资源里 flowParser.getNextNodes 方法在条件匹配失败时会在控制台输出 warning 日志,明确提示“找不到变量 xxx”。修复方式是统一变量命名约定,或在解析表达式时对变量做一次 JSON Schema 校验。
5.5 翻车现场五:驳回人把自己也驳回了
现象:部门经理审批时点了“驳回”,结果待办列表里这个审批任务还在他自己名下,刷新后又出现一条待审批记录。
原因:驳回动作把当前节点状态改回 PENDING,但没有把 assignee 改为上一个节点的审批人,也没有更新 current_node_id 的索引位置,导致审批任务仍然挂在当前人头上。
解决:在 actions.js 的 reject 函数里,除了调用状态机迁移,还必须回写两项数据:current_node_id 指向上一个审批节点、assignee_id 改为该节点对应的审批人。建议在 reject 动作执行后,加一个断言校验:当前节点的 assignee 必须不等于操作人的上一任审批人。
5.6 一条通用排查命令:把状态流转日志打全
遇到状态相关问题,先不要猜。直接在状态机入口处加一行日志,把每次流转的完整上下文打印出来,包括实例ID、操作前状态、事件名称、操作人、操作后状态、操作时间。这一行日志能解决 80% 的流转排查问题。
function logTransition(instanceId, fromStatus, event, operatorId, toStatus) { console.info(`[WF] ${new Date().toISOString()} instance=${instanceId} ${fromStatus} --${event}--> ${toStatus} operator=${operatorId}`); }接入后,把日志输出到开发环境控制台,日常联调时保持打开。等到排查问题时,直接把日志文件拉出来按时间排序,所有状态异常点一眼可见。这比对着数据库一行行查要快得多,也是我调试这类资源时最依赖的手段。
6. 拿到资源后第一天就值得做的改造:给流程加一个调试跳过开关
流程能跑通之后,下一步你会遇到一个很实际的问题:测试一条完整审批链需要凑齐好几个账号,每级都要手动点一次,效率极低。我的做法是给这套资源加一个 debug 模式的跳过开关,只在开发环境生效。
在 actions.js 里加一个配置项和一个判断逻辑。配置项放在独立的 config.js 里,默认关闭,避免误带到生产环境。
// config.js export const DEBUG_BYPASS_ENABLED = true; // 仅本机开发环境设置为 true然后在 executeAction 的统一入口处,带上一个 bypass 分支。当实例进入审批节点时,如果 debug 开关打开,并且操作人是受信任的测试账号,就自动执行一次“同意”,并跳到下一个节点。
// 执行同意时判断是否开启调试跳过 if (DEBUG_BYPASS_ENABLED && payload.debugBypass) { // 跳过当前节点,返回下一个可审批节点 const nextNodes = flowParser.getNextNodes(instance.currentNodeId, instance.variables); payload.nextNodeId = nextNodes[0]?.id || null; }这里的逻辑是在真实 action 执行前,把当前节点的审批人替换成一个固定值,再把下一节点塞进 payload。这样做的好处是:不污染状态机映射表,不改变正常流转规则,只是把“谁点击”这个变量替换成了测试账号。实测下来,测试一条五层审批链,从原来的五分钟缩短到三十秒,省下的时间足够把全流程状态边界测一遍。
从那以后我每次拿到的流程 Demo,第一件事不是点页面,而是先翻状态机映射表,确认所有状态流转都闭环;第二件事就是装这个跳过开关。两层都过了,再开始接业务表单。这套 js 工作流资源本身已经把主干逻辑搭好了,前端的锅、后端的活、状态流转的深水区都有对应模块,唯一需要你自己动手的部分是跟业务表对接。希望这份拆解帮到你,少走我踩过的那些弯路。
本文还有配套的精品资源,点击获取