从状态机到幂等:轻量级JS审批流引擎设计与实现

发布时间:2026/10/29 23:14:01
从状态机到幂等:轻量级JS审批流引擎设计与实现

简介:面向前端与业务系统开发者的JavaScript工作流与审批流示例代码包,聚焦流程建模、任务分配、审批界面交互、状态持久化与异常回退等常见问题,适合具备一定JavaScript基础、希望理解轻量级审批流程实现的学习者。压缩包共49个文件,以js脚本、html页面、xml流程定义和aspx/cs后端代码为主,辅以css样式、gif/png图示及少量数据库与配置文件,整体约69KB。已有1290人学习下载。读者可结合页面脚本与流程配置,观察提交、审核、批准或拒绝等节点如何由状态变迁驱动,并参考角色权限控制、Ajax数据同步、审计日志与流程回退思路,用于课程设计、内部工具原型验证或二次开发。

1. 审批流并不是只画一张流程图就结束:三个真实卡点先摆出来

一个请假单要走三级审批,需求方说“画个流程图就行”,真做起来才发现难的不是画图:退回之后谁知道该回到哪一节点、会签三个人里两个同意算不算通过、上月发起的流程遇到改版是按旧规则还是新规则继续执行。这套 js 工作流 / 审批流方案把这些问题统一放进一个可以复用的轻量引擎里——流程定义用 JSON 描述,流转规则由服务端统一裁决,前端只负责渲染表单和展示审批轨迹。它适合两类人:正在做审批类毕设或课程设计、需要一套讲得清也跑得动的结构;以及要给内部管理系统补上审批能力、又不想引入重型引擎的开发者。下面的模块划分、表结构和状态机可以照搬,改判断条件就能用在自己的项目里。

2. 需求拆解与技术选型:为什么最后没用现成的 BPMN 引擎

2.1 把「审批」拆成四类动作,需求才立得住

不少方案失败在需求阶段:只写了“支持多级审批”,没写清楚“谁能在什么时候做什么”。我一般先把用户操作收敛成四类动作,再往下延伸,需求边界一下就清晰了。

动作触发者影响范围必须写入数据库的副作用
发起申请人首节点审批人创建实例、创建任务、写一条记录
处置审批人当前节点关闭任务、判定节点是否终结、写记录
流转审批人 / 管理员节点或人员插入任务、修改任务归属、写记录
终止申请人 / 定时任务整个实例关闭实例、关闭未办任务、写记录

四类动作之外,还有一批“看起来是细节、其实是需求”的点,必须在设计前定死,否则写到一半返工:审批人怎么算(指定人、角色、发起人自选、部门负责人、上级的上级);会签的通过阈值是全员同意还是过半同意;一人驳回是整节点否决还是只否决自己那一票;退回是退回上一节点、退回发起人还是退回任意历史节点;退回后原审批记录保留还是清空;超时是自动通过、自动提醒还是转交。这六条每一条都会改动表结构或状态机,越晚确认代价越大。

我通常会把上面这些整理成一张“动作 × 规则”的确认表,让需求方逐条勾选,勾完再动手建表。这样做还有个额外好处:答辩时这张表本身就是需求分析的证据,比一句“实现了多级审批”有说服力得多。

2.2 引擎选型的三种方案与取舍

能走的路其实只有三条,各有各的代价。

方案优点代价适用场景
引入开源 BPMN 引擎规范完整,支持并行网关、子流程Node 生态可选少,多数要跨语言部署;配置复杂,出问题难定位流程极其复杂、有多系统集成诉求
用低代码平台的审批组件上手快,表单和流程可视化现成逻辑不掌握在自己手里,定制判断条件受限,迁移成本高内部轻量场景、追求交付速度
自研轻量引擎全栈同语言,每一行都能讲清,改判断条件只是改配置复杂网关、子流程要自己补审批层级 ≤ 5 级、以串行和会签为主

