简介这是一套基于ThinkPHP6LayuiMySQL构建的开源企业办公自动化系统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不是“又一个后台模板”而是用ThinkPHP6Layui把企业真实审批流、组织架构、文档归档全跑通的最小可行办公系统你可能已经见过几十个标着“开源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只负责单表CRUDController只做参数校验和响应组装。下面拆解三个关键实践。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_types1); 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} ); } }); } }为什么不用ObserverThinkPHP6官方推荐用event()静态方法注册比Observer更轻量、调试更直观——日志里直接看到OaLeave.after_write事件触发不需追踪Observer类加载路径。3. Layui前端工程化改造从CDN直引到Webpack打包解决JS污染与模块复用难题勾股OA的Layui不是简单script srclayui.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.jsconst 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: 部门} ]] }); /script3.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/**路径放行CSRFLayui弹窗内页用$.ajaxSetup({headers: {X-Requested-With: XMLHttpRequest}})所有弹窗URL加时间戳参数防缓存/admin/dept/add?_tDate.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;当执行UPDATE oa_process_node SET statusapproved WHERE process_id123 AND node_typeleader时因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 doesnt 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 ENGINEInnoDB; -- 检查并重置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.phpconnections [ 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 闭包表存储任意两节点间的祖先-后代关系 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;插入部门时自动维护闭包关系示例新增部门A其父为BB父为C插入(A,A,0),(B,B,0),(C,C,0)自身关系插入(B,A,1),(C,A,2),(C,B,1)祖先-后代路径。查询“部门A及其所有子部门”只需一条SQLSELECT 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单条SQL12msancestordescendant联合索引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(statusapproved)散落各处。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: - TZAsia/Shanghai depends_on: - db-master - db-slave db-master: image: mysql:5.7.44 command: --server-id1 --log-binmysql-bin --binlog-formatROW environment: - MYSQL_ROOT_PASSWORD123456 - MYSQL_DATABASEgougu_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-id2 --relay-logrelay-bin --read_onlyON environment: - MYSQL_ROOT_PASSWORD123456 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-id1 log-binmysql-bin binlog-formatROW expire_logs_days7 max_binlog_size100M从库启动后执行进入db-slave容器CHANGE MASTER TO MASTER_HOSTdb-master, MASTER_USERroot, MASTER_PASSWORD123456, MASTER_PORT3306, MASTER_LOG_FILEmysql-bin.000001, MASTER_LOG_POS154; 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?keyYOUR_KEY \ -H Content-Type: application/json \ -d {msgtype: text,text: {content: MySQL主从延迟告警$SECONDS_BEHIND_MASTER秒}} fi添加到crontab*/5 * * * * /opt/gougu_oa/check-replication.sh6.3 ThinkPHP6生产环境加固清单项目配置位置必须值验证方式关闭调试模式config/app.phpdebug false访问不存在路由应返回404而非堆栈日志级别config/log.phplevel error检查runtime/log/下无debug目录CSRF防护config/middleware.phpthink\\middleware\\ValidatePost启用POST请求无__token__字段应拦截静态资源缓存.htaccessApacheExpiresActive OnExpiresByTypeChrome Network面板查看Cache-Control数据库密码加密config/database.php密码字段用env(DB_PASSWORD)确认.env文件不在Git中6.4 Layui前端资源指纹化杜绝浏览器缓存旧JSWebpack配置添加contenthashoutput: { 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标签加?vxxx }), ]生成的HTML自动注入带哈希的JSscript src/static/js/app.abc12345.js?vabc12345/script为什么不用cache-control: no-cache因为Layui组件如table.js被多个页面引用强制不缓存会导致重复下载拖慢首屏。指纹化让浏览器缓存JS更新时URL变化自然失效。我部署勾股OA时养成一个铁律每次上线前用测试账号走一遍“新增部门→添加用户→提交请假→领导审批→HR备案→查看归档”全链路卡在任何一步就回滚。不是因为代码不够稳而是业务规则永远比代码更狡猾——上周刚发现财务部要求“出差审批需关联合同编号”这种需求不会出现在任何技术文档里只会藏在行政同事随口一句“我们以前都这么填”。所以与其花三天写个完美架构不如花两小时让真实用户摸着键盘跑通第一单。希望帮到你。本文还有配套的精品资源点击获取