ThinkPHP6+Layui+MySQL开源企业办公系统实战:部署、RBAC与审批流改造

发布时间:2026/10/29 1:41:01
ThinkPHP6+Layui+MySQL开源企业办公系统实战:部署、RBAC与审批流改造

简介:基于ThinkPHP6、Layui与MySQL构建的开源企业办公系统勾股OA,面向中小企业信息化建设与PHP开发者,整合系统设置、人事管理、审批管理、日常办公、客户管理、合同管理、项目管理、财务管理等核心模块,部署即可用,模块化设计便于扩展为CRM、ERP等业务系统。资源包共1017个文件,约10.42MB,包含330个HTML页面、276个JS脚本、193个PHP文件、84个CSS样式表,搭配SQL数据库脚本、JSON配置及字体资源,前后端代码结构清晰,便于按功能模块定位与二次开发。已有526人学习下载。源码内置Layui前端组件与EditorMD在线编辑器,涵盖权限分配、审批流等典型办公场景,适合希望低成本构建OA系统或深入理解ThinkPHP6企业级项目实践的开发者参考学习。

1. 开源企业办公系统:ThinkPHP6 + Layui + MySQL 这套组合,能不能撑起小公司的日常办公

企业办公系统听起来宏大,落到实际功能上无非是审批、考勤、公告、日程、通讯录这几摊事。市面上一套基于 ThinkPHP6 + Layui + MySQL 的开源办公系统,在 Gitee 上能搜出一批,但很多人拉下来就卡在环境联调或数据库导入上,最后不了了之。这套组合的定位很明确:单机可跑、改得动、看得懂。Layui 让不专职前端的后端也能改页面,ThinkPHP6 让二次开发有清晰的目录可循,MySQL 的表结构几乎可以直接当业务流程文档读。它适合小公司内部自用、外包交付的基线项目,也适合想拿真实业务练手 PHP 的工程师。下面按我实际落地的路径讲,从选型、跑通到二次开发改审批流,坑都标出来。

2. 为什么是这套三件套:选型逻辑、最小启动命令与第一次请求链路

2.1 三件套的选型逻辑:反直觉但经得起现实检验

选技术栈不是选最强的,是选最不容易翻车的。企业办公系统本质是单据流转:谁发起、谁审批、状态怎么变、记录怎么查。服务端要处理表单、权限、状态机,前端要处理表格、表单、弹窗、菜单,数据库存组织和流程数据。这三摊事,刚好对应 ThinkPHP6、Layui、MySQL 三个人最擅长的领域。

ThinkPHP6 值得选不是因为性能极端,而是它在 PHP 社区认知成本最低。它对比 ThinkPHP5 做了一次重构,引入容器、依赖注入、中间件,路由和验证器独立成组件。一个没写过框架的 PHP 工程师,照着目录也能在半天内找到控制器和视图。相比 Laravel,ThinkPHP6 的中文文档和国内问答沉淀更厚;相比原生 PHP,它至少把 SQL 注入和模板渲染这类基础问题解决掉了,不需要从零搭轮子。

Layui 是另一个反直觉的选择:现在前端都在聊 Vue、React,但 Layui 是模块化 jQuery 组件库,自带 table、form、layer、element 这些后台管理高频组件。开源办公系统用 Layui 的核心优势是不需要 Node 构建链,静态资源丢到 public 目录就能跑,后端改页面直接改 HTML 和少量 JS,这决定了二次开发成本低。如果团队没有专职前端,Layui 比 Vue 全家桶实际得多。

MySQL 不用多讲,办公系统的数据量级在百万行内,MySQL 的事务、行锁、索引完全够用,部署和备份也最简单。补一句:在 Gitee 上搜开源企业办公系统时,注意看一眼仓库的 LICENSE。办公系统这类内部业务代码,常见做法是 MIT 或 Apache-2.0,MIT 更宽松,Apache-2.0 对专利授权和署名约束更多。仓库没写许可证的,商用前必须问作者,这是开源项目管理里最容易忽略的把柄。

2.2 把项目拉下来跑通:环境准备与最小启动命令

