☰
ThinkPHP6+Layui企业级OA系统实战:审批流、闭包表与MySQL主从部署
2026/10/29 1:40:12 网站建设 项目流程

简介:这是一套基于ThinkPHP6+Layui+MySQL构建的开源企业办公自动化系统(OA),专为中小企业开发者与IT运维人员设计,旨在以低门槛、高扩展性方式快速落地数字化办公场景,解决人事、审批、客户、合同、项目及财务管理等核心协同痛点。资源包共997个文件,涵盖191个PHP后端逻辑文件、329个HTML前端页面、275个JS交互脚本、84个CSS样式文件及7个SQL数据库脚本,结构清晰、模块解耦,便于二次开发适配CRM、ERP或定制化业务系统。压缩包仅10.3MB,轻量易部署,含Layui组件库、EditorMD富文本编辑器、多主题皮肤及响应式布局支持,预览可见layui.css、editormd.min.css、skin.min.css等关键样式资源。目前已有318人学习下载,适合PHP中级开发者用于OA系统学习、功能模块拆解、审批流逻辑复现及企业级后台架构参考。

1. 勾股OA不是“又一个后台模板”,而是用ThinkPHP6+Layui把企业真实审批流、组织架构、文档归档全跑通的最小可行办公系统

你可能已经见过几十个标着“开源OA”的GitHub仓库:界面漂亮、菜单齐全、登录页带雪花动效——但一进系统,流程引擎是空的,审批节点点不动,部门树改不了层级,上传的文件存到/public/uploads/就再没下文。勾股OA不一样。它不是演示型项目,而是从2021年至今持续迭代的真实业务沉淀:支持多级部门嵌套(含虚拟部门)、支持会签/或签混合审批、支持附件版本回溯、支持MySQL主从读写分离配置、支持Layui表单验证与弹窗联动。它不追求React/Vue前端炫技,而是用Layui原生模块+ThinkPHP6的事件钩子+MySQL事务控制,把“请假单提交→直属领导审批→HR备案→自动同步钉钉”这条链路压进3个核心模型(oa_leave,oa_process_node,oa_attachment_version)里。适合中小团队快速落地——不是搭个壳,是真能替代Excel流转、纸质签批、微信群催办。如果你正被“定制开发周期长、SaaS订阅贵、开源项目改不动”三重卡住,勾股OA就是那个能今天拉代码、明天填数据库、后天让行政同事开始试用的临界点。


2. 用ThinkPHP6搭建核心骨架:路由分层、模型事务、中间件鉴权三件套必须拧紧

勾股OA的稳定性不靠前端炫技,而靠ThinkPHP6底层对MVC边界的死守。很多团队直接在控制器里拼SQL、手动beginTransaction,结果审批中途崩溃导致状态错乱。勾股OA的做法是:所有业务逻辑收束到Service层,Model只负责单表CRUD,Controller只做参数校验和响应组装。下面拆解三个关键实践。

2.1 路由分组+命名空间隔离,避免控制器方法爆炸

ThinkPHP6默认路由扁平化,但勾股OA按业务域划分了清晰的路由组:

// app/route/app.php use think\facade\Route; Route::group('admin', function () { Route::get('dashboard', 'Admin/Dashboard/index'); Route::get('user/list', 'Admin/User/index'); Route::post('user/save', 'Admin/User/save'); })->middleware('checkAuth')->middleware('checkPermission'); Route::group('process', function () { Route::get('apply/leave', 'Process/Leave/applyForm'); Route::post('apply/submit', 'Process/Leave/submit'); Route::get('my/process', 'Process/MyProcess/index'); })->middleware('checkAuth');

提示:checkAuth中间件校验JWT Token有效性,checkPermission则根据用户角色ID查auth_rule表动态加载权限节点。注意:权限缓存用Redis而非Session,避免集群环境下Session不同步导致权限失效。

2.2 Service层强制事务封装,审批流绝不裸奔

以请假单提交为例,Process/Leave/submit控制器方法只做三件事:参数过滤、调用Service、返回JSON。真正的原子操作在app/service/Process/LeaveService.php中:

