签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
+143 -187
View File
@@ -1,7 +1,7 @@
# 7.4 数据导入导出
:::tip 概述
ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入导出和分批导出(大数据量)。导入支持表头映射,导出支持枚举显示名自动翻译。
`ExcelService` 基于 PhpSpreadsheet(`phpoffice/phpspreadsheet ^5.10`)封装导入导出能力:`importWithMap` 表头映射导入、`export` 一次性导出、`chunkExport` 分批导出(大数据量)。导入支持枚举文字反查,导出支持字典显示名自动翻译。
:::
## 导入流程
@@ -12,38 +12,41 @@ ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入
前端上传 Excel 文件
│
▼
Controller::import()
│ 校验文件扩展名(xls/xlsx)
Controller::_import($service) ← 基类快捷方法
│ 1. 文件存在校验("请选择文件")
│ 2. 扩展名校验(仅 xls / xlsx)
│ 3. 大小校验(10MB 上限)
│ 4. MIME 类型真实检测(finfo)
▼
ExcelService::importWithMap($filePath, $headerMap)
│ 读取 Excel → 按表头映射转为关联数组
▼
Logic::import()
Service::import($filePath)
│
├─ ExcelService::importWithMap($filePath, $headerMap)
│ 读取活动工作表 → 第一行表头按 headerMap 映射为字段名
│ 第二行起逐行取值,跳过整行为空的行
│
├─ 枚举字段反向映射(文字 → 数字)
│ DictService::getValue('gender', '男') → '1'
│ DictCacheService::getValue('example_status', '在用') → '1'
│
├─ 逐行调用 $this->add($row)
│ ├─ 成功 → 计数 +1
│ └─ 失败 → 收集错误(行号 + 原因)
│ └─ 失败 → 收集错误(行号 + 字段 + 原因),不中断整体
│
▼
返回 { count: 成功数, errors: [失败行列表] }
```
### Logic 层实现
### Service 层实现(来自项目实际代码)
```php
// ExampleLogic::import()
// ExampleService::import()
public function import(string $filePath): array
{
// 1. 定义表头映射:Excel 表头 → 数据库字段
$headerMap = [
'案例名称' => 'name',
'案例类型' => 'type',
'案例状态' => 'status',
'案例排序' => 'sort',
'名称' => 'name',
'状态' => 'status',
'排序' => 'sort',
];
// 2. 解析 Excel 为关联数组
@@ -51,11 +54,8 @@ public function import(string $filePath): array
// 3. 枚举字段反向映射:文字 → 数字
foreach ($data as &$row) {
if (!empty($row['type']) && !is_numeric($row['type'])) {
$row['type'] = DictService::getValue('example_type', $row['type']);
}
if (!empty($row['status']) && !is_numeric($row['status'])) {
$row['status'] = DictService::getValue('example_status', $row['status']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
unset($row);
@@ -65,11 +65,12 @@ public function import(string $filePath): array
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row); // 调用 BaseLogic::add(),触发 beforeAdd/validateData/afterAdd
$this->add($row); // 走完整 add 流程(钩子 / 唯一校验 / 事务)
$count++;
} catch (\Exception $e) {
$errors[] = [
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
'row' => $index + 2, // +2 因为第 1 行是表头,索引从 0 开始
'name' => $row['name'] ?? '',
'reason' => $e->getMessage(),
];
}
@@ -79,39 +80,26 @@ public function import(string $filePath): array
}
```
### Controller 层实现
### Controller 层实现(基类快捷方法)
```php
// ExampleController::import()
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
#[Permission('sys:example:import', '导入案例演示')]
public function import(): Json
// 控制器无需手写文件校验 —— _import() 已内置
#[OperationLog('示例管理-导入数据', OperationLog::TYPE_IMPORT, '通过Excel导入示例数据')]
#[Permission('sys:example:import', '导入示例')]
public function import(): JsonResponse
{
$file = $this->request->file('file');
if (!$file) {
return $this->fail('请选择文件');
}
$ext = strtolower($file->getOriginalExtension());
if (!in_array($ext, ['xls', 'xlsx'])) {
return $this->fail('仅支持 xls、xlsx 格式的文件');
}
try {
$result = $this->logic->import($file->getRealPath());
$msg = empty($result['errors'])
? "成功导入 {$result['count']} 条数据"
: "成功导入 {$result['count']} 条,失败 " . count($result['errors']) . " 条";
return $this->success($result, $msg);
} catch (\Exception $e) {
return $this->fail('导入失败: ' . $e->getMessage());
}
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` 是否为空组装成功/部分失败消息
### 导入响应示例
**全部成功:**
@@ -120,10 +108,7 @@ public function import(): Json
{
"code": 0,
"msg": "成功导入 10 条数据",
"data": {
"count": 10,
"errors": []
}
"data": { "count": 10, "errors": [] }
}
```
@@ -136,8 +121,8 @@ public function import(): Json
"data": {
"count": 8,
"errors": [
{ "row": 3, "reason": "标题不能为空" },
{ "row": 7, "reason": "编码已存在" }
{ "row": 3, "name": "示例A", "reason": "名称已存在" },
{ "row": 7, "name": "", "reason": "案例名称不能为空" }
]
}
}
@@ -145,79 +130,89 @@ public function import(): Json
## 导出流程
### 分批导出(推荐)
### 一次性导出(中小数据量,来自项目实际代码)
```php
// ExampleLogic::export()
// ExampleService::export()
public function export(array $params = []): string
{
// 1. 定义导出表头:字段名 → Excel 表头
// 枚举字段使用 {字段名}Text(自动翻译后的字段)
$list = $this->allList($params);
$headers = [
'name' => '案例名称',
'typeText' => '案例类型', // 枚举显示名
'statusText' => '案例状态', // 枚举显示名
'sort' => '案例排序',
'create_time' => '创建时间',
'name' => '名称',
'statusText' => '状态', // 枚举显示名(serializeMaps 已补全)
'sort' => '排序',
'create_user' => '创建人',
'create_time' => '创建时间',
];
// 2. 分批导出(每批 500 条)
return ExcelService::chunkExport($headers, function (int $page) use ($params) {
$chunk = [];
$this->chunkList($params, function (array $data) use (&$chunk) {
$chunk = $data;
}, 500);
return $chunk;
}, '案例演示数据.xlsx', 500);
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
// ExampleController::export()
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
#[Permission('sys:example:export', '导出案例演示')]
public function export(): Json
#[OperationLog('示例管理-导出数据', OperationLog::TYPE_EXPORT)]
#[Permission('sys:example:export', '导出示例')]
public function export(): JsonResponse
{
try {
$filePath = $this->logic->export($this->getParams());
return $this->success($filePath, '导出成功');
} catch (\Exception $e) {
return $this->fail('导出失败: ' . $e->getMessage());
}
return $this->_export($this->exampleService); // 返回文件地址
}
```
### 一次性导出(小数据量)
### 导出文件
```php
// 适用于数据量较小(< 1000 条)的场景
$data = $this->allList($params);
return ExcelService::export($data, $headers, '导出数据.xlsx');
- **存放位置**:`{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 方法清单
| 方法 | 说明 | 参数 | 返回值 |
|------|------|------|--------|
| `import($filePath, $sheetIndex, $startRow)` | 原始导入 | 文件路径, Sheet索引, 起始行 | `array` 二维数组(数字下标) |
| `importWithMap($filePath, $headerMap)` | 映射导入 | 文件路径, 表头映射 | `array` 关联数组列表 |
| `export($data, $headers, $fileName)` | 一次性导出 | 数据, 表头映射, 文件名 | `string` 文件 URL |
| `chunkExport($headers, $callback, $fileName, $pageSize)` | 分批导出 | 表头映射, 回调函数, 文件名, 每批数量 | `string` 文件 URL |
| 方法 | 说明 | 返回值 |
|------|------|--------|
| `importWithMap($filePath, $headerMap)` | 表头映射导入(未声明的列忽略、缺少的列补空串、跳过空行) | `array` `[字段名 => 值, ...]` |
| `export($data, $headers, $fileName)` | 一次性导出 | `string` 文件 URL |
| `chunkExport($headers, $chunkProvider, $fileName)` | 分批导出(回调 `fn(int $page): array`,返回空数组终止) | `string` 文件 URL |
### importWithMap 参数详解
```php
ExcelService::importWithMap($filePath, $headerMap);
```
| 参数 | 类型 | 说明 |
|------|------|------|
| `$filePath` | string | Excel 文件的绝对路径 |
| `$headerMap` | array | `[Excel表头 => 数据库字段]` 映射 |
| `$headerMap` | array | `['Excel表头' => '数据库字段']` 映射 |
**表头映射示例:**
@@ -232,26 +227,21 @@ $headerMap = [
### chunkExport 参数详解
```php
ExcelService::chunkExport($headers, $callback, $fileName, $pageSize);
```
| 参数 | 类型 | 说明 |
|------|------|------|
| `$headers` | array | `[字段名 => Excel表头]` 映射 |
| `$callback` | callable | 分批回调,接收 `$page` 参数,返回该批数据 |
| `$fileName` | string | 导出文件名 |
| `$pageSize` | int | 每批数据量(默认 500) |
| `$headers` | array | `['字段名' => '中文表头']` 映射 |
| `$chunkProvider` | callable | 分页取数回调 `fn(int $page): array`,返回空数组终止 |
| `$fileName` | string | 导出文件名(默认 `export.xlsx`) |
## 枚举字段处理
### 导入时:文字 → 值
```php
// Excel 中"男" → 数据库中 1
// Excel 中"在用" → 数据库中 1
foreach ($data as &$row) {
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
$row['gender'] = DictService::getValue('gender', $row['gender']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('example_status', $row['status']);
}
}
```
@@ -259,21 +249,13 @@ foreach ($data as &$row) {
### 导出时:值 → 文字
```php
// 配置 serializeMaps 后自动翻译
/**
* 枚举显示名映射
*
* @var array
*/
// 配置 serializeMaps 后自动翻译(HasSerializeMaps)
protected array $serializeMaps = [
'gender' => 'gender',
'status' => 'example_status',
];
/**
* 导出表头:使用 Text 后缀字段,自动翻译枚举值
*/
// 导出表头:使用 Text 后缀字段,自动翻译枚举值
$headers = [
'name' => '姓名',
'genderText' => '性别',
@@ -286,8 +268,7 @@ $headers = [
### 导入
```php
// UserLogic::import()
// UserService::import()(形态示例)
public function import(string $filePath): array
{
$headerMap = [
@@ -303,72 +284,41 @@ public function import(string $filePath): array
// 枚举反向映射
foreach ($data as &$row) {
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
$row['gender'] = DictService::getValue('gender', $row['gender']);
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'] = DictService::getValue('user_status', $row['status']);
if (! empty($row['status']) && ! is_numeric($row['status'])) {
$row['status'] = DictCacheService::getValue('user_status', $row['status']);
}
}
unset($row);
$count = 0;
$errors = [];
foreach ($data as $index => $row) {
try {
$this->add($row);
$count++;
} catch (\Exception $e) {
$errors[] = [
'row' => $index + 2,
'username' => $row['username'] ?? '',
'reason' => $e->getMessage(),
];
}
}
return ['count' => $count, 'errors' => $errors];
// 逐行 add(),失败明细汇总(同 ExampleService::import 模式)
// ...
}
```
### 导出
```php
// UserLogic::export()
// UserService::export()(形态示例)
public function export(array $params = []): string
{
$headers = [
'username' => '用户名',
'realname' => '姓名',
'genderText' => '性别',
'mobile' => '手机号',
'email' => '邮箱',
'statusText' => '状态',
'deptName' => '部门',
'levelName' => '职级',
'positionName'=> '岗位',
'create_time' => '创建时间',
'username' => '用户名',
'realname' => '姓名',
'genderText' => '性别',
'mobile' => '手机号',
'email' => '邮箱',
'statusText' => '状态',
'deptName' => '部门',
'levelName' => '职级',
'positionName' => '岗位',
'create_time' => '创建时间',
];
// 预加载名称映射(避免 N+1 查询)
$levelMap = Level::getNameMap();
$positionMap = Position::getNameMap();
$deptMap = Dept::getNameMap();
return ExcelService::chunkExport($headers, function (int $page) use ($params, $levelMap, $positionMap, $deptMap) {
$chunk = [];
$this->chunkList($params, function (array $data) use (&$chunk, $levelMap, $positionMap, $deptMap) {
foreach ($data as &$item) {
$item['deptName'] = $deptMap[$item['dept_id']] ?? '';
$item['levelName'] = $levelMap[$item['level_id']] ?? '';
$item['positionName'] = $positionMap[$item['position_id']] ?? '';
}
unset($item);
$chunk = $data;
}, 500);
return $chunk;
}, '用户数据.xlsx', 500);
// afterPageList 已批量补全 deptName/levelName/positionName
return ExcelService::export($this->allList($params), $headers, '用户数据.xlsx');
}
```
@@ -382,7 +332,6 @@ public function export(array $params = []): string
:action="'/api/example/import'"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImportSuccess"
:on-error="handleImportError"
accept=".xls,.xlsx"
>
<el-button type="primary">导入</el-button>
@@ -397,9 +346,9 @@ const handleImportSuccess = (response) => {
ElMessage.success(`成功导入 ${count} 条数据`);
} else {
ElMessage.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
// 可以展示错误详情
// 可展开 errors 查看行号与原因
}
refreshTable(); // 刷新列表
refreshTable();
}
};
</script>
@@ -410,8 +359,8 @@ const handleImportSuccess = (response) => {
```typescript
// 导出接口返回文件 URL,前端直接下载
const handleExport = async () => {
const res = await exportExample(params);
window.open(res); // res 是文件 URL
const res = await exampleExport(params); // 文件地址
window.open(res);
};
```
@@ -420,32 +369,39 @@ const handleExport = async () => {
| 场景 | 方式 | 说明 |
|------|------|------|
| 数据量 < 1000 | `export()` 一次性导出 | 简单直接 |
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 避免内存溢出 |
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行 |
| 关联字段导出 | 预加载名称映射 | 避免 N+1 查询 |
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 回调按页取数,避免内存溢出 |
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行(事务隔离) |
| 关联字段导出 | 依赖 `afterPageList` 补全名称 | 批量映射避免 N+1 查询 |
| PHP 内存 | `memory_limit=256M` | Docker 镜像已配置 |
## 常见问题
### 问题 1:导入提示"文件格式不正确"
### 问题 1:导入提示"仅支持 xls、xlsx 格式的文件"
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`。
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`(如 csv、xlsx.zip)。
**解决:** Controller 中校验扩展名,前端 `accept=".xls,.xlsx"` 限制文件类型。
**解决:** 前端 `accept=".xls,.xlsx"` 限制文件类型;`_import()` 会依次校验扩展名、大小(10MB)、MIME。
### 问题 2:导入时中文乱码
**原因:** Excel 文件编码问题(旧版 .xls 可能是 GBK 编码)。
**原因:** 旧版 `.xls` 可能是 GBK 编码。
**解决:** PhpSpreadsheet 自动处理编码,通常不需要手动处理。如仍有问题,检查 Excel 文件是否损坏。
**解决:** PhpSpreadsheet 自动处理编码,通常无需手动处理;优先使用 `.xlsx` 格式。
### 问题 3:导出内存溢出
**原因:** 使用 `export()` 一次性导出大数据量。
**解决:** 改用 `chunkExport()` 分批导出,每批 500 条。
**解决:** 改用 `chunkExport()` 分批导出(回调按页取数,取空自动终止)。
### 问题 4:枚举字段导出为数字
**原因:** 导出表头未使用 `Text` 后缀字段。
**解决:** 确保 Logic 中配置了 `serializeMaps`,导出表头使用 `{字段名}Text`。
**解决:** 确认 Service 中配置了 `serializeMaps`(且 `use HasSerializeMaps`),导出表头使用 `{字段名}Text`。
### 问题 5:导入成功但下拉框值显示异常
**原因:** Excel 中的枚举文字在字典里不存在。
**解决:** `DictCacheService::getValue()` 反查不到会保留原值——检查字典项标签与 Excel 文字是否完全一致(含空格)。