我一般按这个顺序准备环境。先说版本边界:ThinkPHP6.0 要求 PHP 7.2.5 以上,6.1 分支要 PHP 8.0+;MySQL 5.7 或 8.0 都行;Layui 是纯静态资源,不参与版本约束。先检查 PHP 扩展:

php -v php -m | grep -E 'pdo_mysql|mbstring|openssl|fileinfo'

没带 fileinfo 的话,Composer 安装依赖会直接失败。Windows 用户在 php.ini 里把extension=fileinfo注释去掉;Linux 用户按发行版装对应的 php-fileinfo 包。这一步漏了,后面 composer install 会报一长串错,属于那种"看着是依赖问题,其实是扩展问题"的典型。

接着配 Composer 镜像,国内环境必须做这一步,不然依赖下载能等到下班:

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

然后克隆、装依赖、配环境变量:

git clone <你在 Gitee 上选定的仓库地址> oa cd oa composer install cp .env.example .env

.env里改数据库配置。ThinkPHP6 沿用DB_前缀的约定:

DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=oa_system DB_USER=root DB_PASS=你的密码 DB_PREFIX=sys_

DB_PREFIX很关键:很多开源项目表是带前缀的,比如 sys_user、oa_leave。如果你用Db::name('user'),实际查的是sys_user;前缀配错,整个系统白屏,这个先记下来。

建库和导表:

mysql -uroot -p CREATE DATABASE IF NOT EXISTS oa_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; exit; mysql -uroot -p oa_system < database/oa.sql

用 utf8mb4 而不是 utf8,是因为审批意见、公告正文里出现 emoji 时,utf8 存不下直接报错。然后启动本地开发服务器:

php think run

默认监听127.0.0.1:8000,浏览器打开就能看到登录页。这一步如果白屏,优先看 public 目录的入口文件 index.php 是否存在,以及 PHP 扩展是否完整。生产环境则要把站点根目录指向 public,而不是项目根目录,否则会暴露框架内部文件。

2.3 第一次请求走的链路:从 URL 到 Layui 页面

登录页是第一个要读懂的请求。ThinkPHP6 默认单入口在 public/index.php,路由文件在 route/app.php。以登录路由为例:

Route::get('login', 'admin/Login/index');

浏览器访问/login时,Nginx 把请求交给 public/index.php,框架启动容器,路由解析到 admin 应用的 Login 控制器 index 方法,也就是 app/admin/controller/Login.php。控制器加载 app/admin/view/login/index.html 视图,视图里引用 public/static/layui 下的静态资源。

这个链路有两个实操含义。第一,改页面样式不碰 PHP,直接改视图和 Layui 静态文件;第二,所有跳转和接口请求都走路由,不要去改入口文件。TP6 的目录结构长这样:

app/admin/controller/ 后台控制器 app/admin/model/ 数据模型 app/admin/view/ 后台模板 app/common/ 公共基类 config/ 配置文件 route/ 路由文件 public/ 入口和静态资源

把请求链路和目录结构记在脑子里,后面改任何功能都能三分钟内定位。第一次登录如果卡在验证码,八成是 GD 扩展没装,或者验证码扩展没安装,这个后面避坑章还会提。

3. 数据库设计与权限模型:从 RBAC 五张表到审批、考勤的表结构落地

3.1 用户-角色-菜单:用 RBAC 搭出最小权限系统

企业办公系统第一个被问的问题是:为什么他能看到审批按钮我看不到?这不只是 UI 问题,是权限。常见做法是 RBAC(基于角色的访问控制):角色挂菜单权限,用户挂角色。最小实现是五张表:用户、角色、菜单、用户角色关联、角色菜单关联。

用户表设计:

