Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_NaiveVue/zh/tools/excel.md
T
2026-10-08 17:50:29 +08:00

408 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<template>
<el-upload
:action="'/api/example/import'"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImportSuccess"
accept=".xls,.xlsx"
>
<el-button type="primary">导入</el-button>
</el-upload>
</template>
<script setup>
const handleImportSuccess = (response) => {
if (response.code === 0) {
const { count, errors } = response.data;
if (errors.length === 0) {
ElMessage.success(`成功导入 ${count} 条数据`);
} else {
ElMessage.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
// 可展开 errors 查看行号与原因
}
refreshTable();
}
};
</script>
```
### 导出
```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 文字是否完全一致(含空格)。