# 员工导入结果跨页面持久化设计文档

**创建日期**: 2026-02-06
**设计者**: Claude Code
**状态**: 已确认
**关联文档**: [员工信息异步导入功能设计文档](./2026-02-06-employee-async-import-design.md)

---

## 一、需求概述

### 1.1 背景

当前员工信息异步导入功能存在问题:

- 导入开始后,切换到其他菜单再返回,无法查看上一次的导入结果
- `showFailureButton`、`currentTaskId` 等状态变量存储在组件内存中,页面切换后丢失

### 1.2 目标

- 实现导入结果的跨页面持久化
- 用户可以在切换菜单后仍然查看上一次的导入失败记录
- 仅保留最近一次导入记录,下次导入时自动清除旧数据
- 依赖Redis的7天TTL机制自动清理过期数据

### 1.3 核心决策

- **存储方案**: localStorage(前端持久化)
- **保留范围**: 仅最后一次导入记录
- **过期策略**: 依赖Redis TTL(7天),前端校验时间戳
- **清除时机**: 下次导入开始时自动清除旧数据

---

## 二、技术方案

### 2.1 整体设计

采用 **前端localStorage持久化** 方案:

```
用户上传Excel
    ↓
清除localStorage旧数据 → 保存新taskId
    ↓
开始轮询查询状态
    ↓
导入完成 → 更新localStorage状态
    ↓
用户切换菜单 → 组件销毁
    ↓
用户返回页面 → created()钩子
    ↓
从localStorage读取 → 恢复按钮显示状态
    ↓
用户点击查看失败记录 → 正常查询
```

**核心优势**:

- 无需后端改动,完全前端实现
- 简单可靠,利用浏览器原生存储
- 用户体验流畅,状态不丢失

### 2.2 数据结构设计

**localStorage存储格式**:

```javascript
// key: 'employee_import_last_task'
{
  taskId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
  status: 'SUCCESS' | 'PARTIAL_SUCCESS' | 'FAILED' | 'PROCESSING',
  timestamp: 1707225900000,
  saveTime: 1707225900000,
  hasFailures: true,
  totalCount: 100,
  successCount: 95,
  failureCount: 5
}
```

**字段说明**:

- `taskId`: 导入任务唯一标识
- `status`: 导入状态
- `timestamp`: 导入完成时间戳
- `saveTime`: 保存到localStorage的时间戳(用于过期校验)
- `hasFailures`: 是否有失败记录
- `totalCount/successCount/failureCount`: 导入统计信息

---

## 三、前端实现设计

### 3.1 新增工具方法

**文件**: `ruoyi-ui/src/views/ccdiEmployee/index.vue`

