简介:这是一套面向中小企业开发者与信息化管理人员的开源企业办公自动化系统源码,基于ThinkPHP6、Layui与MySQL构建,集成系统设置、人事管理、消息管理、审批管理、日常办公、客户管理、合同管理、项目管理、财务管理等核心模块。系统结构简约,便于功能扩展与二次开发,可在此基础上延伸出CRM、ERP及各类业务管理系统,帮助团队快速搭建企业级办公平台,降低数字化与信息化办公成本。资源包共1017个文件,约10.42MB,其中193个PHP文件承载后端业务逻辑,330个HTML与276个JavaScript文件构成前端页面与交互,84个CSS文件负责样式呈现,另含7个SQL脚本用于数据库初始化,以及字体、图标、图片等静态资源,目录组织清晰,便于按模块定位代码。目前已有526人学习下载,适合希望研究企业级OA架构、进行二次开发或搭建内部办公系统的开发者参考使用。
1. 为什么很多团队最后又回头自研一套 ThinkPHP6 + Layui + MySQL 的办公系统
市面上 SaaS 办公工具不少,但真到落地环节,问题往往出在三个地方:数据不在自己机房、流程改不动、按人头续费越滚越贵。我见过一个二十来人的小团队,用第三方审批工具用了两年,最后因为一个「请假单要同时抄送项目负责人和财务」的规则死活配不出来,硬生生把流程拆成两步手动转发。这就是「基于 ThinkPHP6 + Layui + MySQL 打造的实用的开源的企业办公系统」这类方案真正的生存空间——它不是要替代谁,而是给那些流程有自己脾气、又不想被订阅制绑住的团队一个能改、能控、能自己维护的底座。
ThinkPHP6 负责后端路由、ORM、权限和事务,Layui 负责后台那套表格、表单、弹层,MySQL 存组织架构和业务数据。这套组合的好处是门槛低:会 PHP 的人一周能上手,前端不用学构建工具,改个字段直接改 HTML。适合谁?适合十到两百人规模、有至少一个能写 PHP 的开发者、流程里存在「标准产品覆盖不到」的环节的团队。下面我按「先跑起来、再拆模块、再避坑」的顺序,把这套系统从零到能用的路径讲清楚。
2. 环境搭起来:ThinkPHP6 骨架、Layui 静态资源与 MySQL 的最小可运行组合
2.1 用 Composer 拉 ThinkPHP6 骨架并确认目录约定
ThinkPHP6 用 Composer 管理依赖,这一步别想着手动下压缩包,版本和自动加载会把你坑到怀疑人生。先确认本机 PHP 版本在 7.2 以上(推荐 7.4 或 8.0),然后执行:
# 创建项目骨架,myoffice 是项目目录名,可自行替换 composer create-project topthink/think myoffice # 进入目录 cd myoffice # 安装多应用模式扩展,办公系统一般分 admin / api / home 多个应用 composer require topthink/think-multi-app # 启动内置服务器,先确认骨架能跑 php think run -H 0.0.0.0 -p 8000执行完访问http://127.0.0.1:8000,看到 ThinkPHP 默认欢迎页就说明骨架没问题。这里的关键参数是-H 0.0.0.0,它让内置服务器监听所有网卡,方便你在同一局域网用手机或另一台机器测 Layui 的响应式表格;如果只写127.0.0.1,别的设备访问不到,调试移动端时会以为代码写错了。
多应用扩展装完后,app目录下默认只有一个controller,你需要手动建app/admin、app/api两个目录,每个目录里放controller、model、view。ThinkPHP6 的多应用是按目录名自动映射 URL 的,/admin/user/index就会去找app/admin/controller/User.php的index方法。这个约定比在路由文件里一条条写规则省事得多,但代价是目录结构必须规整,乱放文件会出现「明明方法存在却 404」的玄学问题。
2.2 把 Layui 放进 public 目录并配好视图渲染
Layui 是纯静态资源,不需要 npm,直接下载发行包解压到public/static/layui即可。目录结构建议这样:
public/ static/ layui/ layui.js css/layui.css modules/...然后在app/admin/view/user/index.html里引入。ThinkPHP6 默认模板引擎是原生 PHP,但办公系统里大量用 Layui 的表格和表单,建议装topthink/think-view用模板继承:
composer require topthink/think-view视图里这样写:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>用户管理</title> <link rel="stylesheet" href="/static/layui/css/layui.css"> </head> <body> <!-- Layui 数据表格容器,id 与 JS 初始化对应 --> <table id="userTable" lay-filter="userTable"></table> <script src="/static/layui/layui.js"></script> <script> layui.use(['table'], function(){ var table = layui.table; // url 指向后端接口,page 开启分页,limit 是每页条数 table.render({ elem: '#userTable', url: '/admin/user/list', page: true, limit: 15, cols: [[ {field: 'id', title: 'ID', width: 80}, {field: 'username', title: '用户名'}, {field: 'dept_name', title: '部门'}, {field: 'status', title: '状态', templet: function(d){ return d.status == 1 ? '在职' : '离职'; }} ]] }); }); </script> </body> </html>这里有个容易翻车的点:Layui 的table.render默认按它自己的格式解析返回数据,要求后端返回{code:0, msg:"", count:总数, data:[...]}。ThinkPHP6 里如果你直接return json($list),Layui 表格会一直显示「数据接口请求异常」。正确做法是在控制器里手动拼这个结构,或者写一个统一的响应助手函数。参数limit:15要和后端接收的limit参数对齐,否则分页页码和实际条数对不上,用户翻到第二页发现数据重复。
2.3 数据库连接与迁移:把组织架构表先立住
MySQL 建库建议用utf8mb4字符集,办公系统里难免有人名带生僻字或 emoji。在.env文件里配连接:
[DATABASE] TYPE = mysql HOSTNAME = 127.0.0.1 DATABASE = myoffice USERNAME = root PASSWORD = your_password HOSTPORT = 3306 CHARSET = utf8mb4 DEBUG = true组织架构至少需要三张表:部门、用户、角色。用 ThinkPHP6 的迁移工具建表比手写 SQL 更可控:
composer require topthink/think-migration php think migrate:create CreateDeptTable生成的迁移文件里写:
public function change() { $table = $this->table('dept', ['engine' => 'InnoDB', 'comment' => '部门表']); $table->addColumn('name', 'string', ['limit' => 50, 'comment' => '部门名称']) ->addColumn('parent_id', 'integer', ['default' => 0, 'comment' => '上级部门ID']) ->addColumn('sort', 'integer', ['default' => 0, 'comment' => '排序']) ->addColumn('create_time', 'integer', ['default' => 0]) ->create(); }parent_id用整数存上级 ID,而不是用嵌套集或路径枚举,是因为办公系统的部门层级通常不超过五层,递归查询在 PHP 里做比在 SQL 里写复杂 join 更好维护。sort字段控制同级排序,别小看它,部门顺序乱了以后通讯录看起来会非常难受。执行php think migrate:run建表,再用php think seed:create造几条测试数据,就能开始接 Layui 表格了。
3. 核心模块怎么拆:用户权限、审批流、公告与文件存储的落地顺序
3.1 RBAC 权限:菜单、角色、用户三张表怎么联动
办公系统的权限不是简单的「登录就能看」,而是「不同角色看到不同菜单、能操作不同按钮」。RBAC 的最小实现需要五张表:user、role、menu、role_menu、user_role。核心逻辑是登录后根据 user_id 查角色,再根据角色查菜单,前端 Layui 的侧边栏按返回的菜单树渲染。
后端查菜单的代码:
// app/admin/controller/Auth.php 中获取当前用户菜单 public function getMenus($userId) { // 先查用户角色 $roleIds = Db::name('user_role')->where('user_id', $userId)->column('role_id'); if (empty($roleIds)) { return []; } // 再查角色关联的菜单,去重后按 sort 排序 $menuIds = Db::name('role_menu')->whereIn('role_id', $roleIds)->column('menu_id'); $menus = Db::name('menu') ->whereIn('id', array_unique($menuIds)) ->where('status', 1) ->order('sort asc') ->select() ->toArray(); // 转成树形结构给前端 return $this->buildTree($menus, 0); } private function buildTree($list, $parentId) { $tree = []; foreach ($list as $item) { if ($item['parent_id'] == $parentId) { $children = $this->buildTree($list, $item['id']); if ($children) { $item['children'] = $children; } $tree[] = $item; } } return $tree; }buildTree用递归而不是一次 SQL 查完,是因为菜单数据量小(通常几十条),递归在 PHP 层做比写复杂的自连接 SQL 更直观,改起来也快。注意array_unique那一步不能省,一个用户如果有两个角色且都关联了同一个菜单,不去重会出现重复菜单项。前端 Layui 侧边栏用layui.element渲染,菜单的url字段直接对应/admin/xxx/index路径。
按钮级权限则是在菜单基础上再加一层「操作权限」标记,比如菜单表里加perms字段存user:add、user:delete这样的字符串,前端渲染按钮时判断当前用户是否拥有该 perms。这套做法比在每个控制器方法里硬编码角色判断要干净,但代价是每次加功能都要同步维护菜单表,漏配就会出现「菜单能看见但按钮点了没反应」的排查成本。
3.2 审批流:用状态机而不是工作流引擎
很多团队一上来就想上 Activiti 那种工作流引擎,结果发现光是画流程图就够折腾一周,而实际需求只是「请假单先主管审、再人事审」。我的建议是:办公系统初期用状态机 + 审批记录表,别碰重型引擎。
审批主表加一个status字段:0 待审、1 审批中、2 通过、3 驳回。审批记录表存每一步的操作人、动作、意见、时间。核心流转代码:
// 审批通过时推进状态 public function approve($applyId, $userId, $remark) { Db::startTrans(); try { $apply = Db::name('leave_apply')->where('id', $applyId)->lock(true)->find(); if ($apply['status'] != 1) { throw new \Exception('当前状态不可审批'); } // 记录审批动作 Db::name('approve_log')->insert([ 'apply_id' => $applyId, 'user_id' => $userId, 'action' => 'approve', 'remark' => $remark, 'create_time' => time() ]); // 判断是否还有下一级审批人,这里用简单的节点配置表 $nextNode = $this->getNextNode($apply['flow_id'], $apply['current_node']); if ($nextNode) { Db::name('leave_apply')->where('id', $applyId)->update([ 'current_node' => $nextNode['id'], 'status' => 1 ]); } else { Db::name('leave_apply')->where('id', $applyId)->update(['status' => 2]); } Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } }lock(true)是行锁,防止两个审批人同时点通过导致状态错乱,这是血泪经验——测试时一个人点两下都能复现。flow_id和current_node指向一张流程配置表,管理员可以在后台配「请假流程:主管 → 人事 → 归档」这样的节点链。这套方案能覆盖 80% 的审批场景,剩下 20% 比如会签、条件分支,再考虑加字段或换引擎。
3.3 公告与文件上传:别把文件存进数据库
公告模块本身简单,标题、内容、发布人、发布时间、置顶标记。真正要小心的是附件上传。常见做法是把文件存到public/uploads目录,数据库只存相对路径和原始文件名。ThinkPHP6 的上传用think\facade\Filesystem:
public function upload() { $file = request()->file('file'); // 按日期分目录,避免单目录文件过多 $savename = \think\facade\Filesystem::disk('public')->putFile('notice', $file); if ($savename) { return json(['code' => 0, 'msg' => '上传成功', 'data' => [ 'path' => '/storage/' . $savename, 'name' => $file->getOriginalName() ]]); } return json(['code' => 1, 'msg' => '上传失败']); }putFile('notice', $file)的第一个参数是子目录,第二个是文件对象,返回的路径已经带了日期前缀。注意Filesystem需要在config/filesystem.php里配好public磁盘的根目录指向public/storage,并且做一次软链接php think storage:link,否则上传成功但访问 404。文件大小限制在config/filesystem.php和 PHP 的upload_max_filesize两处都要改,只改一处会出现「小文件能传大文件报错」的迷惑现象。
4. 避坑与排查:这套组合最容易翻车的五个地方
4.1 Layui 表格分页参数对不上,第二页数据重复
现象:表格第一页正常,点第二页显示的还是第一页的数据,或者页码总数不对。原因通常是后端接收分页参数时用了page和limit,但 Layui 默认传的是page和limit,如果你在控制器里写成$request->param('pageSize')就永远取不到。解决:在table.render里显式配request: {pageName: 'page', limitName: 'limit'},或者后端统一用input('page', 1)和input('limit', 15)接收。另外count必须是总记录数而不是当前页条数,用Db::name('user')->count()单独查一次。
4.2 权限菜单缓存导致改了角色不生效
现象:管理员在后台改了某个角色的菜单权限,用户刷新页面还是旧菜单。原因是菜单树被缓存了。ThinkPHP6 默认不一定开缓存,但如果你在getMenus里加了Cache::get,就要在角色菜单变更时清缓存。解决:在role_menu表增删改后调用Cache::delete('menu_user_'.$userId),或者干脆初期不加缓存,等菜单查询成为瓶颈再说。办公系统用户量不大,菜单查询走索引后毫秒级,缓存带来的收益远小于不一致带来的排查成本。
4.3 审批并发导致状态错乱
现象:两个审批人同时打开同一条待审记录,都点了通过,结果审批记录里出现两条通过日志,状态却只推进了一步。原因是没有加锁或锁的粒度不对。解决:如 3.2 所示用lock(true)行锁,并且把状态判断放在事务内。注意 MySQL 的 InnoDB 行锁要求查询走索引,如果where id = ?没走主键,会退化成表锁,并发高时直接卡死。另外事务里不要做耗时操作比如发邮件,否则锁持有时间过长。
4.4 文件上传路径在 Windows 和 Linux 下不一致
现象:本地 Windows 开发时上传正常,部署到 Linux 服务器后图片裂了。原因是路径分隔符和大小写敏感。ThinkPHP6 的Filesystem返回的路径用的是/,但如果你自己用DS常量拼接就可能出问题。解决:统一用Filesystem的返回值,不要手动拼路径;Linux 下目录权限要给www用户写权限,chmod -R 755 public/storage不够,上传目录需要 775 或 777(视安全策略而定)。另外storage:link创建的软链接在部分虚拟主机上不可用,那就改成直接上传到public/uploads并配好 URL 前缀。
4.5 数据库字符集不统一导致生僻字变问号
现象:用户名字里有生僻字,存进去变成?。原因是建库时用了utf8而不是utf8mb4,或者连接字符集没配对。解决:建库语句用CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,.env里CHARSET = utf8mb4,ThinkPHP6 的数据库配置里也要确认charset参数。已经建好的库可以用ALTER DATABASE myoffice CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;改,但已有表的字段字符集要单独改,这一步容易漏。
5. 让这套系统真正「实用」的两个进阶技巧
5.1 用数据库配置表替代硬编码,让非开发者也能改流程
办公系统上线后,改得最频繁的往往不是代码,而是「请假类型有哪些」「审批节点怎么走」「公告谁能发」。这些如果都写死在 PHP 里,每次改动都要发版。我的做法是建一张sys_config表,存key、value、remark,后台加一个配置管理页。比如审批节点链存成 JSON:
// 读取请假流程配置 $flowConfig = Db::name('sys_config')->where('key', 'leave_flow')->value('value'); $nodes = json_decode($flowConfig, true); // $nodes 形如 [{"id":1,"name":"主管审批","role":"manager"},{"id":2,"name":"人事审批","role":"hr"}]这样管理员在后台改 JSON 就能调整流程,不用等开发排期。代价是要做 JSON 格式校验,否则配错了整个流程卡死。我一般会在保存前用json_decode试解析一次,失败就拒绝保存并提示格式错误。这个技巧让系统的「可维护性」上了一个台阶,也是开源办公系统能不能被团队长期用下去的关键——没人愿意为了改一个审批节点去翻代码。
5.2 用定时任务做数据归档,别让日志表拖垮查询
审批日志、操作日志、登录日志,这些表只增不减,半年后就是几十万行。Layui 表格查日志时如果没走好索引,会明显变慢。我的习惯是加一个php think自定义命令,每月把三个月前的日志归档到_history表,主表只留近期数据:
// app/admin/command/ArchiveLog.php protected function configure() { $this->setName('log:archive')->setDescription('归档三个月前的日志'); } protected function execute(Input $input, Output $output) { $threeMonthsAgo = strtotime('-3 months'); $logs = Db::name('approve_log')->where('create_time', '<', $threeMonthsAgo)->select(); if ($logs) { Db::name('approve_log_history')->insertAll($logs); Db::name('approve_log')->where('create_time', '<', $threeMonthsAgo)->delete(); } $output->writeln('归档完成,共 ' . count($logs) . ' 条'); }然后在服务器 crontab 里加0 3 1 * * php /path/to/think log:archive,每月 1 号凌晨 3 点跑。注意insertAll一次插入太多会超内存,日志量大时要分批,比如每 1000 条一批。归档表的结构和主表一致,但可以去掉一些不常用的索引来省空间。这个习惯让我维护的系统在跑了一年多以后,日志查询依然在百毫秒内,而不是等到用户抱怨「点开日志要转十秒」才去救火。
这套 ThinkPHP6 + Layui + MySQL 的组合,说到底不是技术多先进,而是「改得动、看得见、招人好招」。我踩过最深的坑是早期贪图省事把权限判断散落在各个控制器里,后来加一个角色要改十几个文件,最后花了两天重构成统一中间件。如果你正准备起这套系统,先把 RBAC 和审批状态机这两块的表结构设计清楚,后面会省下大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取