Files
ccdi/doc/requirements/plans/2026-02-06-employee-import-result-persistence-design.md
2026-02-09 14:28:25 +08:00

18 KiB

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

创建日期: 2026-02-06 设计者: Claude Code 状态: 已确认 关联文档: 员工信息异步导入功能设计文档


一、需求概述

1.1 背景

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

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

1.2 目标

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

1.3 核心决策

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

二、技术方案

2.1 整体设计

采用 前端localStorage持久化 方案:

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

核心优势:

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

2.2 数据结构设计

localStorage存储格式:

// 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

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 生命周期钩子修改

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

3.3 导入成功处理修改

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 导入完成处理修改

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 失败记录查询增强

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 新增计算属性

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

3.7 模板修改

失败记录按钮:

<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>

失败记录对话框:

<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