18 KiB
18 KiB
员工导入结果跨页面持久化设计文档
创建日期: 2026-02-06 设计者: Claude Code 状态: 已确认 关联文档: 员工信息异步导入功能设计文档
一、需求概述
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存储格式:
// 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: 导入成功无失败
- 用户上传Excel文件
- 导入成功,显示通知"全部成功!共导入100条数据"
- 刷新页面或切换菜单后返回
- 预期: 不显示"查看导入失败记录"按钮
场景2: 导入有失败记录
- 用户上传有错误数据的Excel文件
- 导入完成,显示通知"成功95条,失败5条"
- 显示"查看导入失败记录"按钮
- 用户切换到其他菜单
- 用户返回员工管理页面
- 预期: 按钮仍然存在,点击可查看失败记录
场景3: 导入中切换页面
- 用户上传Excel文件
- 后台开始处理,用户立即切换菜单
- 用户返回员工管理页面
- 预期: 如有失败,显示按钮并可查看
场景4: Redis数据过期
- 导入完成,有失败记录
- 7天后用户点击"查看导入失败记录"
- 后端返回404错误
- 预期: 前端提示"导入记录已过期,无法查看失败记录",并清除localStorage数据,隐藏按钮
场景5: 新导入覆盖旧记录
- 已有上一次的导入失败记录
- 用户上传新的Excel文件
- 预期: 旧记录被立即清除,新导入的结果覆盖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 限制
-
仅保留最后一次导入记录
- 设计决策,符合用户需求
- 需要查看历史记录可考虑后续扩展
-
依赖Redis TTL
- 7天后Redis数据自动删除
- 前端有7天时间戳校验,但以Redis为准
-
单浏览器本地存储
- 不同浏览器不共享状态
- 换设备后无法查看(符合预期)
九、未来扩展方向
9.1 可能的增强功能
-
历史导入记录列表
- 后端新增导入记录表
- 支持查询所有历史导入
- 按时间倒序展示
-
跨设备同步
- 使用后端存储导入记录
- 用户登录后同步导入状态
-
导入结果导出
- 支持导出失败记录为Excel
- 便于用户修正后重新导入
-
导入统计可视化
- 展示导入成功率趋势
- 常见错误类型统计
十、相关文件清单
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