CREATE TABLE `sys_user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(255) NOT NULL COMMENT '密码哈希', `real_name` varchar(50) NOT NULL DEFAULT '' COMMENT '姓名', `dept_id` int(11) NOT NULL DEFAULT '0' COMMENT '部门ID', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `create_time` int(11) NOT NULL DEFAULT '0' COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';

密码字段别用定长的 md5 设计,ThinkPHP6 里常用 password_hash 生成,哈希串长度 60 位以上,所以 varchar 给到 255。status 做逻辑禁用而不是删行,能保留操作日志里的关联。create_time 用 int 时间戳而不是 datetime,排序列查询更快,在 PHP 侧格式化也更直接。

角色和菜单两张表相对简单:

CREATE TABLE `sys_role` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(30) NOT NULL COMMENT '角色名', `remark` varchar(255) NOT NULL DEFAULT '', PRIMARY KEY (`id`) ) ENGINE=InnoDB; CREATE TABLE `sys_menu` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `pid` int(11) NOT NULL DEFAULT '0' COMMENT '父菜单ID', `name` varchar(30) NOT NULL COMMENT '菜单名', `url` varchar(100) NOT NULL DEFAULT '' COMMENT '路由地址', `sort` int(11) NOT NULL DEFAULT '0', `status` tinyint(1) NOT NULL DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB;

pid 字段用来做两级或三级菜单。两张关联表用联合主键防止重复绑定:

CREATE TABLE `sys_user_role` ( `user_id` int(11) NOT NULL, `role_id` int(11) NOT NULL, PRIMARY KEY (`user_id`,`role_id`) ) ENGINE=InnoDB COMMENT='用户-角色关联'; CREATE TABLE `sys_role_menu` ( `role_id` int(11) NOT NULL, `menu_id` int(11) NOT NULL, PRIMARY KEY (`role_id`,`menu_id`) ) ENGINE=InnoDB COMMENT='角色-菜单关联';

用户和角色默认多对多:一个人既能是部门主管也能是项目管理员。如果当初只做一个 role_id 字段挂在用户表上,后面加兼职角色就要改表,那是给自己挖坑。

在 ThinkPHP6 里捞用户可见菜单的写法:

use think\facade\Db; $menus = Db::name('menu') ->alias('m') ->join('sys_role_menu rm', 'm.id = rm.menu_id') ->join('sys_user_role ur', 'rm.role_id = ur.role_id') ->where('ur.user_id', $userId) ->where('m.status', 1) ->order('m.sort', 'asc') ->select() ->toArray();

说明:Db::name('menu')配合DB_PREFIX=sys_实际查的是 sys_menu;join 里写全表名,是防止两张关联表前缀不一致时反而混乱。order 按 sort 排序是后台菜单最常见的 mysql 排序场景,菜单顺序不对,先查 sort 字段的值是不是有序递增,别急着怀疑代码。

3.2 审批单与考勤打卡:两张核心业务表怎么建模

权限是骨架,审批和考勤是血肉。请假审批单是最典型的业务表,几乎所有办公系统的流程都能从这张表变形出来:

CREATE TABLE `oa_leave` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '申请人ID', `leave_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1事假 2病假 3年假', `start_time` int(11) NOT NULL COMMENT '开始时间戳', `end_time` int(11) NOT NULL COMMENT '结束时间戳', `reason` varchar(255) NOT NULL DEFAULT '' COMMENT '请假原因', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待审 1通过 2驳回 3撤销', `auditor_id` int(11) NOT NULL DEFAULT '0' COMMENT '审批人ID', `audit_time` int(11) NOT NULL DEFAULT '0' COMMENT '审批时间戳', `create_time` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='请假审批表';

设计要点:状态机只用 status 一个字段表达,0/1/2/3 互斥,比多个布尔字段表达状态可读性好得多。业务时间用 int 时间戳,跨时区、排序、加减天数都不吃亏;datetime 在框架里要反复格式化,经过 JSON 返回前端时还容易丢格式。索引 idx_user_status 覆盖"我的待办列表"和"我的历史记录"两类高频查询,办公系统数据量五年也难超百万行,这个索引足够。

考勤打卡表要处理"一天两条记录"的约束,直接用唯一索引挡重复打卡:

CREATE TABLE `oa_sign` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `sign_date` date NOT NULL COMMENT '打卡日期', `punch_in` int(11) NOT NULL DEFAULT '0' COMMENT '上班打卡时间戳', `punch_out` int(11) NOT NULL DEFAULT '0' COMMENT '下班打卡时间戳', `lat` decimal(10,6) DEFAULT NULL COMMENT '纬度', `lng` decimal(10,6) DEFAULT NULL COMMENT '经度', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_date` (`user_id`, `sign_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤打卡表';