我选第三条,理由很实际:审批场景里 80% 的需求是“串行节点 + 会签 + 条件分支 + 退回”,并行网关和子流程属于长尾;而全栈统一在 JavaScript 上,前端渲染表单、后端算审批人、调试时同一套断点,联调成本低一大截。代价也要认:不做跨流程调用,不做动态子流程,不做图形化拖拽建模(用 JSON 手写定义 + 一个只读的流程图预览就够了)。

自研最容易被低估的是“能力边界”这件事。开工前我会明确写死一份不做清单:不支持并行网关、不支持运行时改流程结构、不支持一个实例跨两个流程定义。写在文档里,后面谁提需求都能指着这行字拒绝,比事后扯皮省力。

2.3 前后端职责边界:谁算下一节点

边界划错是另一个常见翻车点。有的实现把“下一个审批人是谁”放在前端算,前端传nextApprover给后端,结果后端变成了单纯的存储层——只要改一下请求体,就能把审批单直接送到自己账号上。审批人计算、条件判定、节点完成判定,这三件事必须在服务端,前端只做三件事:渲染表单、发请求、展示轨迹。

接口按这个边界设计,一共五个就够用:

方法路径作用关键参数
POST/api/flow/start发起流程defKey、bizId、formData
GET/api/flow/todo我的待办page、size
POST/api/flow/task/:id/approve审批动作action、comment、requestId
POST/api/flow/instance/:id/revoke撤销reason
GET/api/flow/instance/:id/timeline审批轨迹无

requestId这个字段从第一天就加上,别等上线后出现重复审批再补。它在前端由一次点击生成、在服务端落唯一索引,是幂等的唯一抓手,后面第 5 章会详细说它挡住了什么。

3. 数据模型与状态机:五张表撑起一条可回放的审批链

3.1 流程定义与实例的版本隔离

流程定义是会变的,实例是不能变的。这两件事必须分表,而且实例要把“我按哪一版定义在跑”记下来,否则改一次流程,所有在途单据的轨迹全乱。

-- 流程定义:同一个 def_key 允许多版本共存,只有 PUBLISHED 的能发起新实例 CREATE TABLE flow_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, def_key VARCHAR(64) NOT NULL COMMENT '流程标识,如 leave_apply', name VARCHAR(128) NOT NULL, version INT NOT NULL DEFAULT 1, schema_json JSON NOT NULL COMMENT '节点、连线、审批人策略、条件', status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PUBLISHED/ARCHIVED', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_key_version (def_key, version) ); -- 流程实例:def_version 是快照锚点,运行时永远按这个版本取定义 CREATE TABLE flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, def_key VARCHAR(64) NOT NULL, def_version INT NOT NULL, biz_id VARCHAR(64) NOT NULL COMMENT '业务单据 ID,用于回查业务数据', starter_id VARCHAR(64) NOT NULL, state VARCHAR(16) NOT NULL DEFAULT 'RUNNING', round INT NOT NULL DEFAULT 1 COMMENT '轮次,退回重提时 +1', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_biz (def_key, biz_id) );

schema_json存整份定义而不拆成节点表和连线表,是权衡后的选择:查询永远按整份取,拆表反而多两次 join;流程定义体量小(几十 KB 以内),放 JSON 字段完全够用。round这个字段现在看起来多余,等到实现“退回后重新提交、历史记录要保留”时,它是唯一能把两轮审批区分开的东西。

3.2 任务表与审批记录表的分工

这两张表性质完全不同:任务表存“还没发生的事”,会被修改;记录表存“已经发生的事”,只追加。混在一起写,就会出现在办任务被 update 覆盖、审批历史查不出来的问题。

-- 待办任务:只关心"当前该谁办",办完就置为终态,不复用 CREATE TABLE flow_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, round INT NOT NULL DEFAULT 1, assignee_id VARCHAR(64) NOT NULL, task_type VARCHAR(16) NOT NULL DEFAULT 'NORMAL' COMMENT 'NORMAL/ADD_SIGN', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/DONE/CANCELED', version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_assignee (assignee_id, status), KEY idx_instance (instance_id, node_key, round) ); -- 审批记录:append-only,不提供任何 UPDATE 入口 CREATE TABLE flow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, task_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, round INT NOT NULL, handler_id VARCHAR(64) NOT NULL, action VARCHAR(16) NOT NULL COMMENT 'APPROVE/REJECT/RETURN/ADD_SIGN/REVOKE', comment VARCHAR(512) DEFAULT NULL, request_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (task_id, request_id) );