```javascript
methods: {
  /**
   * 保存导入任务到localStorage
   * @param {Object} taskData - 任务数据
   */
  saveImportTaskToStorage(taskData) {
    try {
      const data = {
        ...taskData,
        saveTime: Date.now()
      };
      localStorage.setItem('employee_import_last_task', JSON.stringify(data));
    } catch (error) {
      console.error('保存导入任务状态失败:', error);
    }
  },

  /**
   * 从localStorage读取导入任务
   * @returns {Object|null} 任务数据或null
   */
  getImportTaskFromStorage() {
    try {
      const data = localStorage.getItem('employee_import_last_task');
      if (!data) return null;

      const task = JSON.parse(data);

      // 数据格式校验
      if (!task || !task.taskId) {
        this.clearImportTaskFromStorage();
        return null;
      }

      // 时间戳校验
      if (task.saveTime && typeof task.saveTime !== 'number') {
        this.clearImportTaskFromStorage();
        return null;
      }

      // 过期检查(7天)
      const sevenDays = 7 * 24 * 60 * 60 * 1000;
      if (Date.now() - task.saveTime > sevenDays) {
        this.clearImportTaskFromStorage();
        return null;
      }

      return task;
    } catch (error) {
      console.error('读取导入任务状态失败:', error);
      this.clearImportTaskFromStorage();
      return null;
    }
  },

  /**
   * 清除localStorage中的导入任务
   */
  clearImportTaskFromStorage() {
    try {
      localStorage.removeItem('employee_import_last_task');
    } catch (error) {
      console.error('清除导入任务状态失败:', error);
    }
  },

  /**
   * 恢复导入状态
   * 在created()钩子中调用
   */
  async restoreImportState() {
    const savedTask = this.getImportTaskFromStorage();

    if (!savedTask) {
      this.showFailureButton = false;
      this.currentTaskId = null;
      return;
    }

    // 如果有失败记录,恢复按钮显示
    if (savedTask.hasFailures && savedTask.taskId) {
      this.currentTaskId = savedTask.taskId;
      this.showFailureButton = true;
    }
  },

  /**
   * 获取上次导入的提示信息
   * @returns {String} 提示文本
   */
  getLastImportTooltip() {
    const savedTask = this.getImportTaskFromStorage();
    if (savedTask && savedTask.timestamp) {
      const date = new Date(savedTask.timestamp);
      const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}');
      return `上次导入: ${timeStr}`;
    }
    return '';
  },

  /**
   * 清除导入历史记录
   * 用户手动触发
   */
  clearImportHistory() {
    this.$confirm('确认清除上次导入记录?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    }).then(() => {
      this.clearImportTaskFromStorage();
      this.showFailureButton = false;
      this.currentTaskId = null;
      this.failureDialogVisible = false;
      this.$message.success('已清除');
    }).catch(() => {});
  }
}
```

### 3.2 生命周期钩子修改

```javascript
created() {
  this.getList();
  this.getDeptTree();
  this.restoreImportState(); // 新增:恢复导入状态
}
```

### 3.3 导入成功处理修改

```javascript
handleFileSuccess(response, file, fileList) {
  this.upload.isUploading = false;
  this.upload.open = false;

  if (response.code === 200) {
    const taskId = response.data.taskId;

    // 清除旧的导入记录(防止并发)
    if (this.pollingTimer) {
      clearInterval(this.pollingTimer);
      this.pollingTimer = null;
    }

    this.clearImportTaskFromStorage();

    // 保存新任务的初始状态
    this.saveImportTaskToStorage({
      taskId: taskId,
      status: 'PROCESSING',
      timestamp: Date.now(),
      hasFailures: false
    });

    // 重置状态
    this.showFailureButton = false;
    this.currentTaskId = taskId;

    // 显示后台处理提示
    this.$notify({
      title: '导入任务已提交',
      message: '正在后台处理中,处理完成后将通知您',
      type: 'info',
      duration: 3000
    });

    // 开始轮询检查状态
    this.startImportStatusPolling(taskId);
  } else {
    this.$modal.msgError(response.msg);
  }
}
```

### 3.4 导入完成处理修改

