简介:这是一套面向中小企业开发者与信息化管理人员的开源企业办公自动化系统源码,基于ThinkPHP6、Layui与MySQL构建,集成系统设置、人事管理、消息管理、审批管理、日常办公、客户管理、合同管理、项目管理、财务管理等核心模块。系统结构简约、易于功能扩展,方便二次开发,可在此基础上延伸出CRM、ERP及各类业务管理系统,帮助团队快速搭建企业级OA平台,降低数字化办公成本。资源包共1017个文件,约10.42MB,以html页面模板、js脚本、php后端逻辑与css样式为主,另含sql建表脚本、字体图标、图片素材及项目说明文档,前后端与数据库结构完整。目前已有526人学习下载,适合希望研究企业级应用架构、进行OA二次开发或搭建内部办公系统的开发者参考,可借此理解模块划分、权限与审批流程设计,并作为业务系统快速起步的工程基础。
1. 为什么很多团队用 ThinkPHP6 + Layui + MySQL 重写办公系统后,反而比买现成 SaaS 更省心
不少中小团队都经历过这样的场景:用着某款在线协作工具,审批流想加一个“金额超过 5000 元自动转财务总监”的节点,结果发现要么加钱升级套餐,要么根本改不了。数据存在别人服务器上,导出还要申请权限。这时候,一套能自己部署、自己改代码的开源企业办公系统就成了刚需。而 ThinkPHP6 + Layui + MySQL 这个组合,恰恰是国内中小团队落地这类系统时最稳、最省人力的一条路。ThinkPHP6 负责后端路由、ORM 和权限控制,Layui 负责后台界面快速搭建,MySQL 存组织架构、审批流和考勤数据。这套方案不追求技术炫技,追求的是“三天能跑起来,一周能改出自己想要的审批节点”。适合谁?适合有一到两名 PHP 开发、想拥有自己数据主权的团队。下面我把这套系统从环境搭建到审批流改造的完整路径拆开讲,中间会重点说清楚参数怎么配、坑在哪。
2. 环境搭建与项目初始化:从零把骨架跑起来
2.1 为什么选 ThinkPHP6 而不是 ThinkPHP5 或 Laravel
ThinkPHP6 相比 TP5 最大的变化是全面拥抱 Composer 和 PSR 标准,中间件机制更清晰,对于办公系统里常见的“登录态校验 + 权限节点检查 + 操作日志记录”这三层拦截,用中间件串起来非常顺手。Laravel 当然更强,但学习曲线和服务器要求都更高,中小团队维护成本划不来。MySQL 选 5.7 或 8.0 都行,5.7 在低配服务器上更稳,8.0 的窗口函数对考勤统计类查询有帮助。我一般会建议:如果服务器内存小于 2G,用 MySQL 5.7;否则直接上 8.0。
2.2 用 Composer 创建项目并配置多应用模式
办公系统通常分“后台管理”和“员工自助”两个入口,ThinkPHP6 的多应用模式正好匹配。先执行创建命令:
# 创建 ThinkPHP6 项目,项目名按自己习惯来 composer create-project topthink/think office-system # 进入目录 cd office-system # 安装多应用模式扩展 composer require topthink/think-multi-app安装完成后,在app目录下建立admin和home两个应用目录。admin放后台管理控制器,home放员工端控制器。多应用模式下,访问/admin/index/index就会自动路由到app/admin/controller/Index.php的index方法。这里有个容易翻车的点:多应用模式安装后必须删除原来的app/controller目录,否则路由会冲突,报“控制器不存在”的错。
2.3 数据库连接与 .env 配置的四个关键参数
ThinkPHP6 推荐用.env文件管理数据库配置,不要硬编码在config/database.php里。在项目根目录新建.env:
APP_DEBUG = true [DATABASE] TYPE = mysql HOSTNAME = 127.0.0.1 DATABASE = office_system USERNAME = office_user PASSWORD = your_strong_password HOSTPORT = 3306 CHARSET = utf8mb4 DEBUG = true [LANG] default_lang = zh-cn四个关键参数说明:CHARSET必须用utf8mb4,否则员工姓名里的生僻字会变成问号;DEBUG在开发阶段设为true,上线后必须改false,否则 SQL 报错会直接暴露表结构;HOSTNAME如果 MySQL 和 PHP 不在同一台机器,要改成内网 IP;PASSWORD不要用 root 密码,单独建一个只对office_system库有权限的账号。建库语句如下:
CREATE DATABASE office_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'office_user'@'%' IDENTIFIED BY 'your_strong_password'; GRANT ALL PRIVILEGES ON office_system.* TO 'office_user'@'%'; FLUSH PRIVILEGES;2.4 引入 Layui 并搭建第一个后台页面
Layui 不需要 Composer 安装,直接下载后放到public/static/layui目录即可。然后在app/admin/view/index/index.html里引入:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>办公系统后台</title> <link rel="stylesheet" href="/static/layui/css/layui.css"> </head> <body> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo">企业办公系统</div> </div> <div class="layui-side layui-bg-black"> <ul class="layui-nav layui-nav-tree"> <li class="layui-nav-item"><a href="/admin/approval/list">审批管理</a></li> <li class="layui-nav-item"><a href="/admin/attendance/list">考勤管理</a></li> </ul> </div> <div class="layui-body"> <iframe src="/admin/welcome" frameborder="0" style="width:100%;height:100%;"></iframe> </div> </div> <script src="/static/layui/layui.js"></script> <script> layui.use(['element'], function(){ var element = layui.element; }); </script> </body> </html>这段代码搭出了经典的后台三栏布局:顶部标题、左侧菜单、右侧 iframe 内容区。Layui 的element模块负责菜单折叠和导航高亮。注意 iframe 的src指向/admin/welcome,这个路由需要在app/admin/controller/Welcome.php里实现一个空方法返回欢迎页。很多新手会忘记注册这个路由,导致右侧一直白屏。
3. 审批流与权限模型:办公系统最核心的两块硬骨头
3.1 审批流的数据库表设计:三张表搞定多级审批
办公系统里最复杂的就是审批流。我见过有人用一张表存所有审批记录,结果查询“当前待我审批”时 SQL 写得像天书。稳妥的做法是三张表:approval_flow存流程模板,approval_node存节点定义,approval_record存实际审批记录。建表 SQL:
CREATE TABLE `approval_flow` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '流程名称', `type` varchar(32) NOT NULL COMMENT '流程类型:leave/expense/purchase', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `approval_node` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `flow_id` int unsigned NOT NULL, `sort` int NOT NULL DEFAULT '0' COMMENT '节点顺序', `approver_type` tinyint NOT NULL COMMENT '1指定人 2角色 3直属上级', `approver_value` varchar(128) NOT NULL COMMENT '用户ID或角色ID', PRIMARY KEY (`id`), KEY `flow_id` (`flow_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `approval_record` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `flow_id` int unsigned NOT NULL, `applicant_id` int unsigned NOT NULL, `current_node` int NOT NULL DEFAULT '0' COMMENT '当前节点sort值', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待审 1通过 2驳回', `data` json DEFAULT NULL COMMENT '表单数据', `create_time` int unsigned NOT NULL, PRIMARY KEY (`id`), KEY `applicant_id` (`applicant_id`), KEY `status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;approval_node里的approver_type是关键设计:指定人适合财务审批,角色适合部门经理审批,直属上级适合请假审批。approval_record的data字段用 JSON 存表单内容,这样不同流程可以共用一张记录表,不用为每种审批建独立表。查询“待我审批”的 SQL 大致是:
SELECT r.* FROM approval_record r JOIN approval_node n ON n.flow_id = r.flow_id AND n.sort = r.current_node WHERE r.status = 0 AND ( (n.approver_type = 1 AND n.approver_value = :user_id) OR (n.approver_type = 2 AND n.approver_value IN (SELECT role_id FROM user_role WHERE user_id = :user_id)) OR (n.approver_type = 3 AND n.approver_value = (SELECT leader_id FROM user WHERE id = r.applicant_id)) )这条 SQL 把三种审批人类型统一在一个查询里,避免了在 PHP 层做多次查询再合并。注意approver_value存的是字符串,比较时 MySQL 会做隐式类型转换,如果用户 ID 是整数,建议在 PHP 层就转成字符串再存入,避免索引失效。
3.2 用 ThinkPHP6 中间件实现权限节点校验
权限控制不要写在每个控制器方法里,用中间件统一处理。先建一个app/admin/middleware/Auth.php:
<?php namespace app\admin\middleware; use think\facade\Session; use think\Response; class Auth { public function handle($request, \Closure $next) { $user = Session::get('admin_user'); if (!$user) { return redirect('/admin/login/index'); } // 获取当前路由节点,如 admin/approval/list $node = strtolower($request->controller() . '/' . $request->action()); $allowList = ['login/index', 'login/check', 'index/index']; if (!in_array($node, $allowList)) { $hasAuth = \app\admin\model\Auth::checkNode($user['role_id'], $node); if (!$hasAuth) { return Response::create('无权限访问', 'html', 403); } } return $next($request); } }然后在app/admin/middleware.php里注册:
<?php return [ \app\admin\middleware\Auth::class, ];checkNode方法去查角色权限表,判断当前角色是否拥有该节点。这里有个血泪经验:节点名统一用小写,因为 ThinkPHP6 的controller()返回的是驼峰格式,不转小写会导致权限表里存的approval/list匹配不上Approval/list。另外allowList里必须放登录页和首页,否则会死循环重定向。
3.3 角色与权限的关联表设计及查询优化
权限表用经典的 RBAC 模型:role、node、role_node三张表。node表存所有可访问的控制器/方法节点,role_node存角色和节点的多对多关系。查询某个角色的所有权限节点时:
SELECT n.node FROM node n JOIN role_node rn ON rn.node_id = n.id WHERE rn.role_id = :role_id如果角色多、节点多,这张查询会频繁执行。优化手段是在 ThinkPHP6 里用缓存:
$nodes = cache('role_nodes_' . $role_id); if (!$nodes) { $nodes = Db::name('role_node') ->alias('rn') ->join('node n', 'n.id = rn.node_id') ->where('rn.role_id', $role_id) ->column('n.node'); cache('role_nodes_' . $role_id, $nodes, 3600); }缓存 1 小时,角色权限变更时手动删缓存。注意column('n.node')返回的是一维数组,in_array判断效率很高。不要用select()返回二维数组再循环,那样白白浪费内存。
4. 考勤与数据导出:把日常高频操作做顺
4.1 考勤打卡的防重复与时间窗口控制
考勤模块最怕员工用脚本刷打卡。常见做法是在attendance_record表上加唯一索引:
ALTER TABLE attendance_record ADD UNIQUE KEY uk_user_date (user_id, attendance_date);这样同一个人同一天只能插一条记录。但上班和下班是两条记录,所以实际设计是user_id + attendance_date + type唯一,type区分上班卡和下班卡。打卡时间窗口用 ThinkPHP6 的验证器控制:
$rule = [ 'type' => 'require|in:1,2', 'time' => 'require|date' ]; $msg = [ 'type.in' => '打卡类型错误', 'time.date' => '时间格式错误' ]; $validate = new \think\Validate($rule, $msg); if (!$validate->check($data)) { return json(['code' => 1, 'msg' => $validate->getError()]); }然后在业务逻辑里判断:上班卡只能在 6:00 到 12:00 之间打,下班卡在 17:00 到 23:59 之间。超出窗口返回“不在打卡时段”。注意服务器时区要设成Asia/Shanghai,在config/app.php里改default_timezone,否则时间差 8 小时,员工会炸锅。
4.2 用 Layui table 模块做分页与搜索
Layui 的 table 模块自带分页,后端只需要返回固定格式的 JSON。ThinkPHP6 控制器里:
public function list() { $page = input('page', 1); $limit = input('limit', 10); $keyword = input('keyword', ''); $where = []; if ($keyword) { $where[] = ['name', 'like', '%' . $keyword . '%']; } $total = Db::name('attendance_record')->where($where)->count(); $list = Db::name('attendance_record') ->where($where) ->page($page, $limit) ->order('id desc') ->select(); return json(['code' => 0, 'msg' => '', 'count' => $total, 'data' => $list]); }前端 Layui table 配置:
layui.use(['table'], function(){ var table = layui.table; table.render({ elem: '#attendanceTable', url: '/admin/attendance/list', page: true, limit: 10, cols: [[ {field: 'user_name', title: '姓名', width: 120}, {field: 'attendance_date', title: '日期', width: 120}, {field: 'check_in_time', title: '上班打卡', width: 160}, {field: 'check_out_time', title: '下班打卡', width: 160} ]] }); });关键参数:code必须为 0 表示成功,count是总记录数,data是当前页数据。Layui 会自动根据count和limit计算分页条。搜索时用table.reload带上where参数即可。注意page和limit参数名不能改,改了 Layui 就认不出来。
4.3 大数据量导出 Excel 的内存控制
导出考勤报表时,如果一个月几千条记录,用 PHPExcel 直接load会爆内存。稳妥做法是用 ThinkPHP6 的分块查询 + CSV 流式输出:
public function export() { $filename = 'attendance_' . date('Ymd') . '.csv'; header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="' . $filename . '"'); $fp = fopen('php://output', 'w'); fputcsv($fp, ['姓名', '日期', '上班打卡', '下班打卡']); $query = Db::name('attendance_record')->order('id asc'); $query->chunk(500, function ($items) use ($fp) { foreach ($items as $item) { fputcsv($fp, [ $item['user_name'], $item['attendance_date'], $item['check_in_time'], $item['check_out_time'] ]); } }); fclose($fp); exit; }chunk(500)每次只取 500 条,内存占用稳定在几 MB。fputcsv直接写入输出流,不生成临时文件。注意 CSV 里的中文要用utf8mb4编码,Excel 打开如果乱码,在文件开头加 BOM:
fwrite($fp, "\xEF\xBB\xBF");这一行不加,Windows 用户打开就是乱码,然后你会收到一堆“导出功能坏了”的反馈。
5. 避坑与排查:那些让我加班到凌晨的坑
5.1 坑一:Session 在多个应用间不共享
现象:后台登录后,员工端页面还是未登录状态。原因:ThinkPHP6 多应用模式下,每个应用默认使用独立的 Session 前缀。解决:在config/session.php里把prefix设为空字符串,或者统一用admin前缀,确保两个应用读同一个 Session。更彻底的做法是用 Redis 存 Session,在.env里配SESSION_TYPE = redis。
5.2 坑二:Layui 表格请求返回 200 但页面不显示数据
现象:Network 面板看到请求成功,返回 JSON 也正常,但表格一直显示“暂无数据”。原因:Layui table 要求返回的code必须是数字 0,如果后端返回的是字符串"0",Layui 会认为失败。解决:在 PHP 里用return json(['code' => 0, ...]),确保0是整数。另外count字段不能少,少了分页条不显示。
5.3 坑三:MySQL 的 utf8mb4 连接排序规则不一致导致查询报错
现象:执行带WHERE name = '张三'的查询时报“Illegal mix of collations”。原因:建库时用了utf8mb4_unicode_ci,但连接时用了utf8mb4_general_ci。解决:在.env的[DATABASE]段加一行COLLATION = utf8mb4_unicode_ci,或者在config/database.php里把collation显式设成和建库一致。这个坑在迁移数据库时特别常见。
5.4 坑四:审批流节点顺序调整后历史记录错乱
现象:管理员把审批流的节点顺序从“经理→总监”改成“总监→经理”,结果之前提交的待审记录全部卡住。原因:approval_record的current_node存的是sort值,节点顺序一变,原来的sort对应的审批人变了。解决:节点顺序调整时,不要改已有节点的sort,而是新建节点并停用旧节点。或者用节点 ID 而不是sort来关联记录,但那样查询会复杂一些。我一般建议前者,简单可靠。
5.5 坑五:定时任务重复执行导致考勤数据重复插入
现象:每天凌晨生成考勤汇总时,偶尔出现同一天两条汇总记录。原因:ThinkPHP6 的定时任务如果没加锁,多台服务器同时跑就会重复。解决:用 Redis 原子锁:
$lock = cache('attendance_lock_' . date('Ymd')); if ($lock) { return; } cache('attendance_lock_' . date('Ymd'), 1, 3600); // 执行汇总逻辑cache的第三个参数是过期时间,1 小时后自动释放。这样即使多台机器同时触发,也只有一台能拿到锁。
6. 进阶技巧:把办公系统做成团队真正愿意用的工具
6.1 用消息队列解耦审批通知
审批通过后要发邮件、发站内信、发企业微信通知,如果同步执行,用户点“通过”后会卡好几秒。用 Redis 队列异步处理:
// 审批通过时推入队列 \think\facade\Queue::push(\app\job\ApprovalNotify::class, ['record_id' => $id], 'approval'); // 消费端 php think queue:work --queue approval --daemonApprovalNotify类里实现fire方法,依次发邮件和站内信。这样审批操作瞬间返回,通知在后台慢慢发。注意--daemon模式要用 Supervisor 守护,否则进程挂了通知就丢了。
6.2 用数据库迁移管理表结构变更
团队协作时,每个人本地数据库结构不一致是灾难。用 ThinkPHP6 的迁移工具:
composer require topthink/think-migration php think migrate:create CreateApprovalFlow生成的迁移文件里写up和down方法,提交到 Git。其他人拉代码后执行php think migrate:run就能同步表结构。这比手动导 SQL 文件靠谱得多,尤其是字段类型调整时。
6.3 一个让我省下大量沟通成本的技巧:操作日志自动记录
在Auth中间件里顺手记录操作日志:
Db::name('operation_log')->insert([ 'user_id' => $user['id'], 'node' => $node, 'method' => $request->method(), 'params' => json_encode($request->param(), JSON_UNESCAPED_UNICODE), 'ip' => $request->ip(), 'create_time' => time() ]);这样谁在什么时候改了哪个审批流、导出了哪份报表,全部有据可查。有一次财务说考勤数据被改了,我直接查日志定位到是某管理员在后台手动调整了打卡时间。没有这张表,扯皮能扯一整天。注意params里不要记录密码字段,在插入前unset($params['password'])。
这套 ThinkPHP6 + Layui + MySQL 的办公系统方案,我前后在三个团队落地过,最深的体会是:不要一上来就追求功能大而全,先把审批流和考勤这两个最高频的场景跑通,让团队用起来,再根据反馈迭代。我自己的习惯是每加一个功能,先问“这个功能能不能用现有表结构扩展”,能就不建新表。希望帮到你。
本文还有配套的精品资源,点击获取