466 lines
12 KiB
Markdown
466 lines
12 KiB
Markdown
# 7.4 数据导入导出
|
||
|
||
:::tip 概述
|
||
ExcelService 基于 PhpSpreadsheet 封装导入导出能力,支持普通导入导出和分批导出(大数据量)。导入支持表头映射,导出支持枚举显示名自动翻译。
|
||
:::
|
||
|
||
## 导入流程
|
||
|
||
### 完整流程图
|
||
|
||
```text
|
||
前端上传 Excel 文件
|
||
│
|
||
▼
|
||
Controller::import()
|
||
│ 校验文件扩展名(xls/xlsx)
|
||
▼
|
||
ExcelService::importWithMap($filePath, $headerMap)
|
||
│ 读取 Excel → 按表头映射转为关联数组
|
||
▼
|
||
Logic::import()
|
||
│
|
||
├─ 枚举字段反向映射(文字 → 数字)
|
||
│ DictService::getValue('gender', '男') → '1'
|
||
│
|
||
├─ 逐行调用 $this->add($row)
|
||
│ ├─ 成功 → 计数 +1
|
||
│ └─ 失败 → 收集错误(行号 + 原因)
|
||
│
|
||
▼
|
||
返回 { count: 成功数, errors: [失败行列表] }
|
||
```
|
||
|
||
### Logic 层实现
|
||
|
||
```php
|
||
// ExampleLogic::import()
|
||
|
||
public function import(string $filePath): array
|
||
{
|
||
// 1. 定义表头映射:Excel 表头 → 数据库字段
|
||
$headerMap = [
|
||
'案例名称' => 'name',
|
||
'案例类型' => 'type',
|
||
'案例状态' => 'status',
|
||
'案例排序' => 'sort',
|
||
];
|
||
|
||
// 2. 解析 Excel 为关联数组
|
||
$data = ExcelService::importWithMap($filePath, $headerMap);
|
||
|
||
// 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']);
|
||
}
|
||
}
|
||
unset($row);
|
||
|
||
// 4. 逐行导入
|
||
$count = 0;
|
||
$errors = [];
|
||
foreach ($data as $index => $row) {
|
||
try {
|
||
$this->add($row); // 调用 BaseLogic::add(),触发 beforeAdd/validateData/afterAdd
|
||
$count++;
|
||
} catch (\Exception $e) {
|
||
$errors[] = [
|
||
'row' => $index + 2, // +2 因为第1行是表头,索引从0开始
|
||
'reason' => $e->getMessage(),
|
||
];
|
||
}
|
||
}
|
||
|
||
return ['count' => $count, 'errors' => $errors];
|
||
}
|
||
```
|
||
|
||
### Controller 层实现
|
||
|
||
```php
|
||
// ExampleController::import()
|
||
|
||
#[Log('案例演示-导入数据', Log::TYPE_IMPORT)]
|
||
#[Permission('sys:example:import', '导入案例演示')]
|
||
public function import(): Json
|
||
{
|
||
$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());
|
||
}
|
||
}
|
||
```
|
||
|
||
### 导入响应示例
|
||
|
||
**全部成功:**
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"msg": "成功导入 10 条数据",
|
||
"data": {
|
||
"count": 10,
|
||
"errors": []
|
||
}
|
||
}
|
||
```
|
||
|
||
**部分失败:**
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"msg": "成功导入 8 条,失败 2 条",
|
||
"data": {
|
||
"count": 8,
|
||
"errors": [
|
||
{ "row": 3, "reason": "标题不能为空" },
|
||
{ "row": 7, "reason": "编码已存在" }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
## 导出流程
|
||
|
||
### 分批导出(推荐)
|
||
|
||
```php
|
||
// ExampleLogic::export()
|
||
|
||
public function export(array $params = []): string
|
||
{
|
||
// 1. 定义导出表头:字段名 → Excel 表头
|
||
// 枚举字段使用 {字段名}Text(自动翻译后的字段)
|
||
$headers = [
|
||
'name' => '案例名称',
|
||
'typeText' => '案例类型', // 枚举显示名
|
||
'statusText' => '案例状态', // 枚举显示名
|
||
'sort' => '案例排序',
|
||
'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);
|
||
}
|
||
```
|
||
|
||
### Controller 层实现
|
||
|
||
```php
|
||
// ExampleController::export()
|
||
|
||
#[Log('案例演示-导出数据', Log::TYPE_EXPORT)]
|
||
#[Permission('sys:example:export', '导出案例演示')]
|
||
public function export(): Json
|
||
{
|
||
try {
|
||
$filePath = $this->logic->export($this->getParams());
|
||
return $this->success($filePath, '导出成功');
|
||
} catch (\Exception $e) {
|
||
return $this->fail('导出失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
```
|
||
|
||
### 一次性导出(小数据量)
|
||
|
||
```php
|
||
// 适用于数据量较小(< 1000 条)的场景
|
||
$data = $this->allList($params);
|
||
return ExcelService::export($data, $headers, '导出数据.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 参数详解
|
||
|
||
```php
|
||
ExcelService::importWithMap($filePath, $headerMap);
|
||
```
|
||
|
||
| 参数 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `$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 参数详解
|
||
|
||
```php
|
||
ExcelService::chunkExport($headers, $callback, $fileName, $pageSize);
|
||
```
|
||
|
||
| 参数 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `$headers` | array | `[字段名 => Excel表头]` 映射 |
|
||
| `$callback` | callable | 分批回调,接收 `$page` 参数,返回该批数据 |
|
||
| `$fileName` | string | 导出文件名 |
|
||
| `$pageSize` | int | 每批数据量(默认 500) |
|
||
|
||
## 枚举字段处理
|
||
|
||
### 导入时:文字 → 值
|
||
|
||
```php
|
||
// Excel 中"男" → 数据库中 1
|
||
foreach ($data as &$row) {
|
||
if (!empty($row['gender']) && !is_numeric($row['gender'])) {
|
||
$row['gender'] = DictService::getValue('gender', $row['gender']);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 导出时:值 → 文字
|
||
|
||
```php
|
||
// 配置 serializeMaps 后自动翻译
|
||
|
||
/**
|
||
* 枚举显示名映射
|
||
*
|
||
* @var array
|
||
*/
|
||
protected array $serializeMaps = [
|
||
'gender' => 'gender',
|
||
'status' => 'example_status',
|
||
];
|
||
|
||
/**
|
||
* 导出表头:使用 Text 后缀字段,自动翻译枚举值
|
||
*/
|
||
$headers = [
|
||
'name' => '姓名',
|
||
'genderText' => '性别',
|
||
'statusText' => '状态',
|
||
];
|
||
```
|
||
|
||
## 完整示例:用户导入导出
|
||
|
||
### 导入
|
||
|
||
```php
|
||
// UserLogic::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'] = DictService::getValue('gender', $row['gender']);
|
||
}
|
||
if (!empty($row['status']) && !is_numeric($row['status'])) {
|
||
$row['status'] = DictService::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];
|
||
}
|
||
```
|
||
|
||
### 导出
|
||
|
||
```php
|
||
// UserLogic::export()
|
||
|
||
public function export(array $params = []): string
|
||
{
|
||
$headers = [
|
||
'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);
|
||
}
|
||
```
|
||
|
||
## 前端集成
|
||
|
||
### 导入
|
||
|
||
```vue
|
||
<template>
|
||
<a-upload
|
||
:action="'/api/example/import'"
|
||
:headers="{ Authorization: `Bearer ${token}` }"
|
||
:before-upload="beforeUpload"
|
||
:show-upload-list="false"
|
||
accept=".xls,.xlsx"
|
||
@change="handleImportChange"
|
||
>
|
||
<a-button type="primary">导入</a-button>
|
||
</a-upload>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { message } from 'ant-design-vue';
|
||
|
||
// 文件校验:非 xlsx/xls 直接阻止
|
||
const beforeUpload = (file) => {
|
||
const isXlsx = /\.(xlsx|xls)$/i.test(file.name);
|
||
if (!isXlsx) {
|
||
message.warning('只能上传 xls、xlsx 文件');
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// onChange 回调中从 file.response 取上传结果
|
||
const handleImportChange = ({ file }) => {
|
||
if (file.response?.code === 0) {
|
||
const { count, errors } = file.response.data;
|
||
if (errors.length === 0) {
|
||
message.success(`成功导入 ${count} 条数据`);
|
||
} else {
|
||
message.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
|
||
// 可以展示错误详情
|
||
}
|
||
refreshTable(); // 刷新列表
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
### 导出
|
||
|
||
```typescript
|
||
// 导出接口返回文件 URL,前端直接下载
|
||
const handleExport = async () => {
|
||
const res = await exportExample(params);
|
||
window.open(res); // res 是文件 URL
|
||
};
|
||
```
|
||
|
||
## 性能建议
|
||
|
||
| 场景 | 方式 | 说明 |
|
||
|------|------|------|
|
||
| 数据量 < 1000 | `export()` 一次性导出 | 简单直接 |
|
||
| 数据量 ≥ 1000 | `chunkExport()` 分批导出 | 避免内存溢出 |
|
||
| 导入大量数据 | 逐行调用 `add()` | 单行失败不影响其他行 |
|
||
| 关联字段导出 | 预加载名称映射 | 避免 N+1 查询 |
|
||
|
||
## 常见问题
|
||
|
||
### 问题 1:导入提示"文件格式不正确"
|
||
|
||
**原因:** 文件扩展名不是 `.xls` 或 `.xlsx`。
|
||
|
||
**解决:** Controller 中校验扩展名,前端 `accept=".xls,.xlsx"` 限制文件类型。
|
||
|
||
### 问题 2:导入时中文乱码
|
||
|
||
**原因:** Excel 文件编码问题(旧版 .xls 可能是 GBK 编码)。
|
||
|
||
**解决:** PhpSpreadsheet 自动处理编码,通常不需要手动处理。如仍有问题,检查 Excel 文件是否损坏。
|
||
|
||
### 问题 3:导出内存溢出
|
||
|
||
**原因:** 使用 `export()` 一次性导出大数据量。
|
||
|
||
**解决:** 改用 `chunkExport()` 分批导出,每批 500 条。
|
||
|
||
### 问题 4:枚举字段导出为数字
|
||
|
||
**原因:** 导出表头未使用 `Text` 后缀字段。
|
||
|
||
**解决:** 确保 Logic 中配置了 `serializeMaps`,导出表头使用 `{字段名}Text`。
|