```javascript
handleImportComplete(statusResult) {
  const hasFailures = statusResult.failureCount > 0;

  // 更新localStorage中的任务状态
  this.saveImportTaskToStorage({
    taskId: statusResult.taskId,
    status: statusResult.status,
    timestamp: Date.now(),
    hasFailures: hasFailures,
    totalCount: statusResult.totalCount,
    successCount: statusResult.successCount,
    failureCount: statusResult.failureCount
  });

  if (statusResult.status === 'SUCCESS') {
    this.$notify({
      title: '导入完成',
      message: `全部成功!共导入${statusResult.totalCount}条数据`,
      type: 'success',
      duration: 5000
    });
    this.getList();
  } else if (hasFailures) {
    this.$notify({
      title: '导入完成',
      message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`,
      type: 'warning',
      duration: 5000
    });

    // 显示查看失败记录按钮
    this.showFailureButton = true;
    this.currentTaskId = statusResult.taskId;

    // 刷新列表
    this.getList();
  }
}
```

### 3.5 失败记录查询增强

```javascript
getFailureList() {
  this.failureLoading = true;
  getImportFailures(
    this.currentTaskId,
    this.failureQueryParams.pageNum,
    this.failureQueryParams.pageSize
  ).then(response => {
    this.failureList = response.rows;
    this.failureTotal = response.total;
    this.failureLoading = false;
  }).catch(error => {
    this.failureLoading = false;

    // 处理不同类型的错误
    if (error.response) {
      const status = error.response.status;

      if (status === 404) {
        // 记录不存在或已过期
        this.$modal.msgWarning('导入记录已过期,无法查看失败记录');
        this.clearImportTaskFromStorage();
        this.showFailureButton = false;
        this.currentTaskId = null;
        this.failureDialogVisible = false;
      } else if (status === 500) {
        this.$modal.msgError('服务器错误,请稍后重试');
      } else {
        this.$modal.msgError(`查询失败: ${error.response.data.msg || '未知错误'}`);
      }
    } else if (error.request) {
      this.$modal.msgError('网络连接失败,请检查网络');
    } else {
      this.$modal.msgError('查询失败记录失败: ' + error.message);
    }
  });
}
```

### 3.6 新增计算属性

```javascript
computed: {
  /**
   * 上次导入信息摘要
   */
  lastImportInfo() {
    const savedTask = this.getImportTaskFromStorage();
    if (savedTask && savedTask.totalCount) {
      return `导入时间: ${this.parseTime(savedTask.timestamp)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`;
    }
    return '';
  }
}
```

### 3.7 模板修改

**失败记录按钮**:

```vue
<el-col :span="1.5" v-if="showFailureButton">
  <el-tooltip
    :content="getLastImportTooltip()"
    placement="top"
  >
    <el-button
      type="warning"
      plain
      icon="el-icon-warning"
      size="mini"
      @click="viewImportFailures"
    >
      查看上次导入失败记录
    </el-button>
  </el-tooltip>
</el-col>
```

**失败记录对话框**:

```vue
<el-dialog
  title="导入失败记录"
  :visible.sync="failureDialogVisible"
  width="1200px"
  append-to-body
>
  <el-alert
    v-if="lastImportInfo"
    :title="lastImportInfo"
    type="info"
    :closable="false"
    style="margin-bottom: 15px"
  />

  <el-table :data="failureList" v-loading="failureLoading">
    <el-table-column label="姓名" prop="name" align="center" />
    <el-table-column label="柜员号" prop="employeeId" align="center" />
    <el-table-column label="身份证号" prop="idCard" align="center" />
    <el-table-column label="电话" prop="phone" align="center" />
    <el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200" :show-overflow-tooltip="true" />
  </el-table>

  <pagination
    v-show="failureTotal > 0"
    :total="failureTotal"
    :page.sync="failureQueryParams.pageNum"
    :limit.sync="failureQueryParams.pageSize"
    @pagination="getFailureList"
  />

  <div slot="footer" class="dialog-footer">
    <el-button @click="failureDialogVisible = false">关闭</el-button>
    <el-button type="danger" plain @click="clearImportHistory">清除历史记录</el-button>
  </div>
