Files
MrleeAdmin/backend/core/excel/ExcelExportService.php
T
2026-08-31 11:33:59 +08:00

289 lines
9.2 KiB
PHP

<?php
/**
*+------------------
* madong
*+------------------
* Copyright (c) https://gitee.com/motion-code All rights reserved.
*+------------------
* Author: Mr. April (405784684@qq.com)
*+------------------
* Official Website: https://madong.tech
*/
namespace core\excel;
use app\service\admin\system\ConfigService;
use Carbon\Carbon;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Csv;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use support\Container;
/**
* Excel-vxe-table导出服务
*
* @author Mr.April
* @since 1.0.
*/
class ExcelExportService
{
/**
* @param array $params
* @param callable $dataCallback
*
* @return array
* @throws \Exception
*/
public static function export(array $params, callable $dataCallback): array
{
try {
// 验证字段配置
$validFields = self::validateFields($params);
// 初始化工作表
[$spreadsheet, $sheet, $fieldMap] = self::initSpreadsheet($params, $validFields);
// 生成表头
$currentRow = self::generateHeader($sheet, $params, $fieldMap);
// 分块写入数据
$currentRow = self::writeData($spreadsheet, $sheet, $currentRow, $fieldMap, $dataCallback);
// 保存文件
$fileInfo = self::saveToDisk($spreadsheet, $params);
// 生成下载链接
$downloadInfo = self::generateDownloadInfo($fileInfo['path'], $params);
$downloadInfo['params']['file_path'] = $fileInfo['download_path'];
return [
'url' => $downloadInfo['url'] . '?file_path=' . $downloadInfo['params']['file_path'] . '&file=' . $downloadInfo['params']['file'],
'expire' => $downloadInfo['expire'],
'params' => $downloadInfo['params'],
];
} catch (\Throwable $e) {
throw new \Exception('导出失败: ' . $e->getMessage());
}
}
/**
* 传入字段验证
*
* @param array $params
*
* @return array
* @throws \Exception
*/
private static function validateFields(array $params): array
{
$validFields = array_filter($params['fields'] ?? [], function ($field) {
return !empty($field['field']) && !empty($field['title']);
});
if (empty($validFields)) {
throw new \Exception("导出的字段配置无效:必须包含field和title");
}
return $validFields;
}
/**
* 初始化工作表
*
* @param array $params
* @param array $validFields
*
* @return array
*/
private static function initSpreadsheet(array $params, array $validFields): array
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(substr($params['sheetName'] ?? 'Sheet1', 0, 31));
$fieldMap = [];
$columnIndex = 0;
foreach ($validFields as $field) {
$dbField = $field['field'];
$fieldMap[$dbField] = [
'title' => $field['title'],
'column' => Coordinate::stringFromColumnIndex($columnIndex + 1),
'format' => $field['format'] ?? null,
];
$columnIndex++;
}
return [$spreadsheet, $sheet, $fieldMap];
}
/**
* 动态表头
*
* @param $sheet
* @param $params
* @param $fieldMap
*
* @return int
*/
private static function generateHeader($sheet, $params, $fieldMap): int
{
$currentRow = 1;
if ($params['isHeader'] ?? true) {
foreach ($fieldMap as $config) {
$sheet->setCellValue($config['column'] . '1', $config['title']);
self::setColumnStyle($sheet, $config); // 列宽及样式设置
}
$currentRow = 2;
}
return $currentRow;
}
/**
* 动态样式列
*
* @param $sheet
* @param array $config
*/
private static function setColumnStyle($sheet, array $config): void
{
switch ($config['format']['type'] ?? 'text') {
case 'date':
$sheet->getColumnDimension($config['column'])->setWidth(20);
$sheet->getStyle($config['column'])
->getNumberFormat()
->setFormatCode('yyyy-mm-dd hh:mm:ss');
break;
case 'json':
$sheet->getColumnDimension($config['column'])->setWidth(40);
$sheet->getStyle($config['column'])
->getAlignment()
->setWrapText(true);
break;
case 'multiline':
$sheet->getColumnDimension($config['column'])->setWidth($config['format']['width'] ?? 24);
$sheet->getStyle($config['column'])
->getAlignment()
->setWrapText(true);
break;
default:
$sheet->getColumnDimension($config['column'])->setAutoSize(true);
}
}
/**
* 分块写入数据
*
* @param $spreadsheet
* @param $sheet
* @param $currentRow
* @param $fieldMap
* @param callable $dataCallback
*
* @return int
*/
private static function writeData($spreadsheet, $sheet, &$currentRow, $fieldMap, callable $dataCallback
): int
{
$dataCallback(function ($dataChunk) use (&$currentRow, $sheet, $fieldMap, $spreadsheet) {
foreach ($dataChunk as $item) {
foreach ($fieldMap as $dbField => $config) {
//这里可以扩展数据格式化以及公共枚举实现
$rawValue = data_get($item, $dbField);
if (is_array($rawValue) || is_object($rawValue)) {
$rawValue = json_encode($rawValue);
}
$sheet->setCellValue($config['column'] . $currentRow, $rawValue);
}
$currentRow++;
// 内存优化:每处理500行释放内存
if ($currentRow % 500 === 0) {
$spreadsheet->garbageCollect();
}
}
});
return $currentRow;
}
/**
* 文件名安全过滤
*/
private static function sanitizeFilename(string $name): string
{
return preg_replace('/[\/\\\?\*\[\]]/', '', $name);
}
/**
* 保存到磁盘
*
* @param \PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet
* @param array $params
*
* @return array {path: string, filename: string}
* @throws \Exception
*/
private static function saveToDisk(
\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet,
array $params
): array
{
$fileName = self::sanitizeFilename($params['filename'] ?? 'export_' . date('YmdHis'));
$format = strtolower((string)($params['format'] ?? 'xlsx'));
$format = in_array($format, ['xlsx', 'csv'], true) ? $format : 'xlsx';
// $path = '/exports/' . date('Ym') . '/';
$path = '/exports/';//临时文件取消月份用完就删除
$storagePath = runtime_path() . $path;
// 目录创建带重试机制(增强健壮性)
if (!is_dir($storagePath) && !mkdir($storagePath, 0755, true)) {
$retry = 0;
while ($retry++ < 3 && !mkdir($storagePath, 0755, true)) {
usleep(100000); // 等待100ms重试
}
if (!is_dir($storagePath)) {
throw new \Exception("目录创建失败: {$storagePath}");
}
}
$writer = $format === 'csv' ? new Csv($spreadsheet) : new Xlsx($spreadsheet);
if ($writer instanceof Csv) {
$writer->setUseBOM(true);
$writer->setLineEnding("\r\n");
}
$filePath = $storagePath . $fileName . '.' . $format;
$writer->save($filePath);
unset($spreadsheet); // 显式释放内存
return [
'path' => $filePath,
'filename' => $fileName,
'download_path' => $path . $fileName . '.' . $format,
];
}
/**
* 生成下载信息
*
* @param string $filePath
* @param array $params
*
* @return array {url: string, expire: string}
*/
private static function generateDownloadInfo(string $filePath, array $params): array
{
/** @var ConfigService $systemConfig */
$systemConfig = Container::make(ConfigService::class);
$baseUrl = rtrim((string) $systemConfig->config('site_url', '', ['group_code' => 'site_setting']), '/');
// 文件信息解析
$fileInfo = pathinfo($filePath);
$expire = Carbon::today()->endOfDay()->timestamp;
return [
'url' => "{$baseUrl}/export/download",
'params' => [
'file' => $fileInfo['basename'],
'expire' => $expire,
],
'expire' => Carbon::createFromTimestamp($expire)->toDateTimeString(),
];
}
}