SpoutExport.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. <?php
  2. /**
  3. * Author: lexuan
  4. * Name: SpoutExport
  5. * version: 1.0.0
  6. * Tip: 安装 composer require box/spout
  7. */
  8. namespace App\Models;
  9. use Box\Spout\Writer\Common\Creator\WriterEntityFactory;
  10. use Box\Spout\Writer\Common\Creator\Style\StyleBuilder;
  11. use Box\Spout\Common\Entity\Style\CellAlignment;
  12. use Illuminate\Support\Facades\File;
  13. use Illuminate\Support\Facades\Log;
  14. class SpoutExport
  15. {
  16. private $enum = []; // 枚举
  17. private $toStrFields = []; // 需字符串处理的字段
  18. private $headers = []; // 表头
  19. private $fileFormat = 'xlsx'; // 文件格式
  20. private $download = false; // 是否下载
  21. private $handleType = 'chunk'; // 数据处理方式 chunk-分块处理 cursor-逐行处理
  22. private $query = null; // 数据模型对象
  23. private $dataList = []; // 数据列表
  24. private $processStyle = false; // 是否后处理样式
  25. private $chunkSize = 2000; // 分块处理时每块大小
  26. private $processStyleMaxRow = 20000; // 后处理样式时最大行数
  27. private $styleWidth = null; // 列宽
  28. private $styleHeight = 30; // 行高
  29. /**
  30. * 初始化
  31. *
  32. * @param array $headers 表头
  33. */
  34. public function __construct(array $headers)
  35. {
  36. $this->headers = $headers;
  37. }
  38. /**
  39. * 设置数据模型对象
  40. *
  41. * @param models $query 数据模型对象
  42. * @param string $handleType 数据处理方式 chunk-分块处理 cursor-逐行处理
  43. * @param integer $chunkSize 分块处理时每块大小
  44. * @return $this
  45. */
  46. public function setQuery($query, $handleType = 'chunk', $chunkSize = 2000)
  47. {
  48. $this->query = $query;
  49. $this->handleType = $handleType;
  50. $this->chunkSize = $chunkSize;
  51. return $this;
  52. }
  53. // 设置数据
  54. public function setData(array|object $dataList)
  55. {
  56. $this->dataList = $dataList;
  57. return $this;
  58. }
  59. // 设置枚举
  60. public function setEnum(array $enum)
  61. {
  62. $this->enum = $enum;
  63. return $this;
  64. }
  65. // 设置文件格式 xlsx 或 csv
  66. public function setFileFormat($format)
  67. {
  68. $this->fileFormat = $format;
  69. return $this;
  70. }
  71. // 设置下载 默认不下载,返回文件路径
  72. public function setDownload()
  73. {
  74. $this->download = true;
  75. return $this;
  76. }
  77. // 设置字符串处理字段
  78. public function setToStrFields(array $toStrFields)
  79. {
  80. $this->toStrFields = $toStrFields;
  81. return $this;
  82. }
  83. /**
  84. * 后处理样式
  85. *
  86. * @param $styleWidth 列宽
  87. * @param $styleHeight 行高
  88. */
  89. public function setProcessStyle($styleWidth = null, $styleHeight = null)
  90. {
  91. $this->processStyle = true;
  92. $this->styleWidth = $styleWidth;
  93. if (!empty($styleHeight)) {
  94. $this->styleHeight = $styleHeight;
  95. }
  96. return $this;
  97. }
  98. // 格式化数据
  99. private function formatData($item, $headers, $dataRowStyle)
  100. {
  101. $rowData = [];
  102. if (is_object($item)) {
  103. $item = $item->toArray();
  104. }
  105. foreach ($headers as $key => $field) {
  106. // 处理关联模型
  107. $rowData[$key] = data_get($item, $field, '');
  108. // 处理枚举
  109. if (isset($this->enum[$field])) {
  110. $rowData[$key] = $this->enum[$field][$rowData[$key]] ?? '';
  111. }
  112. // 字符串处理
  113. if (in_array($field, $this->toStrFields)) {
  114. $rowData[$key] = "\t" . $rowData[$key];
  115. }
  116. }
  117. $dataRow = WriterEntityFactory::createRowFromArray($rowData, $dataRowStyle);
  118. return $dataRow;
  119. }
  120. // 格式化数据
  121. private function formatDataByArrayToExcel($item, $dataRowStyle)
  122. {
  123. $rowData = [];
  124. if (is_object($item)) {
  125. $item = $item->toArray();
  126. }
  127. foreach ($item as $key => $value) {
  128. $rowData[$key] = "\t" . $value;
  129. }
  130. $dataRow = WriterEntityFactory::createRowFromArray($rowData, $dataRowStyle);
  131. return $dataRow;
  132. }
  133. // 获取列范围(例如,从 A 到 AZ)
  134. public function getColumnRange($start, $end) {
  135. $columns = [];
  136. // 将列字母转换为大写
  137. $start = strtoupper($start);
  138. $end = strtoupper($end);
  139. // 辅助函数:将列名转换为对应的数字索引(例如 A -> 1, Z -> 26, AA -> 27)
  140. $convertToColumnIndex = function ($column) {
  141. $column = strtoupper($column);
  142. $length = strlen($column);
  143. $index = 0;
  144. for ($i = 0; $i < $length; $i++) {
  145. $index = $index * 26 + (ord($column[$i]) - ord('A') + 1);
  146. }
  147. return $index;
  148. };
  149. // 辅助函数:将列的数字索引转换为列名(例如 1 -> A, 27 -> AA)
  150. $convertToColumnName = function ($index) {
  151. $column = '';
  152. while ($index > 0) {
  153. $mod = ($index - 1) % 26;
  154. $column = chr($mod + ord('A')) . $column;
  155. $index = intval(($index - $mod) / 26);
  156. }
  157. return $column;
  158. };
  159. // 获取列的数字索引
  160. $startIndex = $convertToColumnIndex($start);
  161. $endIndex = $convertToColumnIndex($end);
  162. // 获取范围内的列名
  163. for ($i = $startIndex; $i <= $endIndex; $i++) {
  164. $columns[] = $convertToColumnName($i);
  165. }
  166. return $columns;
  167. }
  168. /**
  169. * 后处理样式,仅支持Xlsx格式,大量数据慎用
  170. * [ 安装PhpSpreadsheet扩展 composer require phpoffice/phpspreadsheet ]
  171. * @param String $filePath 文件路径
  172. * @return void
  173. */
  174. private function postProcessStyle($filePath)
  175. {
  176. if ($this->fileFormat != 'xlsx') {
  177. return;
  178. }
  179. ini_set('memory_limit', '800M'); // 设置临时内存限制 -1 为不限内存,可注释关闭
  180. // 创建读取器并设置相关属性
  181. $reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx');
  182. $reader->setReadDataOnly(true);
  183. $spreadsheet = $reader->load($filePath);
  184. $sheet = $spreadsheet->getActiveSheet();
  185. // 超过指定行不处理样式,防止内存溢出
  186. if ($sheet->getHighestRow() > $this->processStyleMaxRow) {
  187. return;
  188. }
  189. // 获取表头行(假设第一行为表头,可根据实际调整)
  190. $headerRow = 1;
  191. $headerRange = 'A' . $headerRow . ':' . $sheet->getHighestColumn() . $headerRow;
  192. // 设置表头样式
  193. $headerStyle = [
  194. 'alignment' => [
  195. 'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
  196. 'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER
  197. ],
  198. 'font' => [
  199. 'bold' => true,
  200. 'size' => 12
  201. ]
  202. ];
  203. $sheet->getStyle($headerRange)->applyFromArray($headerStyle);
  204. // 设置数据行样式
  205. $dataRowStyle = [
  206. 'alignment' => [
  207. 'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
  208. 'vertical' => \PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER,
  209. 'wrapText' => false
  210. ]
  211. ];
  212. // 逐行迭代处理数据(设置样式等)
  213. foreach ($sheet->getRowIterator() as $rowIndex => $row) {
  214. if ($rowIndex > 0) { // 跳过表头行
  215. $cellRange = 'A' . ($rowIndex + 1) . ':' . $sheet->getHighestColumn() . ($rowIndex + 1);
  216. $sheet->getStyle($cellRange)->applyFromArray($dataRowStyle);
  217. }
  218. // 设置行高
  219. if ($this->styleHeight) {
  220. $sheet->getRowDimension($rowIndex + 1)->setRowHeight($this->styleHeight);
  221. }
  222. // 释放当前行单元格对象引用
  223. foreach ($row->getCellIterator() as $cell) {
  224. $cell = null;
  225. }
  226. $row = null;
  227. }
  228. // 分块处理列宽设置(示例按10列一块,可根据实际调整)
  229. $columnChunks = array_chunk($this->getColumnRange('A', $sheet->getHighestColumn()), 10);
  230. foreach ($columnChunks as $chunk) {
  231. foreach ($chunk as $column) {
  232. if ($this->styleWidth) {
  233. $sheet->getColumnDimension($column)->setWidth($this->styleWidth);
  234. } else {
  235. $sheet->getColumnDimension($column)->setAutoSize(true);
  236. }
  237. }
  238. }
  239. // 保存修改后的表格
  240. $writer = \PhpOffice\PhpSpreadsheet\IOFactory::createWriter($spreadsheet, 'Xlsx');
  241. $writer->save($filePath);
  242. // 释放整个表格对象引用
  243. $spreadsheet = null;
  244. }
  245. /**
  246. * 使用 Spout 导出 Excel 文件
  247. *
  248. * @param string $fileName 导出的文件名(不包含路径)
  249. * @param string $directory 文件保存目录
  250. * @return array|bool 导出的文件完整 URL,失败时返回 false
  251. */
  252. public function spoutToExcel($fileName = "export", $directory = "exports/")
  253. {
  254. try {
  255. $headers = $this->headers;
  256. if (empty($headers)) {
  257. throw new \Exception('表头不能为空');
  258. }
  259. // 确保文件格式正确
  260. if (!in_array($this->fileFormat, ['csv', 'xlsx'])) {
  261. throw new \Exception('文件格式不正确');
  262. }
  263. // 确保文件名不为空
  264. if (empty($fileName)) {
  265. throw new \Exception('文件名不能为空');
  266. }
  267. if (empty($this->dataList) && empty($this->query)) {
  268. throw new \Exception('未设置数据或模型对象');
  269. }
  270. // 确保目录存在
  271. $directoryPath = $directory;
  272. if (!is_dir($directoryPath)) {
  273. File::makeDirectory($directoryPath, 0755, true);
  274. }
  275. // 生成文件名
  276. $uniqueId = uniqid();
  277. $fileExtension = $this->fileFormat === 'csv' ? 'csv' : 'xlsx';
  278. $fullFileName = "{$fileName}-{$uniqueId}.{$fileExtension}";
  279. $filePath = $directoryPath . '/' . $fullFileName;
  280. // 创建 Writer
  281. $writer = $this->fileFormat === 'csv'
  282. ? WriterEntityFactory::createCSVWriter()
  283. : WriterEntityFactory::createXLSXWriter();
  284. $writer->openToFile($filePath);
  285. // 定义样式
  286. $headerStyle = (new StyleBuilder())->setCellAlignment(CellAlignment::CENTER)->setFontBold()->setFontSize(12)->build();
  287. $dataRowStyle = (new StyleBuilder())->setCellAlignment(CellAlignment::CENTER)->setShouldWrapText(false)->build();
  288. // 写入表头
  289. $headerRow = WriterEntityFactory::createRowFromArray(array_keys($headers), $headerStyle);
  290. $writer->addRow($headerRow);
  291. // 数据列表导出
  292. if (!empty($this->dataList)) {
  293. foreach ($this->dataList as $item) {
  294. $dataRow = $this->formatData($item, $headers, $dataRowStyle);
  295. $writer->addRow($dataRow);
  296. }
  297. }
  298. if (!empty($this->query) && $this->handleType == 'chunk') {
  299. $this->query->chunk($this->chunkSize, function ($dataList) use ($writer, $headers, $dataRowStyle) {
  300. foreach ($dataList as $item) {
  301. $dataRow = $this->formatData($item, $headers, $dataRowStyle);
  302. $writer->addRow($dataRow);
  303. }
  304. });
  305. }
  306. if (!empty($this->query) && $this->handleType == 'cursor') {
  307. $this->query->cursor()->each(function ($item) use ($writer, $headers, $dataRowStyle) {
  308. $dataRow = $this->formatData($item, $headers, $dataRowStyle);
  309. $writer->addRow($dataRow);
  310. });
  311. }
  312. // 关闭文件
  313. $writer->close();
  314. // 后处理样式
  315. if ($this->processStyle) {
  316. $this->postProcessStyle($directory . $fullFileName);
  317. }
  318. if ($this->download) {
  319. return response()->download($filePath, $fullFileName);
  320. } else {
  321. $result = [
  322. 'name' => $fullFileName,
  323. 'path' => $directory . $fullFileName,
  324. 'url' => asset($directory . $fullFileName),
  325. ];
  326. return $result;
  327. }
  328. } catch (\Exception $e) {
  329. // 记录错误日志
  330. Log::error('SpoutExcel 导出失败:' . $e->getMessage());
  331. // 返回失败
  332. return false;
  333. }
  334. }
  335. /**
  336. * 使用 Spout 查询结果导出 Excel 文件
  337. *
  338. * @param string $fileName 导出的文件名(不包含路径)
  339. * @param string $directory 文件保存目录
  340. * @return array|bool 导出的文件完整 URL,失败时返回 false
  341. */
  342. public function arrayToExcel($fileName = "export", $directory = "exports/")
  343. {
  344. try {
  345. $headers = $this->headers;
  346. if (empty($headers)) {
  347. return [false, '表头不能为空', []];
  348. }
  349. // 确保文件格式正确
  350. if (!in_array($this->fileFormat, ['csv', 'xlsx'])) {
  351. return [false, '文件格式不正确', []];
  352. }
  353. // 确保文件名不为空
  354. if (empty($fileName)) {
  355. return [false, '文件名不能为空', []];
  356. }
  357. if (empty($this->dataList)) {
  358. return [false, '未设置数据或模型对象', []];
  359. }
  360. // 确保目录存在
  361. $directoryPath = $directory;
  362. if (!is_dir($directoryPath)) {
  363. File::makeDirectory($directoryPath, 0755, true);
  364. }
  365. // 生成文件名
  366. $uniqueId = uniqid();
  367. $fileExtension = $this->fileFormat === 'csv' ? 'csv' : 'xlsx';
  368. $fullFileName = "{$fileName}_{$uniqueId}.{$fileExtension}";
  369. $filePath = $directoryPath . '/' . $fullFileName;
  370. // 创建 Writer
  371. $writer = $this->fileFormat === 'csv'
  372. ? WriterEntityFactory::createCSVWriter()
  373. : WriterEntityFactory::createXLSXWriter();
  374. $writer->openToFile($filePath);
  375. // 定义样式
  376. $headerStyle = (new StyleBuilder())->setCellAlignment(CellAlignment::CENTER)->setFontBold()->setFontSize(12)->build();
  377. $dataRowStyle = (new StyleBuilder())->setCellAlignment(CellAlignment::CENTER)->setShouldWrapText(false)->build();
  378. // 写入表头
  379. $headerRow = WriterEntityFactory::createRowFromArray($headers, $headerStyle);
  380. $writer->addRow($headerRow);
  381. // 数据列表导出
  382. if (!empty($this->dataList)) {
  383. foreach ($this->dataList as $item) {
  384. $dataRow = $this->formatDataByArrayToExcel($item, $dataRowStyle);
  385. $writer->addRow($dataRow);
  386. }
  387. } else {
  388. return [false, '导出数据为空', []];
  389. }
  390. // 关闭文件
  391. $writer->close();
  392. // 后处理样式
  393. if ($this->processStyle) {
  394. $this->postProcessStyle($directory . $fullFileName);
  395. }
  396. if ($this->download) {
  397. return response()->download($filePath, $fullFileName);
  398. } else {
  399. $result = [
  400. 'name' => $fullFileName,
  401. 'path' => $directory . $fullFileName,
  402. 'url' => asset($directory . $fullFileName),
  403. ];
  404. return [true, '导出成功', $result];
  405. }
  406. } catch (\Exception $e) {
  407. // 记录错误日志
  408. Log::error('SpoutExcel 导出失败:' . $e->getMessage());
  409. // 返回失败
  410. return [false, 'SpoutExcel 导出异常', []];
  411. }
  412. }
  413. }