sign_date 用 date 类型只存日期,punch_in/out 用时间戳精确到秒。位置打卡的经纬度用 decimal(10,6) 而不是 float,float 在边界值会丢精度,这个写在表结构里以后少踩坑。

3.3 MySQL 部署相关:5.7 和 8.0 怎么选,事务与锁要注意什么

开发机装 MySQL,搜"mysql安装配置教程"能看到一堆翻车贴,核心矛盾在版本。Windows 上 5.7.44 是 5.7 分支的收尾版本,行为收敛、兼容老项目;8.0 默认字符集就是 utf8mb4,性能更好,但默认身份认证插件是 caching_sha2_password,老版本 PHP 的 pdo_mysql 连不上,报错通常是Authentication plugin cannot be loaded,解决方法是把账户改回 mysql_native_password:

ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';

Linux 服务器上常见的是 rpm 安装 MySQL,或者用 Docker 起镜像。用 Docker 跑 MySQL 后中文乱码,十有八九是容器默认字符集不是 utf8mb4,启动时带参数:

docker run -d --name mysql8 \ -e MYSQL_ROOT_PASSWORD=root \ -v /data/mysql:/var/lib/mysql \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_general_ci

办公系统里事务的典型场景是审批状态流转:用户点"通过"的一瞬间,要同时改审批单状态、写操作日志、可能还要给下一节点的人生成待办。三步里任何一步失败,整个事务回滚,不能出现"状态变了但日志没记"的半成品。ThinkPHP6 里写法:

use think\facade\Db; Db::transaction(function () use ($leaveId, $auditorId) { $result = Db::name('leave') ->where('id', $leaveId) ->where('status', 0) ->update([ 'status' => 1, 'auditor_id' => $auditorId, 'audit_time' => time(), ]); if (!$result) { throw new \Exception('审批单状态已变更,请刷新再试'); } Db::name('log')->insert([ 'leave_id' => $leaveId, 'action' => 'approve', 'create_time' => time(), ]); });

这里where('status', 0)做条件更新,天然规避并发下两次点击都通过的问题,比先查再改更稳。至于 MySQL 锁的分类,表锁、行锁、间隙锁,办公系统这个量级基本只需在意行锁;update 的 where 条件没走索引时,行锁会升级成表锁,批量审批被拖慢,先查 where 条件的索引。

4. Layui 与 ThinkPHP6 的接口协作:菜单渲染、数据表格与表单提交的标准写法

4.1 后台布局:iframe 方式与菜单渲染

几乎所有开源办公系统的后台模板都是 Layui 的 iframe 布局:左侧菜单、顶部标签、右侧 iframe。打开后台首页后,前端会请求一个菜单接口,再动态生成左侧导航。菜单渲染的常见写法是 jQuery 拼 HTML:

// public/static/admin/js/menu.js layui.use(['jquery', 'element'], function () { var $ = layui.jquery; $.get('/admin/menu', function (res) { var html = ''; res.data.forEach(function (menu) { if (menu.pid === 0) { html += '<li class="layui-nav-item">'; html += '<a href="javascript:;" lay-href="' + menu.url + '">'; html += '<i class="layui-icon ' + menu.icon + '"></i>' + menu.name; html += '</a></li>'; } }); $('#sideMenu').html(html); }); });

说明:lay-href 是 Layui 的 element 模块识别 iframe 地址的约定属性,不能写成普通 href,否则不会在内容区打开。菜单接口返回的 data 就是上一章那张 sys_menu 表的数据,字段名要前后端对得上。

后端控制器输出:

namespace app\admin\controller; use think\facade\Db; class Menu { public function index() { $menus = Db::name('menu') ->order('sort', 'asc') ->select() ->toArray(); return json(['code' => 0, 'msg' => 'ok', 'data' => $menus]); } }

这里返回格式里的code、msg、data是 Layui 组件的统一约定,后续所有接口都按这个结构返回,前端解析就统一。