uk_request这个唯一索引就是幂等的最后一道保险。前端一次点击只生成一个requestId,即使请求被重发三次,第二、三次也会直接撞唯一键失败,业务层捕获后返回“已处理”,不会多出一条记录、也不会多推进一次流程。

审批轨迹的渲染完全基于flow_record+round:按instance_id查出全部记录,按round分组,组内按时间排序,前端就能画出“第 1 轮:张三同意 → 李四退回;第 2 轮:张三同意 → 李四同意 → 王五同意”这样的时间线。这也是为什么记录表绝对不能更新——它承担的是审计职责。

3.3 状态机与合法迁移

状态越多,越不能靠散落在各处的 if 判断。我的做法是把合法迁移写成一张白名单表,任何状态变更先过校验,非法迁移直接抛错。

// 实例状态机:只有白名单内的迁移才放行,避免出现「已撤销还能审批」这类脏数据 const INSTANCE_TRANSITIONS = { RUNNING: ['APPROVED', 'REJECTED', 'REVOKED', 'TERMINATED'], REJECTED: ['RUNNING'], // 退回后重新提交,回到 RUNNING,同时 round + 1 APPROVED: [], REVOKED: [], TERMINATED: [] }; function assertTransition(from, to) { const allowed = INSTANCE_TRANSITIONS[from] || []; if (!allowed.includes(to)) { const err = new Error(`非法状态迁移: ${from} -> ${to}`); err.code = 'ILLEGAL_TRANSITION'; err.status = 400; // 交给统一错误中间件转成 HTTP 400 throw err; } }

参数说明:from是当前状态,必须从数据库里读出来的值,不能信前端传的;to是本次操作期望到达的状态;err.status用来让上层中间件区分“业务规则拒绝”(400)和“系统异常”(500)。用白名单而不是 if-else 的好处是可枚举、可测试——写个单测把 5 个状态两两组合跑一遍,25 条断言,5 分钟就能覆盖完,比人工点击可靠得多。

3.4 流程定义的 JSON 结构

定义体我只保留四类信息:节点、连线、审批人策略、条件。网关不单独建模,条件直接挂在连线上,这样一份定义用眼睛就能读完。

{ "key": "leave_apply", "version": 3, "nodes": [ { "key": "start", "type": "START", "name": "发起" }, { "key": "leader", "type": "APPROVE", "name": "直属主管", "approveType": "ANY", "assignee": { "type": "DEPT_LEADER" } }, { "key": "countersign", "type": "APPROVE", "name": "跨部门会签", "approveType": "ALL", "passRatio": 1, "assignee": { "type": "ROLE", "value": "HRBP" } }, { "key": "end", "type": "END", "name": "结束" } ], "edges": [ { "from": "start", "to": "leader" }, { "from": "leader", "to": "countersign", "condition": "days > 3" }, { "from": "leader", "to": "end", "condition": "days <= 3" }, { "from": "countersign", "to": "end" } ] }

approveType取ANY(或签,一人同意即过)、ALL(会签,全员同意)、SEQ(依次审批,按 assignee 列表顺序逐个生成任务)。passRatio留了扩展位:设成 0.5 就是过半通过,答辩时被问“能不能半数通过”不用改结构,改值就行。condition是一段受控表达式,下一章讲它怎么安全求值。

4. 核心引擎实现:从当前节点算出下一批审批人

4.1 节点流转:一次审批请求的完整执行链

所有审批动作走同一个入口函数,事务包住“关任务 → 写记录 → 判节点 → 找下一节点 → 建任务 → 改实例”六步。任何一步失败整体回滚,不留下“任务关了但记录没写”的中间态。

