ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ThinkPHP5.1+Layui搭建台账管理系统:从数据建模到上线优化

ThinkPHP5.1+Layui搭建台账管理系统:从数据建模到上线优化 简介这套PHP台账管理系统基于ThinkPHP5.1框架、MySQL数据库和Layui前端组件开发适合高校毕业设计、课程设计以及需要实现业务员提成计算与客户对账的中小企业后台场景。系统功能覆盖商品、用户、客户、台账明细、台账审核、操作日志、角色权限、区域分类等模块能够完成从客户建档、业务台账录入、审核流转到提成核算与对账的全流程管理并通过角色权限控制保障不同岗位的数据安全。资源包共包含两千个文件压缩后约十八点四一兆其中以PHP业务代码数量最多超过七百五十个另有四百多个JavaScript交互脚本、两百多个数据文件以及Markdown文档、Excel表格、HTML页面、CSS样式和配置文件等目录结构清晰便于按需查找与二次开发。系统已有三百九十人学习浏览适合用来快速理解ThinkPHP5.1加MySQL加Layui的后台开发模式也可以直接导入数据库配置部署作为课程设计或毕业设计的有力支撑。1. 台账系统不是普通 CRUDThinkPHP5.1 组合拳先看建模很多刚接手“台账管理系统”需求的人第一反应是做一个增删改查后台但台账和普通业务表的差别恰恰在“能不能改”。日常看到的财务费用台账、固定资产台账、工程项目台账都对数据留痕有要求谁录的、谁审核的、状态从哪一步变到哪一步都要能追溯。所以必须先想清楚业务模型再谈表结构和界面。这套项目采用 ThinkPHP5.1 作为后端框架MySQL 存放业务数据和操作日志Layui 负责后台表格、弹层和表单渲染正好覆盖快速交付一个内部管理工具的大部分环节。适合那些已经有明确业务流程、想尽快把 Excel 或纸质登记迁移成在线系统的团队也适合需要在前端拿 Layui 的 table 组件对接 PHP 接口的开发者参考。2. 数据建模与数据库落地ThinkPHP5.1 模型关联和 MySQL 表结构2.1 台账主表和流水表的关系为什么拆成两张表台账这个名称在业务里其实有两类常见形态。一类是流水型台账每一行进库、出库、报销都对应一次真实业务发生记录只追加不修改另一类是结存型台账比如固定资产管理和项目台账一条记录对应一个物品或项目状态会从“在建”变成“已转固”。第二类最容易踩坑开发人员把状态直接写在主表里每次审核都 UPDATE 一次结果台账变成了“记当下”查不到历史是谁改的。我一般在设计这种结存型台账时会把主表当成“当前快照”单独建一张操作流水表记录每一次状态变更。ledger主表存当前最新的业务字段和状态ledger_log流水表存“变更前状态、变更后状态、操作人、操作时间、操作备注”。这样做的好处很直接第一日常列表查询只需要扫主表不需要把日志表的所有历史都 JOIN 进来第二审核、退回、反审核这些操作都往流水表里写一行天然形成不可篡改的操作链第三以后做报表要对账、追溯责任ledger_log已经够用了。这里有一个常见的误解觉得建两张表复杂还不如给主表加一个log字段解 JSON。除非你的所有查询都在单条记录级别完成否则 JSON 字段在 MySQL 里做范围统计、按时间过滤时还得额外处理维护成本远高于现在这种拆分。流水表只存必要的对账字段不要把主表字段全部冗余进去。2.2 用 SQL 建表和索引状态、日期、人员字段怎么设以一套“项目台账”为例我会把主表设计成下面这样关键字段放在注释里CREATE TABLE ledger ( id int(11) unsigned NOT NULL AUTO_INCREMENT, ledger_no varchar(32) NOT NULL COMMENT 台账编号业务唯一, category tinyint(4) NOT NULL DEFAULT 1 COMMENT 类型1工程建设 2设备采购 3费用支出, title varchar(255) NOT NULL COMMENT 事项名称列表页和检索都用它, amount decimal(14,2) NOT NULL DEFAULT 0.00 COMMENT 涉及金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待审核 1已审核 2已退回, biz_date date NOT NULL COMMENT 业务发生日期, remark varchar(500) DEFAULT COMMENT 备注, creator_id int(11) NOT NULL COMMENT 录入人ID, created_at datetime DEFAULT NULL, updated_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_ledger_no (ledger_no), KEY idx_category_status_date (category, status, biz_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;流水表保留和主表关联的外键逻辑但不建真实 FOREIGN KEY便于以后扩大审核范围不锁表CREATE TABLE ledger_log ( id int(11) unsigned NOT NULL AUTO_INCREMENT, ledger_id int(11) NOT NULL COMMENT 关联主表ledger.id, action varchar(32) NOT NULL COMMENT create/audit/reject/update, before_status tinyint(4) DEFAULT NULL COMMENT 操作前状态, after_status tinyint(4) DEFAULT NULL COMMENT 操作后状态, operator_id int(11) NOT NULL COMMENT 操作人ID, remark varchar(255) DEFAULT COMMENT 本次操作说明, created_at datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_ledger_id (ledger_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;从 MySQL 的使用习惯上看int(11) unsigned只是显示宽度你装的是 MySQL 5.7 或 8.0 都照常工作utf8mb4是为了避免拼音、生僻字和 emoji 在导入时报字符集错具体到这台机器用 MySQL 免安装版还是官网安装包无所谓关键是建库时指定default charsetutf8mb4。索引这里我只留了业务唯一键和category, status, biz_date的联合索引没有给每个过滤条件单独加索引。为什么要这样设台账列表最常见的动作是按类型筛选、按状态筛选、再按biz_date排序三个字段的联合索引能让这条WHERE category? AND status? ORDER BY biz_date直接用上索引顺序避免 filesort。注意联合索引的顺序是等值条件在前、排序条件在最后如果你把biz_date放最前面范围条件会阻断后面 status 的索引使用。若你的台账经常只按状态和创建时间查把status和created_at也建一条联合索引也合理。字段类型为什么这样定义amountdecimal(14,2)避免 float 误差台账金额需要精确到分biz_datedate只存业务日期不存时分秒排序更干净statustinyint(4)状态固定几个值省空间查询走索引更紧凑如果你习惯用 Navicat 或 MySQL Workbench 建表本质都是执行上面的 DDL参数含义不会变唯一要注意的是字符集别忘了选 utf8mb4否则创建出来的表默认继承库级字符集后续导数据会遇到隐含乱码。2.3 TP5.1 模型、自动时间戳和软删除的设置数据库表建好之后ThinkPHP5.1 的模型只是一个映射类不需要把 SQL 再抄一遍。项目里一般会放在application/common/model/Ledger.php?php namespace app\common\model; use think\Model; class Ledger extends Model { // 指定表名不用系统自动推断的 ledgers protected $table ledger; // 自动写入 create_time/update_time protected $autoWriteTimestamp true; protected $createTime created_at; protected $updateTime updated_at; // 台账录入必须校验的字段 protected $type [ amount float, biz_date string, ]; }模型里protected $table最好写全不要依赖默认命名规则避免以后表改名造成隐性错误。autoWriteTimestamp打开以后save()时会自动为created_at、updated_at赋值不需要你在控制器里手动date(Y-m-d H:i:s)这在通过 Layui 批量提交多条数据时能少写很多重复代码。这里暂不开放软删除。台账和业务流水表逻辑上不允许物理删除真实需要“废弃”一条记录时用status3之类的业务状态表示更符合台账规范如果用 ThinkPHP5.1 的$deleteTime做软删除反而会让列表查询默认带delete_time IS NULL条件初次排查时很容易当作数据丢失。把软删除留给那些允许物理清除的场景。3. 基于 Layui 的录入、审核和动态下拉围绕 table 接口做前端交互3.1 引入 Layui 资源并在页面里渲染表格Layui 本身就是把常用组件打包在layui.js里用模块化方式引入。列表页的骨架很直接一个div占位一个 table 渲染入口。link relstylesheet href/static/layui/css/layui.css script src/static/layui/layui.js/script div idledger-table/div然后执行layui.use([table, form], function () { var table layui.table; table.render({ elem: #ledger-table, url: /admin/ledger/list, page: true, cols: [[ {field: ledger_no, title: 台账编号, width: 140}, {field: category, title: 类型, templet: #categoryTpl, width: 90}, {field: title, title: 事项名称, minWidth: 200}, {field: amount, title: 金额, templet: function(d){ return Number(d.amount).toFixed(2); }}, {field: status, title: 状态, templet: #statusTpl, width: 100}, {field: biz_date, title: 业务日期, width: 110}, {fixed: right, title: 操作, toolbar: #rowToolbar, width: 150} ]], }); });表格执行后前端默认会把page1、limit10作为查询参数发给/admin/ledger/list。Layui 的 table 组件不关心后端是什么语言只要接口返回code0, msg, count, data就能渲染这里msg是给用户看的说明count用来做分页条总量。这段是典型的 HTML 模板或 templet 函数渲染注意字段amount在 Layui 表格里默认是字符串需要包一层Number(d.amount).toFixed(2)否则会出现 10.00 显示成 10 的情况。另一个容易踩的坑是“字段名没有和 table 的 col 对应上”后端返回的 JSON 里字段名是category_name而 col 里写了category查询接口就显示为空白。Layui 请求参数ThinkPHP5.1 读取方式含义pageinput(get.page, 1, intval)当前页码limitinput(get.limit, 10, intval)每页条数keywordinput(get.keyword/s)搜索关键字需要后端白名单3.2 select 动态赋值和表单校验台账录入页里类型下拉往往要等拿到后端数据后再填例如从 PHP 接口实时读取项目列表。Layui 的思路是先把 select 选项塞进 DOM再调用form.render(select)重绘。常见写法如下// 从 /admin/ledger/project-list 获取项目选项 $.get(/admin/ledger/project-list, function(res) { var opt option value请选择项目/option; res.data.forEach(function(item) { opt option value item.id item.name /option; }); $(#project_select).html(opt); layui.form.render(select); // 重要不调用则下拉不显示 });很多开发人员从 Vue 或 React 过来会习惯性以为 jQuery 修改 DOM 后组件会自动更新但 Layui 的 select 是封装过的原生select变化后必须调用form.render(select)否则已经生成的下拉面板不会读取新选项。这个机制和“Layui 能不能用 Vue”其实是两回事Layui 是面向普通页面的组件库Vue 是响应式数据流框架两者混用时最容易出问题的就是 select 和表单。表单提交前再做一次必填校验不要只依赖后端form.on(submit(ledger-form), function(data) { var field data.field; if (!field.title) { layer.msg(事项名称不能为空); return false; } $.post(/admin/ledger/save, field, function(res) { layer.msg(res.msg); if (res.code 0) { table.reload(ledger-table); // 保存成功刷新列表 } }, json); return false; // 阻断原生提交 });3.3 对接 ThinkPHP5.1 控制器的 JSON 响应格式后端拿到 Layui 分页参数最方便的写法是直接用查询构造器分页然后按 Layui 的约定打包返回public function list() { $page (int) input(get.page, 1); $limit (int) input(get.limit, 10); $list Db::name(ledger) -order(biz_date, desc) -page($page, $limit) -select(); $count Db::name(ledger)-count(); return json([ code 0, msg ok, count $count, data $list, ]); }这里不用paginate($limit)的本质原因是 Layui 已经帮你把page和limit作为参数传过来了直接透传给 MySQL 的分页语法即可。where条件如果由前端拼好再传进来后端必须用白名单过滤避免用户随便传一个order参数改排序或者传status[0]1造成 SQL 注入面变大。如果前后端是不同域名再给控制器加 CORS 头就行内部系统没必要开 JSONP。后端返回的其实就是一个 PHP 数组json()方法会把它转成 JSON 对象数组Layui 拿到的data就是数组对象结构。支付场景或审核操作建议用Db::startTrans()包裹主表和流水表的双写Db::startTrans(); try { Db::name(ledger)-where(id, $id)-update([status 1]); Db::name(ledger_log)-insert([ ledger_id $id, action audit, after_status 1, operator_id $this-loginUser[id], ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); return json([code 1, msg 审核失败]); }综合下来前端只管按 Layui 的表格约定传 page/limit后端只管按 TP5.1 的查询构造器取数再返回统一结构两边最需要注意的就是“不要自己封装一套复杂的{status, data: {list}}返回格式去迁就后端习惯”直接按 Layui 的code/msg/count/data四字段配合连二次开发都省心。4. 台账系统的典型功能补齐Excel 导入、条件查询、MySQL 索引优化4.1 用 PhpSpreadsheet 读取 Excel 并做逐行校验老台账系统上线时通常要补录历史数据最常见的是拿一张 Excel 表格做批量导入。社区里对 PHP 读 Excel 的称呼可能还有 PHPExcel实际现在主要维护的是 PhpSpreadsheet通过 Composer 引入phpoffice/phpspreadsheet之后可以用下面的方式逐行解析use PhpOffice\PhpSpreadsheet\IOFactory; $spreadsheet IOFactory::load($uploadedFile); $sheet $spreadsheet-getActiveSheet(); $rows $sheet-toArray(); // 转成二维数组第一行是表头 foreach (array_slice($rows, 1) as $rowIndex $row) { if (empty($row[0]) empty($row[1])) { continue; // 跳过完全空行 } $data [ ledger_no trim($row[0]), title trim($row[1]), amount is_numeric($row[2]) ? round($row[2], 2) : 0, biz_date date(Y-m-d, strtotime($row[3])), status 0, ]; // 入库前做简单格式检查 if ($data[amount] 0) { $errors[] 第 . ($rowIndex 1) . 行金额非法; continue; } $inserts[] $data; }这里的逻辑和普通接口编程不同必须先做“整表校验”再开始Db::name(ledger)-insertAll($inserts)而不是一行一行插入。因为中间某一行异常会导致整个事务回滚而回滚后 Excel 里前面的有效行也会被撤销对用户来说更直观。另一个细节是 Excel 日期序列号和文本日期并不一样用strtotime($row[3])有时会拿到非常奇怪的错误值稳妥做法是先判断 Excel 单元格数据类型如果是数值型序列号就用PhpOffice\PhpSpreadsheet\Shared\Date::excelToTimestamp()转换避免误把序列号当文本解析。现象原因处理导入后中文乱码源 Excel 编码与程序读入不一致统一按 UTF-8 读取和入库金额变成 0 或精度错单元格被存成文本用is_numeric判断后再取值不要直接用字符串数据量大时超时循环里频繁开启事务先把数据攒成数组每 500 条一批insertAll4.2 ThinkPHP5.1 查询构造器实现台账筛选列表页搜索字段一多条件拼 SQL 就成了灾难。TP5.1 查询构造器里比较好用的方式是用when()方法把可空参数统一处理$query Db::name(ledger); $query-when(!empty($category), function ($query) use ($category) { return $query-where(category, $category); }) -when($status ! , function ($query) use ($status) { return $query-where(status, $status); }) -when(!empty($keyword), function ($query) use ($keyword) { return $query-where(title, like, %$keyword%); }); $list $query-order(biz_date, desc)-page($page, $limit)-select();when()的第一个参数是布尔条件为真时才执行后面的闭包闭包里可以继续用where、order、join等于把散落各处的 if 判断压缩到查询构造器里。需要注意闭包里的变量要用use传进去这是 PHP 基础但很常见错点。如果台账数据量不到十万行这种写法可以接受但like %$keyword%不会走索引多个查询条件叠加时用explain看执行计划会更直观。另外page($page, $limit)默认不会带去 count 统计如果你用 Layui 做分页还需要单独count()一次如果数据量很大可以改为查询两次都放在同一个方法里执行再用数组把响应拼出来。4.3 MySQL 联合索引在筛选和排序中的配合台账系统上线三个月后最典型的性能问题就是某个筛选条件单独加了一条索引但列表依然慢。我用一个实际例子说明EXPLAIN SELECT * FROM ledger WHERE category 1 AND status 0 ORDER BY biz_date DESC LIMIT 20;如果你的索引是这样建的ALTER TABLE ledger ADD INDEX idx_category_status (category, status);那查询时 MySQL 先按 category 和 status 过滤得到中间结果然后再对 biz_date 做 filesort数据量大时 filesort 非常消耗内存。虽然 EXPLAIN 里显示可能还带Using index condition但最终排序代价并没有省掉。改进方案是把排序字段也放进联合索引DROP INDEX idx_category_status ON ledger; ALTER TABLE ledger ADD INDEX idx_category_status_date ( category, status, biz_date );因为联合索引天然按字段顺序创建一个有序结构可以把WHERE category? AND status?条件命中时直接按biz_date倒序取前 20 行不需要额外排序。MySQL 创建索引的语法在 5.7 和 8.0 里基本一样DROP INDEX idx_category_status ON ledger与ALTER TABLE ledger DROP INDEX idx_category_status都合法。但要记住联合索引不是越多越好每次插入、更新索引也有成本台账这类以查询为主、写入不频繁的表适合用联合索引而对那些每天导入几万行的流水表就优先保证主键和唯一键业务查询尽量走覆盖索引。5. 上线前可验证的 3 个技巧权限过滤、自动备份、慢查询定位5.1 在 TP5.1 中间件里按控制器/方法做权限过滤后台系统每个控制器都给权限会写到手软常见做法是把权限判断收敛到一个中间件里。先注册中间件// application/admin/middleware.php return [ app\admin\middleware\AdminAuth::class, ];中间件类里按控制器和方法名做白名单过滤namespace app\admin\middleware; class AdminAuth { public function handle($request, \Closure $next) { $controller strtolower($request-controller()); $action strtolower($request-action()); $allow [login, captcha, logout]; if (!in_array($controller . / . $action, $allow) !$this-checkPerm($request)) { return json([code 1, msg 无权限], 403); } return $next($request); } }checkPerm()里再查当前登录用户是否拥有该控制器/方法对应的权限标识这样一个中间件就把所有后台接口都罩住了。关键是$allow列表要放在前面先放行登录、验证码这类无需鉴权的入口否则会把自己锁在外面。5.2 用 MySQL 定时任务或 bat 每天备份台账表台账数据重要性高备份不能只靠数据库本地文件。Windows 服务器上最省事的备份就一条命令写进 plan task 定时跑echo off set DT%date:~0,4%%date:~5,2%%date:~8,2% mysqldump -uroot -pYourPass ledger ledger ledger_log D:\backup\ledger_%DT%.sqlledger和ledger_log两张表是必须导出的其他系统表不需要冗余备份%DT%取当前日期拼到文件名里保留最近 30 天即可。如果数据量大还可以在 mysqldump 后面加--single-transaction --quick减少备份期间对 InnoDB 表的锁影响。5.3 结合 slow log 和 EXPLAIN 定位 Layui 分页慢的原因Layui 表格分页一页 10 条接口却要 800ms问题几乎都在 SQL 层。先在 MySQL 里开慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;请求一次慢接口再去看慢日志里记录的 SQL。常见病根是前端翻到第 100 页时查询条件是LIMIT 990, 10MySQL 需要先扫前面 990 条再丢弃数据量大时越来越慢这时候把分页改成“游标式”会更好比如记录上一页最后一条biz_date下一页查biz_date 上一页值。遇到这种分页慢先做三件事翻慢查询日志、对语句执行 EXPLAIN、确认 offset 是不是越来越大问题一般就出在这三处的某一处。本文还有配套的精品资源点击获取
返回列表