# 7.4 数据导入导出 :::tip 概述 `ExcelService` 基于 PhpSpreadsheet(`phpoffice/phpspreadsheet ^5.10`)封装导入导出能力:`importWithMap` 表头映射导入、`export` 一次性导出、`chunkExport` 分批导出(大数据量)。导入支持枚举文字反查,导出支持字典显示名自动翻译。 ::: ## 导入流程 ### 完整流程图 ```text 前端上传 Excel 文件 │ ▼ Controller::_import($service) ← 基类快捷方法 │ 1. 文件存在校验("请选择文件") │ 2. 扩展名校验(仅 xls / xlsx) │ 3. 大小校验(10MB 上限) │ 4. MIME 类型真实检测(finfo) ▼ Service::import($filePath) │ ├─ ExcelService::importWithMap($filePath, $headerMap) │ 读取活动工作表 → 第一行表头按 headerMap 映射为字段名 │ 第二行起逐行取值,跳过整行为空的行 │ ├─ 枚举字段反向映射(文字 → 数字) │ DictCacheService::getValue('example_status', '在用') → '1' │ ├─ 逐行调用 $this->add($row) │ ├─ 成功 → 计数 +1 │ └─ 失败 → 收集错误(行号 + 字段 + 原因),不中断整体 │ ▼ 返回 { count: 成功数, errors: [失败行列表] } ``` ### Service 层实现(来自项目实际代码) ```php // ExampleService::import() public function import(string $filePath): array { // 1. 定义表头映射:Excel 表头 → 数据库字段 $headerMap = [ '名称' => 'name', '状态' => 'status', '排序' => 'sort', ]; // 2. 解析 Excel 为关联数组 $data = ExcelService::importWithMap($filePath, $headerMap); // 3. 枚举字段反向映射:文字 → 数字 foreach ($data as &$row) { if (! empty($row['status']) && ! is_numeric($row['status'])) { $row['status'] = DictCacheService::getValue('example_status', $row['status']); } } unset($row); // 4. 逐行导入 $count = 0; $errors = []; foreach ($data as $index => $row) { try { $this->add($row); // 走完整 add 流程(钩子 / 唯一校验 / 事务) $count++; } catch (\Exception $e) { $errors[] = [ 'row' => $index + 2, // +2 因为第 1 行是表头,索引从 0 开始 'name' => $row['name'] ?? '', 'reason' => $e->getMessage(), ]; } } return ['count' => $count, 'errors' => $errors]; } ``` ### Controller 层实现(基类快捷方法) ```php // 控制器无需手写文件校验 —— _import() 已内置 #[OperationLog('示例管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入示例数据')] #[Permission('sys:example:import', '导入示例')] public function import(): JsonResponse { return $this->_import($this->exampleService); } ``` `Controller::_import()` 内置校验顺序(见 [3.6 控制器开发](/zh/guide/controller.html)): 1. 文件是否存在(`"请选择文件"`) 2. 扩展名白名单(仅 `xls`、`xlsx`) 3. 大小上限(10MB) 4. MIME 真实检测(`finfo`,防伪造扩展名) 5. 调用 `Service::import()`,按 `errors` 是否为空组装成功/部分失败消息 ### 导入响应示例 **全部成功:** ```json { "code": 0, "msg": "成功导入 10 条数据", "data": { "count": 10, "errors": [] } } ``` **部分失败:** ```json { "code": 0, "msg": "成功导入 8 条,失败 2 条", "data": { "count": 8, "errors": [ { "row": 3, "name": "示例A", "reason": "名称已存在" }, { "row": 7, "name": "", "reason": "案例名称不能为空" } ] } } ``` ## 导出流程 ### 一次性导出(中小数据量,来自项目实际代码) ```php // ExampleService::export() public function export(array $params = []): string { $list = $this->allList($params); $headers = [ 'name' => '名称', 'statusText' => '状态', // 枚举显示名(serializeMaps 已补全) 'sort' => '排序', 'create_user' => '创建人', 'create_time' => '创建时间', ]; return ExcelService::export($list, $headers, '示例数据.xlsx'); } ``` ### 分批导出(大数据量) ```php // ExcelService::chunkExport —— 数据由回调按页提供,取空即终止 return ExcelService::chunkExport( $headers, function (int $page) use ($params): array { // 按页取数(示例:每页 500 条,返回空数组即导出结束) $list = $this->pageList(array_merge($params, ['pageNo' => $page, 'pageSize' => 500])); return $list['records'] ?? []; }, '导出数据.xlsx' ); ``` :::tip 终止条件 `chunkExport` 的终止条件是 **`$chunkProvider` 返回空数组**——不依赖条数与页大小的关系,过滤后数量不固定也能正确处理。 ::: ### Controller 层实现 ```php #[OperationLog('示例管理-导出数据', OperationLog::TYPE_EXPORT)] #[Permission('sys:example:export', '导出示例')] public function export(): JsonResponse { return $this->_export($this->exampleService); // 返回文件地址 } ``` ### 导出文件 - **存放位置**:`{upload_dir}/temp/{Ymd}/`(`upload_dir` 取 `config/file.php`,支持绝对路径) - **文件名**:`原文件名_YmdHis.xlsx`,同秒重复导出自动追加 `_1`、`_2` 序号,避免同名文件被占用 - **返回值**:`file.domain_url` 拼接的完整地址,接口 `data` 直接返回该地址字符串,前端 `window.open(res)` 即可下载 **导出响应示例:** ```json { "code": 0, "ok": true, "msg": "导出成功", "data": "http://file.laravel13.elevue/temp/20261006/用户数据_20261006050143.xlsx" } ``` ## ExcelService 方法清单 | 方法 | 说明 | 返回值 | |------|------|--------| | `importWithMap($filePath, $headerMap)` | 表头映射导入(未声明的列忽略、缺少的列补空串、跳过空行) | `array` `[字段名 => 值, ...]` | | `export($data, $headers, $fileName)` | 一次性导出 | `string` 文件 URL | | `chunkExport($headers, $chunkProvider, $fileName)` | 分批导出(回调 `fn(int $page): array`,返回空数组终止) | `string` 文件 URL | ### importWithMap 参数详解 | 参数 | 类型 | 说明 | |------|------|------| | `$filePath` | string | Excel 文件的绝对路径 | | `$headerMap` | array | `['Excel表头' => '数据库字段']` 映射 | **表头映射示例:** ```php $headerMap = [ '用户名' => 'username', // Excel 中"用户名"列 → $row['username'] '姓名' => 'realname', // Excel 中"姓名"列 → $row['realname'] '性别' => 'gender', // Excel 中"性别"列 → $row['gender'] '手机号' => 'mobile', // Excel 中"手机号"列 → $row['mobile'] ]; ``` ### chunkExport 参数详解 | 参数 | 类型 | 说明 | |------|------|------| | `$headers` | array | `['字段名' => '中文表头']` 映射 | | `$chunkProvider` | callable | 分页取数回调 `fn(int $page): array`,返回空数组终止 | | `$fileName` | string | 导出文件名(默认 `export.xlsx`) | ## 枚举字段处理 ### 导入时:文字 → 值 ```php // Excel 中"在用" → 数据库中 1 foreach ($data as &$row) { if (! empty($row['status']) && ! is_numeric($row['status'])) { $row['status'] = DictCacheService::getValue('example_status', $row['status']); } } ``` ### 导出时:值 → 文字 ```php // 配置 serializeMaps 后自动翻译(HasSerializeMaps) protected array $serializeMaps = [ 'gender' => 'gender', 'status' => 'example_status', ]; // 导出表头:使用 Text 后缀字段,自动翻译枚举值 $headers = [ 'name' => '姓名', 'genderText' => '性别', 'statusText' => '状态', ]; ``` ## 完整示例:用户导入导出 ### 导入 ```php // UserService::import()(形态示例) public function import(string $filePath): array { $headerMap = [ '用户名' => 'username', '姓名' => 'realname', '性别' => 'gender', '手机号' => 'mobile', '邮箱' => 'email', '状态' => 'status', ]; $data = ExcelService::importWithMap($filePath, $headerMap); // 枚举反向映射 foreach ($data as &$row) { if (! empty($row['gender']) && ! is_numeric($row['gender'])) { $row['gender'] = DictCacheService::getValue('gender', $row['gender']); } if (! empty($row['status']) && ! is_numeric($row['status'])) { $row['status'] = DictCacheService::getValue('user_status', $row['status']); } } unset($row); // 逐行 add(),失败明细汇总(同 ExampleService::import 模式) // ... } ``` ### 导出 ```php // UserService::export()(形态示例) public function export(array $params = []): string { $headers = [ 'username' => '用户名', 'realname' => '姓名', 'genderText' => '性别', 'mobile' => '手机号', 'email' => '邮箱', 'statusText' => '状态', 'deptName' => '部门', 'levelName' => '职级', 'positionName' => '岗位', 'create_time' => '创建时间', ]; // afterPageList 已批量补全 deptName/levelName/positionName return ExcelService::export($this->allList($params), $headers, '用户数据.xlsx'); } ``` ## 前端集成 ### 导入 ```vue ``` ### 导出 ```typescript // 导出接口返回文件 URL,前端直接下载 const handleExport = async () => { const res = await exampleExport(params); // 文件地址 window.open(res); }; ``` ## 性能建议 | 场景 | 方式 | 说明 | |------|------|------| | 数据量 < 1000 | `export()` 一次性导出 | 简单直接 | | 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 回调按页取数,避免内存溢出 | | 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行(事务隔离) | | 关联字段导出 | 依赖 `afterPageList` 补全名称 | 批量映射避免 N+1 查询 | | PHP 内存 | `memory_limit=256M` | Docker 镜像已配置 | ## 常见问题 ### 问题 1:导入提示"仅支持 xls、xlsx 格式的文件" **原因:** 文件扩展名不是 `.xls` 或 `.xlsx`(如 csv、xlsx.zip)。 **解决:** 前端 `accept=".xls,.xlsx"` 限制文件类型;`_import()` 会依次校验扩展名、大小(10MB)、MIME。 ### 问题 2:导入时中文乱码 **原因:** 旧版 `.xls` 可能是 GBK 编码。 **解决:** PhpSpreadsheet 自动处理编码,通常无需手动处理;优先使用 `.xlsx` 格式。 ### 问题 3:导出内存溢出 **原因:** 使用 `export()` 一次性导出大数据量。 **解决:** 改用 `chunkExport()` 分批导出(回调按页取数,取空自动终止)。 ### 问题 4:枚举字段导出为数字 **原因:** 导出表头未使用 `Text` 后缀字段。 **解决:** 确认 Service 中配置了 `serializeMaps`(且 `use HasSerializeMaps`),导出表头使用 `{字段名}Text`。 ### 问题 5:导入成功但下拉框值显示异常 **原因:** Excel 中的枚举文字在字典里不存在。 **解决:** `DictCacheService::getValue()` 反查不到会保留原值——检查字典项标签与 Excel 文字是否完全一致(含空格)。