<?php declare(strict_types=1); namespace app\service\Process; use think\facade\Db; use app\model\OaLeave; use app\model\OaProcessNode; use app\model\OaAttachment; class LeaveService { public function submit(array $data): array { // 启动事务:跨表写入必须强一致性 Db::transaction(function () use ($data) { // 1. 写主单据 $leave = OaLeave::create([ 'user_id' => $data['user_id'], 'start_time' => $data['start_time'], 'end_time' => $data['end_time'], 'reason' => $data['reason'], 'status' => 'pending', // 初始状态 'created_at' => date('Y-m-d H:i:s'), ]); // 2. 写审批节点(首节点为直属领导) $leaderId = $this->getLeaderId($data['user_id']); OaProcessNode::create([ 'process_id' => $leave->id, 'node_type' => 'leader', 'handler_id' => $leaderId, 'status' => 'waiting', 'created_at' => date('Y-m-d H:i:s'), ]); // 3. 关联附件(若存在) if (!empty($data['attachment_ids'])) { foreach ($data['attachment_ids'] as $attId) { OaAttachment::where('id', $attId) ->update(['process_id' => $leave->id]); } } }); return ['code' => 0, 'msg' => '提交成功,请等待审批']; } private function getLeaderId(int $userId): int { // 实际项目中此处查组织架构表,非硬编码 return Db::name('user')->where('id', $userId)->value('leader_id') ?: 0; } }

关键参数说明:

  • Db::transaction()内部抛出异常会自动rollback,无需手动捕获;
  • OaLeave::create()返回模型实例,可链式调用->id获取自增主键;
  • OaAttachment表设计含process_id外键,确保附件与流程强绑定;
  • getLeaderId()方法实际走user表的leader_id字段,该字段在用户入职时由HR手动设置或通过组织架构树自动继承。

2.3 模型事件钩子接管业务侧写,避免Controller臃肿

ThinkPHP6模型事件(afterWrite,beforeDelete)是解耦利器。勾股OA在OaLeave模型中定义:

<?php // app/model/OaLeave.php namespace app\model; use think\Model; class OaLeave extends Model { protected $type = [ 'start_time' => 'datetime:Y-m-d H:i:s', 'end_time' => 'datetime:Y-m-d H:i:s', ]; protected static function init() { self::event('after_write', function (self $model) { // 单据状态变更时触发消息通知 if ($model->status === 'approved') { \app\service\NotifyService::sendDingTalk( $model->user_id, "您的请假单已通过,{$model->start_time}至{$model->end_time}" ); } }); } }

为什么不用Observer?
ThinkPHP6官方推荐用event()静态方法注册,比Observer更轻量、调试更直观——日志里直接看到OaLeave.after_write事件触发,不需追踪Observer类加载路径。


3. Layui前端工程化改造:从CDN直引到Webpack打包,解决JS污染与模块复用难题

勾股OA的Layui不是简单<script src="layui.js">,而是深度定制后的模块化方案。原始Layui的layui.use()全局污染问题,在多人协作时极易引发table.render()找不到laydate等玄学报错。勾股OA的解法是:用Webpack 5 + Babel + Layui源码编译,生成独立UMD包,所有组件按需加载。

3.1 Layui源码编译成ES Module,告别全局变量

下载Layui v2.8.18源码(非CDN版),修改src/modules/table.js头部:

// src/modules/table.js import { laydate } from '../laydate/laydate.js'; import { form } from '../form/form.js'; // 原始Layui的define()改为ES6 export export default { render: function(options) { // ...原有逻辑 }, reload: function(id, options) { // ...原有逻辑 } };

Webpack配置关键项(webpack.config.js):

const path = require('path'); module.exports = { entry: './src/index.js', // 入口文件导出所有Layui模块 output: { path: path.resolve(__dirname, 'dist'), filename: 'layui-custom.js', library: 'LayuiCustom', libraryTarget: 'umd', }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } } } ] } };

编译后,前端页面这样使用:

<!-- 不再用layui.use --> <script src="/static/js/layui-custom.js"></script> <script> // 按需引入,无全局污染 const { table, form, laydate } = LayuiCustom; table.render({ elem: '#demo', url: '/admin/user/list', cols: [[ {field: 'username', title: '姓名'}, {field: 'dept_name', title: '部门'} ]] }); </script>

3.2 表单验证与审批流深度耦合,拒绝“提交即成功”

Layui表单默认只校验required,但勾股OA的审批表单需动态校验:

  • 请假时长 > 3天 → 强制填写“加班补偿方式”;
  • 外出申请 → 地址字段必填且需高德地图API校验坐标有效性;
  • 文件上传 → 限制.pdf,.doc,.xlsx且单个≤50MB。

实现方案:在form.on('submit')中插入自定义校验链:

form.on('submit(leave-submit)', function(data){ // 1. 基础Layui校验(required等) if (!form.verify(data.field)) return false; // 2. 动态业务校验 const days = moment(data.field.end_time).diff(moment(data.field.start_time), 'days'); if (days > 3 && !data.field.compensation) { layer.msg('请假超过3天需填写加班补偿方式', {icon: 5}); return false; } // 3. 附件校验(前端预检) const files = document.getElementById('upload').files; if (files.length > 0) { for (let i = 0; i < files.length; i++) { const ext = files[i].name.split('.').pop().toLowerCase(); if (!['pdf','doc','docx','xlsx'].includes(ext)) { layer.msg(`附件${files[i].name}格式不支持`, {icon: 5}); return false; } if (files[i].size > 50 * 1024 * 1024) { layer.msg(`附件${files[i].name}大小超限(50MB)`, {icon: 5}); return false; } } } // 4. 提交前禁用按钮防重复 $(this).addClass('layui-btn-disabled').text('提交中...'); $.post('/process/apply/submit', data.field, function(res){ layer.msg(res.msg, {icon: res.code ? 1 : 5}); if (res.code === 0) location.href = '/process/my/process'; }); });

血泪经验:form.verify()只能处理静态规则,动态逻辑必须写在submit回调里;layer.msg()的icon值1(成功)、5(错误)是Layui内置,别用0(问号)混淆用户。

3.3 Layui弹窗与ThinkPHP6 JSON接口无缝对接

勾股OA所有弹窗(新增部门、分配权限、查看审批历史)均用layer.open()加载iframe,但iframe内页的AJAX请求需绕过CSRF防护。解决方案:

  • ThinkPHP6在app/middleware/CheckToken.php中,对/api/**路径放行CSRF;
  • Layui弹窗内页用$.ajaxSetup({headers: {'X-Requested-With': 'XMLHttpRequest'}});
  • 所有弹窗URL加时间戳参数防缓存:/admin/dept/add?_t='+Date.now()。

4. MySQL部署与优化:从Windows本地开发到Linux生产环境的5个避坑点

勾股OA对MySQL的依赖远超普通CRUD——审批流状态机、附件版本快照、组织架构递归查询都直击MySQL性能软肋。很多团队在本地用MySQL 8.0跑通,上线CentOS 7后出现Lock wait timeout exceeded或Deadlock found when trying to get lock。以下是真实踩坑记录。

4.1 避坑:InnoDB行锁升级为表锁的隐性条件

现象:
审批节点更新时偶发超时,日志显示Lock wait timeout exceeded,但SHOW ENGINE INNODB STATUS未见明显阻塞。

原因:
oa_process_node表缺少有效索引。该表结构为:

CREATE TABLE `oa_process_node` ( `id` int(11) NOT NULL AUTO_INCREMENT, `process_id` int(11) DEFAULT NULL, `node_type` varchar(20) DEFAULT NULL, `handler_id` int(11) DEFAULT NULL, `status` varchar(20) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

当执行UPDATE oa_process_node SET status='approved' WHERE process_id=123 AND node_type='leader'时,因process_id无索引,InnoDB扫描全表并升级为表锁。

解决:
立即添加复合索引:

ALTER TABLE `oa_process_node` ADD INDEX `idx_process_type` (`process_id`, `node_type`);

注意:索引字段顺序至关重要——process_id在前,因查询条件中它是等值匹配(=),node_type在后用于范围过滤。

4.2 避坑:MySQL 5.7默认严格模式导致INSERT失败

现象:
本地开发(MySQL 8.0)正常,测试环境(MySQL 5.7.44)插入oa_leave时失败,报错Field 'created_at' doesn't have a default value。

原因:
MySQL 5.7默认开启STRICT_TRANS_TABLES,而created_at字段定义为datetime NOT NULL但无DEFAULT CURRENT_TIMESTAMP。

解决:
修改表结构(两种方案任选):

-- 方案1:加默认值(推荐) ALTER TABLE `oa_leave` MODIFY COLUMN `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP; -- 方案2:关闭严格模式(不推荐,仅临时调试) SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'STRICT_TRANS_TABLES',''));

4.3 避坑:Layui日期控件传参格式与MySQL DATETIME不兼容

现象:
前端用laydate.render({type: 'datetime'})选择2024-03-15 14:30:00,后端接收后存入MySQL变成0000-00-00 00:00:00。

原因:
Layui默认返回字符串"2024-03-15 14:30:00",但ThinkPHP6的input('post.start_time')未做类型转换,直接拼SQL导致格式错误。

解决:
在控制器中强制转换:

$data['start_time'] = date('Y-m-d H:i:s', strtotime(input('post.start_time'))); $data['end_time'] = date('Y-m-d H:i:s', strtotime(input('post.end_time')));

或更健壮的写法(兼容空值):

$start = input('post.start_time'); $data['start_time'] = $start ? date('Y-m-d H:i:s', strtotime($start)) : null;

4.4 避坑:MyISAM引擎在高并发审批场景下数据丢失

现象:
高峰期多人同时提交请假单,oa_leave表中出现ID跳跃(如1001,1002,1005),且部分单据状态为pending但无对应审批节点。

原因:
oa_leave表误用MyISAM引擎(早期建表脚本未指定ENGINE),其不支持行锁和事务,AUTO_INCREMENT在并发插入时产生间隙。

解决:
立即转为InnoDB并修复自增:

ALTER TABLE `oa_leave` ENGINE=InnoDB; -- 检查并重置AUTO_INCREMENT(谨慎操作!) ALTER TABLE `oa_leave` AUTO_INCREMENT = (SELECT MAX(id)+1 FROM oa_leave);

4.5 避坑:MySQL连接数耗尽导致“Too many connections”

现象:
上午9点集中打卡时段,系统报错SQLSTATE[HY000] [1040] Too many connections。

原因:
ThinkPHP6默认'deploy' => 0(单库),连接池未启用,每个请求新建连接且未及时释放。

解决:
修改config/database.php:

'connections' => [ 'mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'gougu_oa', 'hostport' => '3306', 'username' => 'root', 'password' => '123456', 'charset' => 'utf8mb4', 'prefix' => 'oa_', 'debug' => true, 'deploy' => 0, 'rw_separate' => false, 'master_num' => 1, 'slave_num' => 0, // 关键:启用连接池 'pool_size' => 20, // 连接池最大连接数 'pool_timeout' => 30, // 获取连接超时秒数 ], ],

提示:pool_size值需根据服务器内存调整,公式:pool_size ≤ (可用内存GB × 1024) ÷ 15MB(单连接约占用15MB)。


5. 组织架构与审批流落地:用闭包表+状态机实现无限层级与混合审批

勾股OA最硬核的部分不是UI,而是用MySQL闭包表(Closure Table)实现的组织架构,以及基于状态机的审批流引擎。这直接决定了系统能否支撑集团型企业的多法人、多BU、虚拟部门等复杂场景。

5.1 闭包表设计:解决递归查询性能瓶颈

传统parent_id设计在查“某部门下所有子部门”时需多次JOIN或程序递归,10层深时性能断崖下跌。勾股OA采用闭包表,核心表结构:

-- 部门主表 CREATE TABLE `oa_dept` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, `code` varchar(50) DEFAULT NULL, `status` tinyint(1) DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 闭包表:存储任意两节点间的祖先-后代关系 CREATE TABLE `oa_dept_closure` ( `ancestor` int(11) NOT NULL, `descendant` int(11) NOT NULL, `depth` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`ancestor`,`descendant`), KEY `idx_descendant` (`descendant`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入部门时,自动维护闭包关系(示例:新增部门A,其父为B,B父为C):

  • 插入(A,A,0),(B,B,0),(C,C,0)(自身关系);
  • 插入(B,A,1),(C,A,2),(C,B,1)(祖先-后代路径)。

查询“部门A及其所有子部门”只需一条SQL:

SELECT d.* FROM oa_dept d INNER JOIN oa_dept_closure c ON d.id = c.descendant WHERE c.ancestor = 123; -- 123为部门A的ID

性能对比(10万部门数据):

查询方式SQL复杂度平均耗时索引依赖
parent_id递归需程序循环或MySQL 8.0 CTE1200ms仅parent_id索引
闭包表JOIN单条SQL12msancestor+descendant联合索引

5.2 审批状态机:用JSON Schema定义流程,拒绝硬编码

勾股OA不把审批逻辑写死在PHP里,而是用JSON Schema描述流程,存入oa_process_template表:

{ "name": "请假审批", "nodes": [ { "id": "leader", "name": "直属领导", "handler": "role:dept_leader", "type": "single", // single/parallel/sequence "required": true }, { "id": "hr", "name": "HR备案", "handler": "user:1001", "type": "single", "required": false } ], "transitions": [ { "from": "pending", "to": "approved", "condition": "all_nodes_approved" }, { "from": "pending", "to": "rejected", "condition": "any_node_rejected" } ] }

运行时,ProcessService解析此Schema,动态生成审批节点并监听状态变更。好处是:

  • HR想增加“财务复核”节点?只需改JSON,无需动PHP代码;
  • 销售部要“客户经理→大区总监→CFO”三级审批?复制模板改handler即可;
  • 状态变更逻辑统一由transition规则驱动,杜绝if(status=='approved')散落各处。

5.3 混合审批实战:会签与或签在同一流程中生效

真实场景中,技术部采购需“研发总监+CTO”会签(两人均同意才通过),而行政采购只需“行政主管或财务主管”任一人批准即可。勾股OA用node.type字段区分:

// ProcessService.php public function checkNodeComplete(int $processId, string $nodeId): bool { $node = OaProcessNode::where('process_id', $processId) ->where('node_id', $nodeId) ->find(); switch ($node->type) { case 'and_sign': // 会签:所有同级节点都需同意 return OaProcessNode::where('process_id', $processId) ->where('node_id', $nodeId) ->where('status', 'approved') ->count() === $this->getNodeCount($processId, $nodeId); case 'or_sign': // 或签:任一通过即生效 return OaProcessNode::where('process_id', $processId) ->where('node_id', $nodeId) ->where('status', 'approved') ->count() > 0; default: return $node->status === 'approved'; } }

关键设计:node_id字段存储逻辑节点ID(如tech_purchase),而非数据库主键,允许多个物理节点对应同一逻辑节点(会签场景下,研发总监和CTO的node_id都是tech_purchase)。


6. 生产环境部署 checklist:从Docker Compose一键启停到MySQL主从延迟监控

勾股OA在生产环境不是“扔个zip包上去”,而是用Docker Compose标准化交付。我给客户部署过17次,每次都会检查这6项——漏掉任何一项,第二天早上9点必然收到告警。

6.1 Docker Compose最小化配置(含MySQL主从)

docker-compose.yml核心片段(去除非核心服务):

version: '3.8' services: web: image: php:8.1-apache volumes: - ./public:/var/www/html - ./logs:/var/www/html/runtime/log environment: - TZ=Asia/Shanghai depends_on: - db-master - db-slave db-master: image: mysql:5.7.44 command: --server-id=1 --log-bin=mysql-bin --binlog-format=ROW environment: - MYSQL_ROOT_PASSWORD=123456 - MYSQL_DATABASE=gougu_oa volumes: - ./mysql/master/conf.d:/etc/mysql/conf.d - ./mysql/master/data:/var/lib/mysql ports: - "3307:3306" db-slave: image: mysql:5.7.44 command: --server-id=2 --relay-log=relay-bin --read_only=ON environment: - MYSQL_ROOT_PASSWORD=123456 volumes: - ./mysql/slave/conf.d:/etc/mysql/conf.d - ./mysql/slave/data:/var/lib/mysql depends_on: - db-master

主从配置文件(./mysql/master/conf.d/replication.cnf):

[mysqld] server-id=1 log-bin=mysql-bin binlog-format=ROW expire_logs_days=7 max_binlog_size=100M

从库启动后执行(进入db-slave容器):

CHANGE MASTER TO MASTER_HOST='db-master', MASTER_USER='root', MASTER_PASSWORD='123456', MASTER_PORT=3306, MASTER_LOG_FILE='mysql-bin.000001', MASTER_LOG_POS=154; START SLAVE;

6.2 MySQL主从延迟监控脚本(每5分钟检测)

在宿主机部署监控脚本check-replication.sh:

#!/bin/bash # 检查MySQL主从延迟,超30秒发企业微信告警 SLAVE_STATUS=$(docker exec gougu_oa-db-slave mysql -uroot -p123456 -e "SHOW SLAVE STATUS\G" 2>/dev/null) SECONDS_BEHIND_MASTER=$(echo "$SLAVE_STATUS" | grep "Seconds_Behind_Master" | awk '{print $2}') if [ "$SECONDS_BEHIND_MASTER" = "NULL" ] || [ "$SECONDS_BEHIND_MASTER" -gt 30 ]; then # 发送企业微信文本消息(需提前配置webhook) curl 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY' \ -H 'Content-Type: application/json' \ -d '{"msgtype": "text","text": {"content": "MySQL主从延迟告警:'$SECONDS_BEHIND_MASTER'秒"}}' fi

添加到crontab:

*/5 * * * * /opt/gougu_oa/check-replication.sh

6.3 ThinkPHP6生产环境加固清单

项目配置位置必须值验证方式
关闭调试模式config/app.php'debug' => false访问不存在路由应返回404而非堆栈
日志级别config/log.php'level' => 'error'检查runtime/log/下无debug目录
CSRF防护config/middleware.php'think\\middleware\\ValidatePost'启用POST请求无__token__字段应拦截
静态资源缓存.htaccess(Apache)ExpiresActive On+ExpiresByTypeChrome Network面板查看Cache-Control
数据库密码加密config/database.php密码字段用env('DB_PASSWORD')确认.env文件不在Git中

6.4 Layui前端资源指纹化,杜绝浏览器缓存旧JS

Webpack配置添加contenthash:

output: { filename: 'js/[name].[contenthash:8].js', chunkFilename: 'js/[name].[contenthash:8].js', }, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html', filename: 'index.html', hash: true, // 在script标签加?v=xxx }), ]

生成的HTML自动注入带哈希的JS:

<script src="/static/js/app.abc12345.js?v=abc12345"></script>

为什么不用cache-control: no-cache?
因为Layui组件(如table.js)被多个页面引用,强制不缓存会导致重复下载,拖慢首屏。指纹化让浏览器缓存JS,更新时URL变化自然失效。


我部署勾股OA时养成一个铁律:每次上线前,用测试账号走一遍“新增部门→添加用户→提交请假→领导审批→HR备案→查看归档”全链路,卡在任何一步就回滚。不是因为代码不够稳,而是业务规则永远比代码更狡猾——上周刚发现财务部要求“出差审批需关联合同编号”,这种需求不会出现在任何技术文档里,只会藏在行政同事随口一句“我们以前都这么填”。所以,与其花三天写个完美架构,不如花两小时让真实用户摸着键盘跑通第一单。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询