4.2 数据表格:Layui table 与 ThinkPHP6 的分页约定

列表页是办公系统的主战场。Layui table 组件向服务端发请求时,默认带 page 和 limit 参数。TP6 控制器里解析这两个参数并返回分页数据:

public function list() { $page = (int) $this->request->param('page', 1); $limit = (int) $this->request->param('limit', 10); $total = Db::name('leave') ->where('user_id', session('user_id')) ->count(); $list = Db::name('leave') ->where('user_id', session('user_id')) ->page($page, $limit) ->order('create_time', 'desc') ->select() ->toArray(); return json([ 'code' => 0, 'msg' => 'ok', 'count' => $total, 'data' => $list, ]); }

这个返回结构是 Layui table 写死的:code 必须为 0 表示成功,count 是总数,data 是当前页数据。新手最容易翻车的是把 count 写成count($list),也就是当前页条数,导致分页条永远只显示一页。前端表格初始化:

table.render({ elem: '#leaveTable', url: '/admin/leave/list', page: true, cols: [[ { field: 'id', title: 'ID', width: 60 }, { field: 'leave_type', title: '假别', width: 100 }, { field: 'create_time', title: '申请时间', templet: '#timeTpl' } ]] });

前端不用自己传页码,table 组件会自动带上 page 和 limit,后端只管按这两个参数取数。

4.3 表单提交与令牌:把审批单安全地提交到后端

表单页在 Layui 里用 form 模块监听提交:

<form class="layui-form" lay-filter="leaveForm"> <div class="layui-form-item"> <label class="layui-form-label">请假类型</label> <div class="layui-input-block"> <select name="leave_type"> <option value="1">事假</option> <option value="2">病假</option> <option value="3">年假</option> </select> </div> </div> <div class="layui-form-item"> <div class="layui-input-block"> <button type="button" lay-submit lay-filter="leaveSubmit">提交申请</button> </div> </div> </form>
form.on('submit(leaveSubmit)', function (data) { $.post('/admin/leave/save', data.field, function (res) { if (res.code === 0) { layer.msg('提交成功', { icon: 1 }); } else { layer.msg(res.msg, { icon: 2 }); } }); return false; });

后端 save 里至少做三件事:登录态检查、参数合法性校验、状态机初始值写入。TP6 可以用验证器,也可以手动校验。办公系统字段不算多,我一般手动校验,减少样板代码:

public function save() { if (!$this->request->isPost()) { return json(['code' => 1, 'msg' => '请求方式错误']); } $data = $this->request->post(); if (empty($data['leave_type']) || empty($data['start_time'])) { return json(['code' => 1, 'msg' => '必填项缺失']); } $data['user_id'] = session('user_id'); $data['status'] = 0; $data['create_time'] = time(); Db::name('leave')->insert($data); return json(['code' => 0, 'msg' => '提交成功']); }

登录态用session('user_id'),前提是控制器继承的基类里已经登录用户 id 写进 session。这种方式简单直接,适合后台管理系统;接口要对外暴露才需要换 JWT 这类无状态方案,办公系统大多数项目没必要。

5. 部署避坑:MySQL 安装配置、伪静态、Session 等 5 个高频问题怎么排查

5.1 MySQL 起不来:Windows 和 Docker 两种情况

现象:Windows 上按教程装 MySQL 后服务启动失败,错误日志提示 1067;或者 Docker 里 mysql 容器起来了,业务库表导入后中文乱码。

原因:1067 大多是 my.ini 里 basedir 或 datadir 路径写错,或者 data 目录权限不足;乱码则是容器初始化时没有指定 utf8mb4 字符集。

解决:my.ini 里路径用绝对路径,别放在带空格的 Program Files 目录下,推荐C:/mysql/data;Docker 启动时显式传--character-set-server=utf8mb4。Linux 下用 rpm 方式安装 MySQL 时,注意 mysql-server 和 mysql-client 要分开装,否则会出现 socket 连不上的假故障。

5.2 依赖装不上,或慢到怀疑人生

