diff --git a/doc/plans/2026-02-06-employee-import-result-persistence-design.md b/doc/plans/2026-02-06-employee-import-result-persistence-design.md new file mode 100644 index 0000000..832c53c --- /dev/null +++ b/doc/plans/2026-02-06-employee-import-result-persistence-design.md @@ -0,0 +1,678 @@ +# 员工导入结果跨页面持久化设计文档 + +**创建日期**: 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 + + + + 查看上次导入失败记录 + + + +``` + +**失败记录对话框**: +```vue + + + + + + + + + + + + + + + +``` + +--- + +## 四、用户体验流程 + +### 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