尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

FastAdmin自定义导出实战:多Sheet报表与权限校验

FastAdmin自定义导出实战:多Sheet报表与权限校验 做后台管理系统这几年FastAdmin 是我用得比较多的一套框架它的 CRUD 一键生成、后台权限、通用搜索确实是省事。不过真正到了报表导出、明细汇总这类需求默认那套导出功能就会暴露短板。这篇文章就把我在 FastAdmin 里做自定义导出功能的完整实践记录一下从设计思路到控制器代码、前端按钮接入、常见坑和安全加固都讲清楚适合刚接触 FastAdmin、想从默认导出升级到业务级导出的同学参考。1. 项目概述为什么需要自定义导出功能1.1 默认导出能做到什么程度FastAdmin 的表格组件自带导出能力按钮在列表页工具栏里点击后会弹窗让你选择导出字段和文件格式XLS、CSV然后根据当前列表的搜索条件、排序参数、勾选的记录 ID 去后端生成文件。这个流程对于只有单表、字段简单、不需要加工的数据来说非常香比如导出用户列表、操作日志基本零代码。但默认导出有个天然边界它的数据源和列定义都来自当前表格。也就是说表格里展示什么才能导什么表格没有的字段或者需要跨表关联、合并单元格、加汇总 Sheet 这类需求它完全做不了。很多业务人员拿到手发现导出出来的 Excel 跟表格长得一模一样还经常抱怨“我要的统计怎么没有”。1.2 自定义导出的典型业务场景我这次做自定义导出背景是一个订单管理模块。列表页只是展示了订单主表的基础信息但客户需要的导出一份订单商品明细表一个订单对应多条商品记录商品明细不在列表页展示导出的文件要有多层表头最后还要附带一个汇总 Sheet统计总订单数、总销售额、每月分布。类似的场景在实际项目里特别常见订单、销售、财务类报表需要主表加子表明细合并导出状态值需要转成中文说明金额需要格式化、带单位Excel 里要做合计行、合并单元格、自定义列宽样式导出数据范围不只是当前筛选条件还需要包含日期区间、部门范围等额外条件管理员只能导出自己权限范围内的数据后端必须二次校验数据归属默认导出解决不了这些场景这时候就需要自己在控制器里写导出方法。1.3 本文案例与适用人群我下面就以订单明细导出为例完整走一遍自定义导出的实现控制器里重写导出方法、处理参数校验和权限校验、用分块查询避免内存溢出、用 PhpSpreadsheet 生成多 Sheet 的 Excel、在前端表格工具栏接入自定义按钮、最后记录几个我实际踩过且排查了很久的坑。如果你已经在用 FastAdmin遇到默认导出不够用的需求这篇文章可以直接给你一个可落地的参考方案。即使你用的是别的 ThinkPHP 或 Laravel 后台框架前半部分的设计思路和 PhpSpreadsheet 使用方式也可以直接借鉴。2. 整体设计思路与关键取舍2.1 自定义导出的三种实现路径在 FastAdmin 里做自定义导出有三种路线我这次选择的是第二种。第一种直接重写控制器里的export方法。FastAdmin 的公共控制器Backend已经内置了一个默认的export方法它会解析filter、op、sort等参数并生成 Excel。你可以覆写这个方法加入自己的业务逻辑。好处是很省事前端默认的导出按钮不用改坏处是耦合了 FastAdmin 默认导出那套参数解析流程字段选择弹窗的逻辑也不一定是你要的改动起来束手束脚。第二种新增独立的导出方法比如exportOrderDetail()然后在列表页工具栏单独加一个按钮来触发。前端把当前列表的筛选参数、勾选的 ID 拼到 URL 上后端依赖 FastAdmin 的buildparams()解析通用搜索条件再走自己的数据组装逻辑。这个方式互不干扰默认导出按钮还在自定义导出按钮也只服务自己的报表需求。我这次采用的就是这个方案。第三种把导出逻辑放到后台队列或计划任务里生成文件后存到本地或对象存储再通过消息推送通知下载地址。这种方式适合超大数据量、导出时间长、需要定时生成的场景但结构上已经跳出控制器请求响应的范畴适合作为第二步扩展。2.2 前端触发与参数传递的约定自定义导出方法确定之后前端怎么把列表页当前的条件传过去是个关键点。FastAdmin 的表格对象会维护当前查询参数。一般开发者的第一反应是直接把勾选的 ID 数组拼到 URL 上却忘了把搜索框里的筛选条件也带上导致导出出来的数据跟列表对不上。正确做法是在点击按钮时从表格对象里拿到当前查询参数序列化的结果和ids参数一起拼到导出 URL 上。我用的方式是在视图里手动加一个工具栏按钮再在 JS 里绑定点击事件。这里要注意一个细节如果直接window.location.href url导出链接会暴露在浏览器历史里。后台导出通常涉及敏感数据建议用 iframe 或者表单 POST 的方式提交导出请求。我在项目里为了简单先用 GET 方式解决功能问题后续如果对安全性要求高再升级成 POST 提交并带上 FastAdmin 的 token 校验。2.3 后端查询与统计的分层设计自定义导出最怕把所有逻辑都堆在一起看起来能跑后面一个需求变化就闷了。我习惯把后端方法拆成几个层次第一层是参数接收与校验把日期格式、状态枚举、ID 数组都检查一遍不合法的直接拒绝第二层是权限校验确认当前管理员有没有这个导出节点的权限以及他所在的部门是否能访问这些数据第三层是查询数据只做一件事根据条件拿订单主表 ID再批量查子表明细第四层是组装数据把明细按订单分组、计算小计和汇总第五层是生成 Excel只负责把已组装好的数据写入文件并输出。分层的好处是后面改起来索命轻松业务人员只需要改汇总规则换文件格式时也只是换 Writer不用碰查询逻辑。2.4 技术选型为什么用 PhpSpreadsheetFastAdmin 新版本里已经通过 Composer 集成了 PhpSpreadsheet也就是旧版 PHPExcel 的继任者。如果你的项目里还没装执行一句composer require phpoffice/phpspreadsheet就能拿到。为什么不用简单的 CSV 拼接CSV 对纯数据导出很轻量但要做到合并单元格、多 Sheet、自定义列宽、统计公式、数据格式比如长数字、金额、日期就非常痛苦。PhpSpreadsheet 虽然内存占用偏高但对于几万行以内的导出完全可接受而且它支持直接输出 xlsx 文件流后端代码也不复杂。下面实操部分我会展示完整写法。3. 核心细节与实操过程3.1 控制器导出方法完整实现我的订单导出方法大概长这样先看整体结构再拆解重点部分。?php namespace app\admin\controller; use app\admin\model\Order as OrderModel; use app\admin\model\OrderGoods; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Font; class Order extends Backend { public function exportOrderDetail() { // 1. 权限校验 if (!$this-auth-check(shop/order/exportOrderDetail)) { $this-error(您没有导出权限); } // 2. 接收并校验参数 $ids $this-request-get(ids/a, []); $startDate $this-request-get(start_date, ); $endDate $this-request-get(end_date, ); $status $this-request-get(status, ); if ($startDate strtotime($startDate) false) { $this-error(开始日期格式不正确); } if ($endDate strtotime($endDate) false) { $this-error(结束日期格式不正确); } // 3. 复用 FastAdmin 通用搜索条件 list($where, $sort, $order, $offset, $limit) $this-buildparams(); // 如果勾选了指定记录以 ids 为准 if ($ids) { $where[order.id] [in, $ids]; } // 业务自定义条件 if ($startDate) { $where[order.order_date] [, strtotime($startDate . 00:00:00)]; } if ($endDate) { $where[order.order_date] [, strtotime($endDate . 23:59:59)]; } if ($status ! ) { $where[order.status] $status; } // 4. 分批查询主表 ID避免一次加载全量数据 $orderIds []; OrderModel::alias(order) -where($where) -field(order.id) -chunk(500, function ($orders) use ($orderIds) { foreach ($orders as $order) { $orderIds[] $order-id; } }); if (!$orderIds) { $this-error(没有符合条件的数据); } // 5. 批量查询明细避免 N1 循环查询 $detailRows []; OrderGoods::where(order_id, in, $orderIds) -chunk(500, function ($items) use ($detailRows) { foreach ($items as $item) { $detailRows[] $item-toArray(); } }); // 6. 组装订单映射用于补全主表字段 $orderMap OrderModel::where(id, in, $orderIds) -column(order_no, customer_name, order_date, total_amount, status, id); // 7. 生成 Excel $this-buildOrderDetailExcel($orderMap, $detailRows); } }这里有个容易忽略的地方buildparams()返回的$where里的参数值默认是经过 FastAdmin 过滤处理的但它的字段名没有表前缀。我在 join 或是 alias 查询时必须手动给字段加order.前缀否则表名冲突会直接查错数据。上面代码里写$where[order.id]、$where[order.order_date]就是出于这个原因。3.2 明细数据的分批查询与合并很多人写导出时会习惯这样做$list OrderGoods::where(order_id, in, $orderIds)-select();如果$orderIds有几千个子表数据量几万条这种方式会把所有数据一次性加载到内存。PhpSpreadsheet 本身内存占用就偏高再叠加全量查询结果跑一次导出就可能直接把 PHP 干到内存溢出。所以我上面用了chunk()分批读取。这里有一个 FastAdmin 的老坑在chunk()回调里如果再查同表或者同模型会触发锁表或者主键冲突的问题。解决办法是回调里只做数据收集不执行当前模型的写操作跨表查询也用独立的模型完成。比如我在回调里只把 ID 追加到$orderIds等分批结束再统一查OrderGoods这样就绕开了这个限制。还有一种更省资源的方式直接走order_goods表 left joinorder表一次性查询出所有要导出的明细列。它的缺点是 SQL 条件复杂时不好维护且如果最后要按订单分组做汇总还是要在 PHP 里再跑一遍内存分组。我的方案是主表 ID 和子表明细分开查主表很小只查 ID 和必要字段明细按需分批结构清晰。3.3 多 Sheet 与样式处理数据拿齐了剩下就是写 Excel。下面这段是我封装好的生成方法核心是创建两个 Sheet一个是明细数据一个是汇总统计。private function buildOrderDetailExcel(array $orderMap, array $detailRows) { $spreadsheet new Spreadsheet(); // 明细 Sheet $sheet $spreadsheet-getActiveSheet(); $sheet-setTitle(订单明细); $headers [订单编号, 客户名称, 下单时间, 商品名称, 单价, 数量, 小计, 订单状态]; foreach ($headers as $col $title) { $sheet-setCellValueByColumnAndRow($col 1, 1, $title); } // 设置表头样式加粗、居中 $sheet-getStyle(A1:H1)-getFont()-setBold(true); $sheet-getStyle(A1:H1)-getAlignment() -setHorizontal(Alignment::HORIZONTAL_CENTER); $rowIndex 2; $statusMap [ unpaid 待付款, paid 已付款, shipped 已发货, finished 已完成, canceled 已取消, ]; foreach ($detailRows as $item) { $order $orderMap[$item[order_id]] ?? []; if (!$order) { continue; } $subtotal $item[price] * $item[number]; $sheet-setCellValueExplicit(A . $rowIndex, $order[order_no], \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); $sheet-setCellValue(B . $rowIndex, $order[customer_name]); $sheet-setCellValue(C . $rowIndex, date(Y-m-d H:i:s, $order[order_date])); $sheet-setCellValue(D . $rowIndex, $item[goods_name]); $sheet-setCellValue(E . $rowIndex, $item[price]); $sheet-setCellValue(F . $rowIndex, $item[number]); $sheet-setCellValue(G . $rowIndex, $subtotal); $sheet-setCellValue(H . $rowIndex, $statusMap[$order[status]] ?? $order[status]); $rowIndex; } // 汇总 Sheet $summarySheet $spreadsheet-createSheet(); $summarySheet-setTitle(汇总统计); $summarySheet-setCellValue(A1, 总订单数); $summarySheet-setCellValue(B1, count($orderMap)); $summarySheet-setCellValue(A2, 商品总行数); $summarySheet-setCellValue(B2, count($detailRows)); $summarySheet-setCellValue(A3, 总销售额); $summarySheet-setCellValue(B3, $this-calcTotalAmount($orderMap)); // 列宽自适应简单设置不追求精确 $sheet-getColumnDimension(A)-setWidth(22); $sheet-getColumnDimension(D)-setWidth(30); $sheet-getColumnDimension(H)-setWidth(12); // 输出文件 $fileName order_detail_ . date(YmdHis) . .xlsx; header(Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); header(Content-Disposition: attachment;filename . $fileName . ); header(Cache-Control: max-age0); $writer new Xlsx($spreadsheet); $writer-save(php://output); exit; } private function calcTotalAmount(array $orderMap) { $total 0; foreach ($orderMap as $order) { $total $order[total_amount]; } return $total; }这里有一个很多人都踩过的点订单号、身份证号这类超长数字直接用setCellValue()写入 Excel 会自动变成科学计数法或丢精度。我上面用了setCellValueExplicit(..., \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING)强制按字符串写入这比在数字后面拼一个制表符强行格式化要干净得多。还有输出文件流时不要再用$this-success()或任何响应封装必须裸写header()后直接$writer-save(php://output)最后exit。因为 FastAdmin 在请求结束后会自动生成响应内容如果你提前用了$this-error()又继续往下走很容易把错误提示内容混进 Excel 文件导致 Excel 打开报错。3.4 前端 JS 配置与按钮接入自定义导出按钮我选择手动加不依赖 FastAdmin 默认导出弹窗。在订单列表页的 toolbar 区域加入这样的 HTMLa hrefjavascript:; classbtn btn-success btn-export-custom >$(document).on(click, .btn-export-custom, function () { var url $(this).data(url); // 勾选的记录 var ids Table.api.selectedids(); // 当前查询参数搜索框、筛选表单等 var params Table.api.query(options.extend.index_url, options.params); if (ids.length 0) { url ?ids ids.join(,) params; } else { url ? params; } // 新窗口导出避免遮罩和页面跳转 window.open(url, _blank); });说句实在话Table.api.query这个方法在不同版本的 FastAdmin 里写法略有差异有的版本直接把params拼到index_url上就是查询字符串。你只要搞清楚你项目里require-table.js中表格参数是怎么序列化的就行核心思路是保证后端拿到的条件和列表页一致。如果你不想去抠这些字符拼凑还有个更稳妥的方式后端不主动接收filter/op而是直接让前端把搜索表单序列化成普通参数提交后端按固定参数名接收。虽然少了一点 FastAdmin 的通用性但代码更可控排查起来也更容易。3.5 导出接口的权限与安全校验FastAdmin 的后台权限控制是基于节点菜单规则的。自定义导出方法新增后需要到后台「权限管理 - 菜单规则」里加上对应的节点比如shop/order/exportOrderDetail并分配给对应的角色。然后在控制器方法开头再手动校验一次$this-auth-check()双重保险。这里特别要提醒不要以为列表页有筛选权限导出接口就能高枕无忧。导出往往意味着批量获取数据如果没做好权限校验可能出现普通管理员导出其他部门数据甚至全量数据的情况。我在exportOrderDetail方法里就加了当前管理员的部门过滤条件只导出本部门订单。这段逻辑就不展开贴了思路就是在$where里追加一条order.department_id 当前管理员部门ID。还有三个安全细节值得注意第一buildparams()返回的$sort和$order来自请求参数如果你在导出方法里直接拼接进 order by理论上存在字段名注入的可能。稳妥做法是加一个字段白名单不在白名单里的统一按默认主键排序。第二导出文件的文件名不要直接用用户输入。我之前见过有人把task_name当文件名任务名称里带了路径分隔符直接把导出目录结构搞混乱了。自定义导出统一用date(YmdHis)或加上前缀。第三防 Excel 公式注入。Excel 单元格如果以、、-、开头会被当成公式执行。当导出的字段值来自用户输入、且拼接了固定的 Excel 表达式时这就是一个潜在的注入点。比如客户名称如果被写成cmd|...这样的内容一旦打开 Excel 就可能被触发。我一般在写入单元格之前做一次过滤private function safeCellValue($value) { $value (string)$value; $unsafePrefix [, , -, ]; if (in_array(substr($value, 0, 1), $unsafePrefix)) { return . $value; } return $value; }这个处理会让原本以特殊符号开头的字符串变成安全文本不影响阅读。4. 常见问题与排查技巧实录4.1 Excel 打不开或提示文件损坏现象导出后文件看起来在下载列表里但双击打开 Excel 直接提示文件已损坏用编辑软件打开看里面全是乱码。原因十有八九是输出流里混入了非文件内容。最常见的有三种第一种是app_debug开着调试模式下页面顶部输出了 trace 信息或 PHP notice第二种是在header()之前不小心用了echo或var_dump调试代码第三种是调用了$this-error()返回 JSON 提示然后又走了导出逻辑响应结构被污染。排查方法很简单先把下载下来的文件用文本编辑器打开看头部是不是以PK开头。如果前面有空格、div、错误信息那就说明前面混入了杂质。解决办法导出方法里严格保证在save(php://output)之前不输出任何东西生产环境把app_debug关掉输出完成后exit。如果用了 FastAdmin 的$this-error()就不要再往下执行导出代码。4.2 导出数据与列表页不一致现象列表页筛选了某段时间的订单点击自定义导出按钮后导出的数据包含了筛选范围之外的数据。这个坑我在第一次接自定义导出时踩过原因是前端按钮触发时只把勾选的ids传给了后端没有把搜索表单的筛选条件一起传。后端没有拿到filter和op自然只按默认条件查。解决就是前端在组织 URL 时把表格当前查询参数带上。我建议在后端方法里打印一次request()-get()看看实际接收到了什么参数再对照你的拼参逻辑。FastAdmin 的通用搜索参数名是filter字段过滤值和op操作符如果你发现filter是空字符串问题一定出在前端拼参环节。另外一个容易忽略的是时区问题。FastAdmin 的时间字段在表里存的可能是 int 时间戳也可能直接存 datetime 字符串。我在前端传start_date时用了年月日格式后端如果用strtotime处理后跟 int 时间戳比较没问题但如果换成某个模型自带转换器返回了字符串日期比较就会出现类型不一致查不出数据。建议统一在数据库层用整数时间戳存时间点减少这种不一致。4.3 大数据量导出超时与内存溢出现象导出几万行数据时浏览器等待很久PHP 报Allowed memory size of ... exhausted或页面直接 504。原因包括全量查询结果集没有分批、PhpSpreadsheet 单元格缓存用的默认内存模式、输出前没有设置较长的脚本执行时间。我的处理方式查询阶段用chunk()分批每次限制 500 行回调里只收集数组数据不保留模型对象。模型对象比普通数组占用内存多得多能转数组就转数组。Excel 生成阶段每写满 5000 行就新建一个 Sheet避免单个 Sheet 单元格对象太多挤压内存。在方法开头可以执行set_time_limit(0)但也要配合数据量控制毕竟一次导出永远超时也会卡死 PHP-FPM。更合理的做法是限制单次导出最大行数比如超过 3 万行就提示用户缩小时间范围再导出或者走异步队列生成。如果最终数据量稳定很大建议放弃 xlsx改用 CSV 输出几十万行也能流畅处理代价是不能在 CSV 里做合并单元格和格式样式。4.4 旧版本上传类问题与自查加固很多做 FastAdmin 项目的朋友可能也关注过框架曾经公布过的上传文件相关漏洞。这类问题的主要成因集中在上传接口对文件后缀校验不严、允许上传可执行脚本、上传目录没有禁止解析这几个方面。如果你还在用比较老的 FastAdmin 版本我强烈建议做三件事第一把框架升级到最新版本。官方在安全公告发布后已经在新版本里做了修复停在旧版等于把风险挂在线上。第二检查application/extra/upload.php里的配置。重点是去掉php、phtml、php3、php4、php5这类可执行后缀。不要只做黑名单过滤应该用白名单方式只允许图片、文档、压缩包等常见业务后缀。第三确认上传目录不能执行 PHP 脚本。如果你是 Nginx 部署可以在对应目录的 location 配置里加上禁止解析 PHP 的设置如果是 Apache则要检查上传目录是否误加了AddHandler或AddType规则。这里多说一句这类安全问题不一定只在官方上传接口里存在。如果你在自定义导出、导入或者其他业务代码里用了类似move_uploaded_file直接保存用户文件的操作同样要认真做后缀白名单和服务端重命名。文件名不要直接用原始文件名建议统一生成随机字符串再拼接白名单内的后缀从根源上切断可执行文件落地的路径。4.5 常见问题速查表为了方便以后排查我把这次实践遇到和同类项目常见的问题整理成一张速查表。现象可能原因解决方向Excel 打开提示损坏输出流混入调试信息或 BOM关闭 debug检查save()前有无额外输出最后exit导出行数缺失或数据不对前端筛选参数没传用表格对象把查询参数序列化后拼到导出 URL订单号变成科学计数法长数字被 Excel 自动转数值用setCellValueExplicit强制字符串导出数据包含无权限记录后端没做数据范围校验导出方法里追加部门或管理员维度的 where 条件内存溢出全量查询或单元格缓存过高chunk()分批超过 5000 行分 Sheet超大数据转 CSVPHP 超时脚本执行时间太短set_time_limit(0)加合理上限配合行数限制商品名称以 开头被当公式Excel 公式注入写入前过滤、、-、开头的值文件下载名乱码中文字符串 URL 编码问题文件名用英文字母拼接日期中文名需做 RFC 2231 编码这张表里最后两行是我在实际项目中分别帮同事排查过的真实案例第二个案例当时就是因为上线前没注意列值以开头结果运营同事打开导出的 Excel 弹出了一堆警告框吓得以为中毒了。5. 实操心得与后续扩展方向5.1 我踩过的最贵的坑这次自定义导出里我记忆最深的一个阶段是把 FastAdmin 的默认export方法覆写了后来发现前端默认弹窗的字段选择面板根本不会调用我新写的业务逻辑它走的是自己的导出参数解析流程。我花费了大半天时间最终把方案改成新增独立方法才算理顺。另外一点导出这种功能看起来简单但一旦涉及多表SQL 或查询条件的坑就非常多。我在开发环境一切正常到了生产环境才发现有部分订单的order_date是空值导致strtotime变成了当前时间戳查回来的数据范围完全不对。后来加上了空值默认处理逻辑并用测试数据覆盖了“无明细订单也要导出主表一行”的场景。建议以后写导出功能时多想想边界数据空值、无子表记录、超过时间范围、状态为空这些都要有明确策略。5.2 后续可以这样继续扩展自定义导出做多了之后你会慢慢发现所有报表需求都长得很像。我现在已经把导出封装成了一个通用服务类配置一套字段映射和查询规则就能快速生成一个新报表不用再复制粘贴控制器代码。更进阶的玩法是把导出和定时任务、消息通知结合起来每天晚上自动生成日报推送到企业微信群或钉钉群在导出文件中嵌入统计图表直接用 PhpSpreadsheet 的图表功能画销售趋势线超大数据量场景切换到异步任务导出完成后存到本地或对象存储生成下载短链接给导出操作加操作日志记录是谁在什么时间导出了哪个时间范围的数据这在财务和订单场景几乎是硬需求我实际用下来的感受是FastAdmin 的自定义导出真正难的不是 PhpSpreadsheet 那几个 API而是如何把你的业务范围、数据权限、筛选条件、输出格式这些因素在设计阶段就梳理清楚。按我上面这套分层思路来做后续维护起来会非常舒服。最后再提一句导出之前宁可多校验一层权限也不要给线上数据留一条未被验证的路。
返回列表