# 采购交易管理导入功能优化设计文档 ## 文档信息 - **创建日期**: 2026-02-08 - **模块**: 采购交易管理 - **设计目标**: 优化导入功能,采用后台异步处理+通知提示,避免弹窗阻塞用户操作 - **参考方案**: 员工信息维护导入功能 --- ## 目录 1. [需求概述](#需求概述) 2. [整体架构](#整体架构) 3. [前端组件结构](#前端组件结构) 4. [UI组件修改](#ui组件修改) 5. [核心方法实现](#核心方法实现) 6. [完整修改清单](#完整修改清单) 7. [测试要点](#测试要点) --- ## 需求概述 ### 当前问题 采购交易管理的导入功能采用同步处理方式,上传文件后需要等待导入完成,使用弹窗显示结果,阻塞用户操作。 ### 优化目标 1. ✅ 采用后台异步处理,上传后立即关闭对话框 2. ✅ 使用右上角通知提示,不使用弹窗 3. ✅ 自动轮询导入状态,完成后通知用户 4. ✅ 支持查看导入失败记录 5. ✅ 状态持久化,刷新页面后仍可查看上次导入结果 ### 设计原则 - **完全复用**员工信息维护的导入逻辑 - 保持一致的交互体验 - 最小化代码修改,复用已有组件 --- ## 整体架构 ### 用户交互流程 ``` 用户点击"导入"按钮 ↓ 打开导入对话框 ↓ 选择Excel文件,点击"确定" ↓ 上传文件到后端 ↓ 立即关闭导入对话框 ↓ 右上角显示通知:"导入任务已提交,正在后台处理中,处理完成后将通知您" ↓ 系统后台每2秒轮询一次导入状态 ↓ 导入完成后,右上角显示结果通知: - 全部成功: "导入完成!全部成功!共导入N条数据" - 部分失败: "导入完成!成功N条,失败M条" ↓ 如果有失败记录: - 在页面操作栏显示"查看导入失败记录"按钮 - 带tooltip显示上次导入信息 ``` ### 数据存储策略 使用localStorage存储导入任务状态,实现状态持久化: **存储Key**: `purchase_transaction_import_last_task` **存储内容**: ```javascript { taskId: "task-20250206-123456789", status: "SUCCESS", // PROCESSING/SUCCESS/FAILED saveTime: 1707225600000, hasFailures: true, totalCount: 1000, successCount: 980, failureCount: 20 } ``` **数据保留时间**: 7天(过期自动清除) ### 轮询机制 - **轮询间隔**: 2秒 - **最大轮询次数**: 150次(5分钟) - **超时处理**: 显示"导入任务处理超时,请联系管理员" - **状态检查**: 当status !== 'PROCESSING'时停止轮询 --- ## 前端组件结构 ### 新增data属性 ```javascript data() { return { // ... 现有属性 // 导入轮询定时器 importPollingTimer: null, // 是否显示查看失败记录按钮 showFailureButton: false, // 当前导入任务ID currentTaskId: null, // 失败记录对话框 failureDialogVisible: false, failureList: [], failureLoading: false, failureTotal: 0, failureQueryParams: { pageNum: 1, pageSize: 10 } } } ``` ### 新增computed属性 ```javascript computed: { /** * 上次导入信息摘要 */ lastImportInfo() { const savedTask = this.getImportTaskFromStorage(); if (savedTask && savedTask.totalCount) { return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; } return ''; } } ``` ### 生命周期钩子修改 ```javascript created() { this.getList(); this.restoreImportState(); // 新增:恢复导入状态 }, beforeDestroy() { // 清理定时器 if (this.importPollingTimer) { clearInterval(this.importPollingTimer); this.importPollingTimer = null; } } ``` ### 需要新增/修改的方法 | 方法名 | 类型 | 说明 | |----------------------------|----|---------------------| | saveImportTaskToStorage | 新增 | 保存导入状态到localStorage | | getImportTaskFromStorage | 新增 | 读取导入状态 | | clearImportTaskFromStorage | 新增 | 清除导入状态 | | restoreImportState | 新增 | 页面加载时恢复导入状态 | | getLastImportTooltip | 新增 | 获取上次导入提示信息 | | handleFileSuccess | 修改 | 上传成功后不弹窗,开始轮询 | | startImportStatusPolling | 新增 | 开始轮询导入状态 | | handleImportComplete | 新增 | 处理导入完成 | | viewImportFailures | 新增 | 查看导入失败记录 | | getFailureList | 新增 | 查询失败记录列表 | | clearImportHistory | 新增 | 清除导入历史记录 | --- ## UI组件修改 ### 1. 修改导入对话框 **移除loading相关属性:** ```vue ``` **原因**: 导入改为后台异步处理,不需要在对话框中显示loading。 ### 2. 操作栏添加"查看导入失败记录"按钮 **位置**: 导入按钮和导出按钮之后 ```vue 查看导入失败记录 ``` **条件显示**: `v-if="showFailureButton"` - 仅当有失败记录时显示 ### 3. 新增导入失败记录对话框 **位置**: 导入结果对话框之后 ```vue ``` **显示字段**: - 采购事项ID (purchaseId) - 项目名称 (projectName) - 标的物名称 (subjectName) - 失败原因 (errorMessage) --- ## 核心方法实现 ### 1. handleFileSuccess - 上传成功处理 **修改说明**: 移除弹窗提示,改为通知+轮询 ```javascript handleFileSuccess(response, file, fileList) { this.upload.isUploading = false; this.upload.open = false; if (response.code === 200) { // 验证响应数据完整性 if (!response.data || !response.data.taskId) { this.$modal.msgError('导入任务创建失败:缺少任务ID'); this.upload.isUploading = false; this.upload.open = true; return; } const taskId = response.data.taskId; // 清除旧的轮询定时器 if (this.importPollingTimer) { clearInterval(this.importPollingTimer); this.importPollingTimer = 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); } } ``` ### 2. startImportStatusPolling - 轮询导入状态 ```javascript startImportStatusPolling(taskId) { let pollCount = 0; const maxPolls = 150; // 最多轮询150次(5分钟) this.importPollingTimer = setInterval(async () => { try { pollCount++; // 超时检查 if (pollCount > maxPolls) { clearInterval(this.importPollingTimer); this.$modal.msgWarning('导入任务处理超时,请联系管理员'); return; } const response = await getImportStatus(taskId); if (response.data && response.data.status !== 'PROCESSING') { clearInterval(this.importPollingTimer); this.handleImportComplete(response.data); } } catch (error) { clearInterval(this.importPollingTimer); this.$modal.msgError('查询导入状态失败: ' + error.message); } }, 2000); // 每2秒轮询一次 } ``` ### 3. handleImportComplete - 处理导入完成 ```javascript handleImportComplete(statusResult) { // 更新localStorage中的任务状态 this.saveImportTaskToStorage({ taskId: statusResult.taskId, status: statusResult.status, hasFailures: statusResult.failureCount > 0, totalCount: statusResult.totalCount, successCount: statusResult.successCount, failureCount: statusResult.failureCount }); if (statusResult.status === 'SUCCESS') { // 全部成功 this.$notify({ title: '导入完成', message: `全部成功!共导入${statusResult.totalCount}条数据`, type: 'success', duration: 5000 }); this.showFailureButton = false; // 成功时清除失败按钮显示 this.getList(); // 刷新列表 } else if (statusResult.failureCount > 0) { // 部分失败 this.$notify({ title: '导入完成', message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, type: 'warning', duration: 5000 }); // 显示查看失败记录按钮 this.showFailureButton = true; this.currentTaskId = statusResult.taskId; // 刷新列表 this.getList(); } } ``` ### 4. localStorage状态管理 ```javascript /** * 保存导入任务到localStorage */ saveImportTaskToStorage(taskData) { try { const data = { ...taskData, saveTime: Date.now() }; localStorage.setItem('purchase_transaction_import_last_task', JSON.stringify(data)); } catch (error) { console.error('保存导入任务状态失败:', error); } }, /** * 从localStorage读取导入任务 */ getImportTaskFromStorage() { try { const data = localStorage.getItem('purchase_transaction_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('purchase_transaction_import_last_task'); } catch (error) { console.error('清除导入任务状态失败:', error); } } ``` ### 5. restoreImportState - 恢复导入状态 ```javascript /** * 恢复导入状态 * 在created()钩子中调用 */ 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; } } ``` ### 6. getLastImportTooltip - 获取导入提示 ```javascript /** * 获取上次导入的提示信息 */ getLastImportTooltip() { const savedTask = this.getImportTaskFromStorage(); if (savedTask && savedTask.saveTime) { const date = new Date(savedTask.saveTime); const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}'); return `上次导入: ${timeStr}`; } return ''; } ``` ### 7. viewImportFailures - 查看失败记录 ```javascript /** * 查看导入失败记录 */ viewImportFailures() { this.failureDialogVisible = true; this.getFailureList(); } ``` ### 8. getFailureList - 查询失败记录列表 ```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); } }); } ``` ### 9. clearImportHistory - 清除历史记录 ```javascript /** * 清除导入历史记录 * 用户手动触发 */ clearImportHistory() { this.$confirm('确认清除上次导入记录?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { this.clearImportTaskFromStorage(); this.showFailureButton = false; this.currentTaskId = null; this.failureDialogVisible = false; this.$message.success('已清除'); }).catch(() => {}); } ``` --- ## 完整修改清单 ### 需要修改的文件 **文件路径**: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` ### 具体修改项 #### 1. data()中新增属性 ```javascript // 在data()返回对象中添加: importPollingTimer: null, showFailureButton: false, currentTaskId: null, failureDialogVisible: false, failureList: [], failureLoading: false, failureTotal: 0, failureQueryParams: { pageNum: 1, pageSize: 10 } ``` #### 2. computed中新增属性 ```javascript // 在computed中添加: lastImportInfo() { const savedTask = this.getImportTaskFromStorage(); if (savedTask && savedTask.totalCount) { return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`; } return ''; } ``` #### 3. created钩子 ```javascript // 在created()中添加: this.restoreImportState(); ``` #### 4. beforeDestroy钩子 ```javascript // 在beforeDestroy()中添加: if (this.importPollingTimer) { clearInterval(this.importPollingTimer); this.importPollingTimer = null; } ``` #### 5. methods中新增方法 需要新增10个方法(见上文"核心方法实现"部分) #### 6. 模板修改 - 导入对话框: 移除v-loading和element-loading-*属性 - 操作栏: 添加"查看导入失败记录"按钮 - 新增导入失败记录对话框 --- ## 测试要点 ### 1. 正常导入流程测试 **测试步骤**: 1. 点击"导入"按钮 2. 选择有效的Excel文件 3. 点击"确定"上传 **预期结果**: - ✅ 导入对话框立即关闭 - ✅ 右上角显示通知:"导入任务已提交,正在后台处理中,处理完成后将通知您" - ✅ 后台开始轮询状态(每2秒一次) - ✅ 导入完成后,右上角显示结果通知 - ✅ 列表自动刷新,显示新导入的数据 ### 2. 全部成功场景测试 **测试步骤**: 1. 上传包含100条有效数据的Excel文件 **预期结果**: - ✅ 显示成功通知:"导入完成!全部成功!共导入100条数据" - ✅ 不显示"查看导入失败记录"按钮 - ✅ 列表中显示100条新数据 ### 3. 部分失败场景测试 **测试步骤**: 1. 上传包含部分错误数据的Excel文件 **预期结果**: - ✅ 显示警告通知:"导入完成!成功80条,失败20条" - ✅ 显示"查看导入失败记录"按钮 - ✅ 按钮tooltip显示上次导入信息 - ✅ 列表中显示80条成功导入的数据 ### 4. 失败记录查看测试 **测试步骤**: 1. 导入有失败的数据 2. 点击"查看导入失败记录"按钮 **预期结果**: - ✅ 打开失败记录对话框 - ✅ 顶部显示导入信息提示(总数、成功、失败) - ✅ 表格显示失败记录,包含: - 采购事项ID - 项目名称 - 标的物名称 - 失败原因 - ✅ 支持分页查询 ### 5. 状态持久化测试 **测试步骤**: 1. 导入有失败的数据 2. 刷新页面 **预期结果**: - ✅ "查看导入失败记录"按钮仍然显示 - ✅ tooltip显示正确的导入时间 - ✅ 点击按钮可以正常查看失败记录 ### 6. 清除历史记录测试 **测试步骤**: 1. 打开失败记录对话框 2. 点击"清除历史记录"按钮 3. 确认清除 **预期结果**: - ✅ localStorage中的导入状态被清除 - ✅ "查看导入失败记录"按钮消失 - ✅ 失败记录对话框关闭 ### 7. 边界情况测试 **测试场景**: **a. 轮询超时** - 测试方法: 模拟导入任务超过5分钟未完成 - 预期结果: 显示"导入任务处理超时,请联系管理员" **b. 记录过期** - 测试方法: 修改localStorage中的saveTime为8天前 - 预期结果: 自动清除过期记录,不显示"查看导入失败记录"按钮 **c. 网络错误** - 测试方法: 断网后查询失败记录 - 预期结果: 显示"网络连接失败,请检查网络" **d. 服务器错误(404)** - 测试方法: 查询不存在的taskId的失败记录 - 预期结果: 显示"导入记录已过期,无法查看失败记录",自动清除状态 **e. 服务器错误(500)** - 测试方法: 后端返回500错误 - 预期结果: 显示"服务器错误,请稍后重试" ### 8. 用户体验测试 **测试要点**: - ✅ 导入过程中用户可以继续操作页面(不被阻塞) - ✅ 通知消息清晰易懂 - ✅ 失败记录对话框字段对齐,支持长文本省略 - ✅ tooltip提示信息准确 - ✅ 分页功能正常 --- ## 附录 ### A. 与员工信息维护的差异对比 | 对比项 | 员工信息维护 | 采购交易管理 | |------------------|-----------------------------------------------|----------------------------------------------------| | localStorage Key | `employee_import_last_task` | `purchase_transaction_import_last_task` | | API路径 | `/ccdi/employee/importData` | `/ccdi/purchaseTransaction/importData` | | 失败记录字段 | name, employeeId, idCard, phone, errorMessage | purchaseId, projectName, subjectName, errorMessage | | 轮询超时时间 | 5分钟(150次×2秒) | 5分钟(150次×2秒) | ### B. 后端API依赖 本设计依赖以下后端API(已实现): 1. **导入数据**: - 路径: `POST /ccdi/purchaseTransaction/importData` - 参数: `updateSupport` (是否更新已存在数据) - 响应: `{code: 200, data: {taskId: "task-xxx"}}` 2. **查询导入状态**: - 路径: `GET /ccdi/purchaseTransaction/importStatus/{taskId}` - 响应: `{code: 200, data: {taskId, status, totalCount, successCount, failureCount}}` 3. **查询导入失败记录**: - 路径: `GET /ccdi/purchaseTransaction/importFailures/{taskId}` - 参数: `pageNum`, `pageSize` - 响应: `{code: 200, rows: [...], total: N}` ### C. 技术栈 - **前端框架**: Vue 2.6.12 - **UI组件库**: Element UI 2.15.14 - **HTTP客户端**: Axios 0.28.1 - **状态管理**: localStorage (浏览器原生API) ### D. 参考文档 - 员工信息维护导入功能: `ruoyi-ui/src/views/ccdiEmployee/index.vue` - 采购交易API文档: `doc/api/ccdi_purchase_transaction_api.md` --- ## 版本历史 | 版本 | 日期 | 说明 | 作者 | |-------|------------|--------|--------| | 1.0.0 | 2026-02-08 | 初始设计文档 | Claude | --- ## 结语 本设计完全复用了员工信息维护的导入逻辑,实现了采购交易管理的后台异步导入功能。通过采用通知提示替代弹窗,避免了阻塞用户操作,提供了更好的用户体验。所有设计均已详细说明,可直接进入实施阶段。