</el-dialog>
```

---

## 四、用户体验流程

### 4.1 典型场景

**场景1: 导入成功无失败**

1. 用户上传Excel文件
2. 导入成功,显示通知"全部成功!共导入100条数据"
3. 刷新页面或切换菜单后返回
4. **预期**: 不显示"查看导入失败记录"按钮

**场景2: 导入有失败记录**

1. 用户上传有错误数据的Excel文件
2. 导入完成,显示通知"成功95条,失败5条"
3. 显示"查看导入失败记录"按钮
4. 用户切换到其他菜单
5. 用户返回员工管理页面
6. **预期**: 按钮仍然存在,点击可查看失败记录

**场景3: 导入中切换页面**

1. 用户上传Excel文件
2. 后台开始处理,用户立即切换菜单
3. 用户返回员工管理页面
4. **预期**: 如有失败,显示按钮并可查看

**场景4: Redis数据过期**

1. 导入完成,有失败记录
2. 7天后用户点击"查看导入失败记录"
3. 后端返回404错误
4. **预期**: 前端提示"导入记录已过期,无法查看失败记录",并清除localStorage数据,隐藏按钮

**场景5: 新导入覆盖旧记录**

1. 已有上一次的导入失败记录
2. 用户上传新的Excel文件
3. **预期**: 旧记录被立即清除,新导入的结果覆盖localStorage

---

## 五、错误处理与边界情况

### 5.1 localStorage异常

| 异常情况            | 处理方式                                 |
|-----------------|--------------------------------------|
| localStorage被禁用 | try-catch捕获,console.error记录,功能降级但不报错 |
| 数据损坏(非JSON格式)   | try-catch捕获,清除损坏数据,返回null            |
| 数据格式不完整         | 校验必要字段,清除无效数据                        |
| 时间戳异常           | 校验类型,清除无效数据                          |

### 5.2 API请求失败

| 错误类型      | HTTP状态码 | 处理方式                            |
|-----------|---------|---------------------------------|
| 记录不存在或已过期 | 404     | 提示用户"记录已过期",清除localStorage,隐藏按钮 |
| 服务器内部错误   | 500     | 提示"服务器错误,请稍后重试"                 |
| 网络连接失败    | 无响应     | 提示"网络连接失败,请检查网络"                |
| 其他错误      | 其他      | 显示具体错误信息                        |

### 5.3 并发导入处理

- 新导入开始时,立即清除旧的localStorage数据
- 清除旧的轮询定时器(如果有)
- 防止状态混乱

### 5.4 浏览器兼容性

localStorage在所有现代浏览器中都得到支持:

- Chrome 4+
- Firefox 3.5+
- Safari 4+
- IE 8+
- Edge(所有版本)

### 5.5 存储空间限制

- localStorage通常有5-10MB限制
- 本功能仅存储一个JSON对象(约200字节),远低于限制
- 不需要考虑存储空间问题

---

## 六、测试策略

### 6.1 功能测试

| 测试用例       | 步骤                            | 预期结果                |
|------------|-------------------------------|---------------------|
| 导入成功无失败-刷新 | 上传正确Excel → 等待完成 → 刷新页面       | 不显示失败记录按钮           |
| 导入有失败-刷新   | 上传有错误Excel → 等待完成 → 刷新页面      | 显示按钮,可查看失败记录        |
| 导入有失败-切换菜单 | 上传有错误Excel → 等待完成 → 切换菜单 → 返回 | 显示按钮,可查看失败记录        |
| 导入中切换页面    | 上传Excel → 立即切换菜单 → 返回         | 状态正常,如有失败显示按钮       |
| 新导入覆盖      | 有旧记录 → 上传新Excel → 等待完成        | 显示新导入的按钮,旧记录清除      |
| 手动清除记录     | 有失败记录 → 点击"清除历史记录"            | 按钮隐藏,localStorage清空 |
| Redis过期模拟  | 修改localStorage时间戳为8天前 → 打开页面  | 自动清除数据,不显示按钮        |
| API 404处理  | 有失败记录 → Mock后端返回404           | 提示过期,清除数据,隐藏按钮      |

### 6.2 边界测试

| 测试用例               | 预期结果                  |
|--------------------|-----------------------|
| localStorage被禁用    | 功能正常,不报错,仅不持久化        |
| localStorage数据手动篡改 | 自动检测并清除,恢复正常          |
| 连续快速多次导入           | 最后一次导入的状态为准           |
| 浏览器关闭后重新打开         | localStorage数据保留,状态恢复 |

### 6.3 浏览器兼容性测试

测试目标浏览器:

- Chrome(最新版)
- Firefox(最新版)
- Edge(最新版)
- Safari(如适用)

### 6.4 性能测试

| 指标               | 目标          |
|------------------|-------------|
| localStorage读取时间 | < 10ms      |
| localStorage写入时间 | < 10ms      |
| 页面加载恢复时间         | < 50ms      |
| 内存占用增加           | 可忽略(约200字节) |

---

## 七、实施检查清单

### 7.1 代码实现

- [ ] 新增 `saveImportTaskToStorage()` 方法
- [ ] 新增 `getImportTaskFromStorage()` 方法
- [ ] 新增 `clearImportTaskFromStorage()` 方法
- [ ] 新增 `restoreImportState()` 方法
- [ ] 新增 `getLastImportTooltip()` 方法
- [ ] 新增 `clearImportHistory()` 方法
- [ ] 新增 `lastImportInfo` 计算属性
- [ ] 修改 `created()` 钩子,调用 `restoreImportState()`
- [ ] 修改 `handleFileSuccess()` 方法
- [ ] 修改 `handleImportComplete()` 方法
- [ ] 修改 `getFailureList()` 方法
- [ ] 修改模板,添加tooltip和清除按钮

### 7.2 测试

- [ ] 导入成功无失败-刷新页面测试
- [ ] 导入有失败-刷新页面测试
- [ ] 导入有失败-切换菜单测试
- [ ] 导入中切换页面测试
- [ ] 新导入覆盖旧记录测试
- [ ] 手动清除记录测试
- [ ] Redis过期处理测试
- [ ] API 404错误处理测试
- [ ] localStorage异常处理测试
- [ ] 浏览器兼容性测试

### 7.3 文档

- [ ] 更新 `doc/api/ccdi-employee-import-api.md` (如有需要)
- [ ] 更新用户手册(如需要)

---

## 八、风险与限制

### 8.1 风险

| 风险              | 影响      | 缓解措施          |
|-----------------|---------|---------------|
| localStorage被禁用 | 无法持久化   | 功能降级,不影响基本使用  |
| 用户清除浏览器数据       | 记录丢失    | 符合预期,无负面影响    |
| 多标签页并发导入        | 状态可能不一致 | 新导入会覆盖旧数据,可接受 |

### 8.2 限制

1. **仅保留最后一次导入记录**
    - 设计决策,符合用户需求
    - 需要查看历史记录可考虑后续扩展

2. **依赖Redis TTL**
    - 7天后Redis数据自动删除
    - 前端有7天时间戳校验,但以Redis为准

3. **单浏览器本地存储**
    - 不同浏览器不共享状态
    - 换设备后无法查看(符合预期)

---

## 九、未来扩展方向

### 9.1 可能的增强功能

1. **历史导入记录列表**
    - 后端新增导入记录表
    - 支持查询所有历史导入
    - 按时间倒序展示

2. **跨设备同步**
    - 使用后端存储导入记录
    - 用户登录后同步导入状态

3. **导入结果导出**
    - 支持导出失败记录为Excel
    - 便于用户修正后重新导入

4. **导入统计可视化**
    - 展示导入成功率趋势
    - 常见错误类型统计

---

## 十、相关文件清单

### 10.1 修改文件

- `ruoyi-ui/src/views/ccdiEmployee/index.vue` - 员工管理页面

### 10.2 关联文档

- `doc/plans/2026-02-06-employee-async-import-design.md` - 员工信息异步导入功能设计文档
- `doc/api/ccdi-employee-import-api.md` - 员工导入API文档

---

## 附录

### A. localStorage Key命名规范

```
employee_import_last_task  // 员工导入最后一次任务
```

命名格式: `{模块}_{功能}_{用途}`

### B. 相关接口

| 接口                                     | 方法   | 说明     |
|----------------------------------------|------|--------|
| /ccdi/employee/importData              | POST | 提交导入任务 |
| /ccdi/employee/importStatus/{taskId}   | GET  | 查询导入状态 |
| /ccdi/employee/importFailures/{taskId} | GET  | 查询失败记录 |

---

**文档版本**: 1.0
**最后更新**: 2026-02-06