async function completeTask({ taskId, userId, action, comment, requestId }) { return db.transaction(async (tx) => { // 1) 用状态条件做占位式更新,affectedRows 为 0 说明任务已被别人处理 const [r] = await tx.query( "UPDATE flow_task SET status='DONE', version=version+1 " + "WHERE id=? AND status='PENDING'", [taskId] ); if (r.affectedRows === 0) throw new BizError('TASK_HANDLED', '该任务已被处理'); const task = await tx.one('SELECT * FROM flow_task WHERE id=?', [taskId]); // 2) 写审批记录,只追加;request_id 唯一索引挡住重复提交 await tx.query( "INSERT INTO flow_record(instance_id,task_id,node_key,round,handler_id,action,comment,request_id) " + "VALUES (?,?,?,?,?,?,?,?)", [task.instance_id, taskId, task.node_key, task.round, userId, action, comment, requestId] ); // 3) 判定当前节点是否整体完成 const nodeState = await judgeNode(tx, task.instance_id, task.node_key, task.round); if (!nodeState.done) return { instanceId: task.instance_id, nodeFinished: false }; // 4) 驳回:按节点配置决定退回发起人还是退回上一节点 if (nodeState.rejected) return rollbackTo(tx, task, userId); // 5) 找下一节点;没有下一节点说明流程走完 const next = await findNextNode(tx, task.instance_id, task.node_key); if (!next) return finishInstance(tx, task.instance_id, 'APPROVED'); // 6) 按审批人策略批量生成待办 return spawnTasks(tx, task.instance_id, next); }); }

第 1 步的WHERE id=? AND status='PENDING'是整个函数的关键:把“判断任务是否待办”和“把任务置为已办”合并成一条原子语句,并发下只有一个请求能让affectedRows等于 1,另一个直接走异常分支。这比先 select 再判断再 update 少一个竞态窗口,也不用额外加分布式锁。

第 3 步的judgeNode只看当前node_key和round下的全部任务,不看历史轮次,这是round字段存在的意义。第 5 步找下一节点时要重新读一遍定义里的edges,因为分支条件可能依赖本次审批填写的表单变量,这一条链路必须能拿到最新的流程变量。

4.2 会签、或签、依次审批的判定差异

三种审批类型的差别全在“节点什么时候算结束”这一件事上,写成一个纯函数就够了,便于单测。

// 节点完成判定:返回 done(是否终结)、rejected(是否否决)、passed(是否通过) function judgeNodeByType(node, tasks) { const done = tasks.filter(t => t.status === 'DONE'); const rejected = done.filter(t => t.action === 'REJECT'); switch (node.approveType) { case 'ANY': // 或签:一人处理完即终结,同意即通过 return { done: done.length > 0, rejected: rejected.length > 0, passed: done.some(t => t.action === 'APPROVE') }; case 'ALL': { // 会签:可配一票否决,否则按 passRatio 算通过率 if (node.rejectStrategy === 'ONE_VETO' && rejected.length > 0) return { done: true, rejected: true, passed: false }; const ratio = done.filter(t => t.action === 'APPROVE').length / tasks.length; const all = done.length === tasks.length; if (!all && ratio < (node.passRatio ?? 1)) return { done: false }; return { done: true, rejected: false, passed: ratio >= (node.passRatio ?? 1) }; } case 'SEQ': // 依次审批:只有当前这个人处理完才生成下一个人 return { done: done.length > 0, rejected: rejected.length > 0, passed: done.some(t => t.action === 'APPROVE') }; default: throw new Error(`未知审批类型: ${node.approveType}`); } }

参数与配置说明:

字段取值含义
approveTypeANY / ALL / SEQ或签、会签、依次审批
passRatio0~1,默认 1会签通过阈值,0.5 即过半
rejectStrategyONE_VETO / NORMAL会签中一人否决是否直接终止节点
assignee.typeUSER / ROLE / DEPT_LEADER / STARTER_CHOOSE审批人来源

SEQ类型的实现要特别注意:它的任务不是一次性全部生成,而是生成第一条,处理完再生成下一条。很多实现图省事,一次性把 5 个人都插进任务表,结果第一个人还没批,后面四个人在待办里都看到了这条单据,依次审批就退化成了会签。判断方式是看flow_task里同一node_key + round的PENDING记录数,SEQ类型永远应当只有 1 条。

4.3 条件分支的表达式求值

分支条件写在 JSON 里,本质是配置数据,绝不能拿去eval或new Function——那等于给配置开了一个执行任意代码的口子。我写了一个只认比较运算和逻辑运算的小求值器,够用且可控。

const OPS = { '>': (a, b) => a > b, '<': (a, b) => a < b, '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, '==': (a, b) => a === b, '!=': (a, b) => a !== b }; // 只支持 A && B || C 这种两层结构,括号内可继续递归 function evalCondition(expr, vars) { return expr.split('||').some(part => part.split('&&').every(atom => evalAtom(atom.trim(), vars)) ); } function evalAtom(atom, vars) { if (atom.startsWith('(') && atom.endsWith(')')) { return evalCondition(atom.slice(1, -1), vars); // 去掉外层括号递归 } const m = atom.match(/^([\w.]+)\s*(>=|<=|==|!=|>|<)\s*(.+)$/); if (!m) throw new Error(`条件表达式不合法: ${atom}`); const left = getByPath(vars, m[1]); // 支持 form.days 这类取值 const right = parseLiteral(m[3].trim()); // 'RD' 转字符串,5000 转数字 return OPS[m[2]](left, right); }

三个要注意的地方。getByPath要支持form.days这种点路径,但路径必须白名单校验,不允许出现__proto__、constructor之类;parseLiteral要区分引号包裹的字符串和裸数字,否则dept == '1'和dept == 1会得到相反结果,这类问题排查起来非常费时;这个求值器不支持跨层括号嵌套((A || B) && C里的括号跨了&&),遇到复杂条件建议在流程定义里拆成两条连线,而不是把表达式写复杂。

调用位置在findNextNode里:取出当前节点的所有出边,对带condition的边求值,取第一条为真的;多条同时为真时按定义顺序取第一条并打一条告警日志,方便发现配置歧义。

4.4 退回、撤销与加签

这三个动作最容易写漏边界。退回要决定“退到哪”,撤销要判断“还能不能撤”,加签要区分“前加签”和“后加签”。

// 退回:默认退回上一已审批节点,也支持退发起人、退指定节点 async function rollbackTo(tx, task, userId) { const node = await getNodeDef(tx, task.instance_id, task.node_key); const target = node.returnTo || 'PREV'; // 1) 关闭当前节点剩余待办,不能留着,否则还会有人继续批 await tx.query( "UPDATE flow_task SET status='CANCELED' WHERE instance_id=? AND round=? AND status='PENDING'", [task.instance_id, task.round] ); // 2) 实例回到 RUNNING,轮次 +1,历史记录全部保留 await tx.query( "UPDATE flow_instance SET state='RUNNING', round=round+1 WHERE id=?", [task.instance_id] ); // 3) 按 target 决定新任务落在哪个节点:PREV 取上一节点,STARTER 取 start const nodeKey = target === 'STARTER' ? 'start' : await prevNodeKey(tx, task); return spawnTasks(tx, task.instance_id, { nodeKey, isReturn: true }); }

加签我一般做成“插一条task_type='ADD_SIGN'的任务,node_key与当前节点相同”,这样会签判定里它天然算一票,不用额外改判定逻辑。前加签插在当前任务之前、当前任务置为等待;后加签直接插一条新任务,当前任务正常结束。撤销的判断条件是实例状态为RUNNING且第一节点尚未有人审批——只要有一个人批过,撤销按钮就该禁用,这是业务共识,也是防止“批到一半被撤单”的必备规则。

5. 常见翻车点排查:五条最容易在上线后集中暴露的问题

5.1 与数据一致性有关的三条

一、两个标签页同时点同意,流程被推进两次。现象是审批轨迹里同一节点出现两条完全相同的同意记录,或者流程直接跳过了下一节点。原因很典型:先SELECT出任务判断status='PENDING',再UPDATE,两步之间存在时间窗口,两个请求都能通过判断。解决办法是把判断塞进UPDATE的WHERE里,用affectedRows做裁决,也就是 4.1 里那种写法:

-- 正确姿势:状态判断和数据修改在同一条语句里完成 UPDATE flow_task SET status='DONE', version=version+1 WHERE id=? AND status='PENDING'; -- affectedRows = 0 直接抛「该任务已被处理」,不要再往下走

二、网络超时用户重复点击,记录表多出一条。现象是用户说“我就点了一次”,但记录表里两条一模一样的记录,节点判定被多算了一票。原因是前端按钮没有防抖、接口没有幂等键,服务端把两次请求当成两次独立操作。解决分两层:前端点击后立即置灰并生成requestId,重试复用同一个 id;服务端在flow_record上建(task_id, request_id)唯一索引,插入冲突时捕获ER_DUP_ENTRY直接返回成功结果,而不是报错给用户。

三、会签最后一票并发,节点状态卡在中间。现象是一个三人会签的节点,三个人的任务都是DONE,但实例还停在RUNNING,谁也没收到结束通知。原因是三个请求同时进入事务,各自判定“还有别人没批”,于是谁都没有触发节点终结。解决办法是把节点完成判定放到事务内、并对实例行加锁(SELECT ... FROM flow_instance WHERE id=? FOR UPDATE),保证同一实例的判定串行执行;代价是并发度降低,但审批场景下这个代价完全可以接受。

5.2 与流程演进有关的两条

四、改完流程定义,在途单据的轨迹对不上。现象是测试环境好好的,上线后改了一版流程,已经发起的单据突然跳到了新节点,或者报“节点不存在”。原因很简单:实例没有绑定版本,运行时每次都去取最新的 PUBLISHED 定义。解决方式就是 3.1 里的def_version,实例创建时把版本号写死,之后所有流转都按def_key + def_version取定义。这样改版只影响新发起的单据,在途单据按旧规则跑完,历史轨迹永远自洽。

五、退回重提后审批历史被覆盖。现象是退了再提,时间线上只剩最后一轮,前面谁批过、谁退的、什么意见,全没了。原因是图省事复用了原来的任务行,重新UPDATE成PENDING。解决办法有两条铁律:任务行绝不复用,退回时全部置CANCELED,重提时插入全新任务;记录表只追加不修改,靠round区分轮次。查轨迹时按round分组展示,两轮审批并列显示,这比只显示最新一轮更有说服力,答辩演示时也更直观。

6. 验收与答辩:用一张流程用例表把系统讲透

功能点逐条点一遍不叫验收,那叫演示。我给这套引擎准备的验收材料是一张用例表,7 条覆盖全部关键路径和异常路径,每跑通一条就打钩,出问题直接定位到模块。

编号用例关键验证点对应模块
C1单级审批全通过实例终态为 APPROVED,轨迹 2 条状态机
C2金额 > 5000 走总监分支条件求值命中正确出边表达式求值
C3三人会签两人同意一人驳回节点判定与rejectStrategy一致会签判定
C4退回后重提round变为 2,第一轮记录完整保留退回逻辑
C5撤销已有人审批的单据接口返回 400,实例状态不变状态机、权限
C6双击审批按钮只有一条记录,affectedRows只成功一次幂等
C7改版后在途实例继续跑按旧版本定义流转,不报节点不存在版本隔离

答辩时的讲述顺序我建议反过来:先讲状态机图,再讲表结构,最后演用例。因为评委最先质疑的永远是“状态会不会乱”,你把白名单式的迁移表往那一放,这个问题基本就结束了;然后是“数据丢了怎么办”,flow_record的 append-only 加round分组能直接回答;最后演示 C4 和 C6 这两个异常用例,比演示十个正常流程更能说明工程成熟度。

还有一个小技巧:把flow_record的时间线页面做成答辩 PPT 的第一张图,一条单据从发起到三级审批、中途退回一次再重提,整个链路一屏看完,比任何架构描述都省口舌。

这套方案我自己复盘过好几轮,最早那版没有round、也没有request_id,上线两周就被并发和退回两个问题教育了一遍,加班改表结构的时候是真的后悔。从那以后我每次接审批相关的活儿,都强制先把那张七行的用例表写出来再动手建表,宁愿前期多花半天。希望帮到你。

本文还有配套的精品资源,点击获取