现象:composer install卡在某个包上半小时不动,或者直接报扩展缺失、内存不足。

原因:Composer 默认源在国外;fileinfo 扩展没开;PHP 的 memory_limit 设置过低。

解决:先配阿里云 Composer 镜像,再检查php -m输出。fileinfo 缺失就装 php-fileinfo;内存临时调高可以这样:

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ php -d memory_limit=512M composer install

5.3 点开菜单就 404:伪静态没配

现象:访问http://ip/首页正常,点击菜单后 404,URL 里带 index.php 反而能开。

原因:ThinkPHP6 的 URL 美化依赖伪静态规则,服务器没配 rewrite。

解决:Nginx 在 server 块里加:

location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }

Apache 则在 public/.htaccess 里确认有这段:

Options -MultiViews RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.*)$ index.php?/$1 [QSA,PT,L]

这个配置是 ThinkPHP 系列的老规矩,换域名后 404 先查它,别急着改代码。

5.4 登录成功马上跳回登录页,或验证码图片不显示

现象:本地开发服务器能登录,跳转后立刻回登录页;或验证码位置空白。

原因:Session 没写进去,或验证码扩展缺失。ThinkPHP6 的验证码是独立扩展 topthink/think-captcha,很多开源项目没有默认装。

解决:安装验证码扩展并确认 GD 扩展开启:

composer require topthink/think-captcha

Session 失效则检查 config/session.php 里的 name、expire 配置,以及是否在带 www 和不带 www 两个域名之间跳转。Cookie 跨了域名,session 天然读不到,表现为"登录了又没登录"。

5.5 改完代码不生效:生产模式缓存

现象:本地改控制器、改模板立即生效,线上怎么刷新都不变。

原因:ThinkPHP6 在部署模式下开启了模板缓存或路由缓存,没有执行清缓存操作。

解决:改动后执行清缓存命令;开发期可以临时关闭 view 缓存:

php think clear
// config/view.php 'cache' => false, // 开发期临时关闭,生产环境建议改回 true

常见做法是发布脚本里在 git pull 之后自动执行一次php think clear,顺手解决这类"线上不更新"的玄学问题。

6. 二次开发进阶:把单级审批升级成多级审批流,以及怎么验证

6.1 从单级到多级:只加两个字段

实际交付里被要求最多的改动,就是把"提交人申请、管理员直接通过"改成"主管审批、总经理终审"的多级流程。最省事的做法不是重做审批表,而是加两个字段:

ALTER TABLE `oa_leave` ADD COLUMN `flow_id` int(11) NOT NULL DEFAULT '0' COMMENT '流程模板ID'; ALTER TABLE `oa_leave` ADD COLUMN `current_step` tinyint(1) NOT NULL DEFAULT '1' COMMENT '当前审批层级';

再建一张流程模板表存层级定义,比如oa_flow_node字段含 flow_id、step、auditor_role_id。审批通过的逻辑从"直接改状态"变成"按层级推进":

if ($leave['current_step'] < $flow['total_steps']) { // 不是最后一级,只推进层级,状态仍是待审 Db::name('leave')->where('id', $leave['id']) ->update(['current_step' => $leave['current_step'] + 1]); } else { // 最后一级通过,才置为最终通过 Db::name('leave')->where('id', $leave['id']) ->update(['status' => 1]); }

6.2 验证方法:用三个账号走完整条链路

多级审批改完,验证不能只点一次通过。我的习惯是建三个账号:张三发请假,李四部门主管,王五总经理。先让李四通过,这时张三端看到的应该是"审批中",且层级变成 2;再让王五通过,状态才变成已通过。中间任何一步驳回,张三端都要能看到驳回原因。核心是确认状态值在中间态停得住,而不是被单级逻辑直接冲到终态。

做第一个办公项目时,我图省事没拆层级,客户后来加多级审批需求时翻车,一个人改表加逻辑花了一整晚。所以我的建议是:初始设计审批单时,就把"当前层级"和"最终状态"拆成两个字段,哪怕第一版只需要一级审批,这个字段也先留着。这是整个系统里性价比最高的一次设计。希望帮到你。

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