Files
ccdi/doc/requirements/plans/2026-02-08-purchase-transaction-import-design.md
2026-02-09 14:28:25 +08:00

21 KiB
Raw Blame History

采购交易管理导入功能优化设计文档

文档信息

  • 创建日期: 2026-02-08
  • 模块: 采购交易管理
  • 设计目标: 优化导入功能,采用后台异步处理+通知提示,避免弹窗阻塞用户操作
  • 参考方案: 员工信息维护导入功能

目录

  1. 需求概述
  2. 整体架构
  3. 前端组件结构
  4. UI组件修改
  5. 核心方法实现
  6. 完整修改清单
  7. 测试要点

需求概述

当前问题

采购交易管理的导入功能采用同步处理方式,上传文件后需要等待导入完成,使用弹窗显示结果,阻塞用户操作。

优化目标

  1. 采用后台异步处理,上传后立即关闭对话框
  2. 使用右上角通知提示,不使用弹窗
  3. 自动轮询导入状态,完成后通知用户
  4. 支持查看导入失败记录
  5. 状态持久化,刷新页面后仍可查看上次导入结果

设计原则

  • 完全复用员工信息维护的导入逻辑
  • 保持一致的交互体验
  • 最小化代码修改,复用已有组件

整体架构

用户交互流程

用户点击"导入"按钮
    ↓
打开导入对话框
    ↓
选择Excel文件,点击"确定"
    ↓
上传文件到后端
    ↓
立即关闭导入对话框
    ↓
右上角显示通知:"导入任务已提交,正在后台处理中,处理完成后将通知您"
    ↓
系统后台每2秒轮询一次导入状态
    ↓
导入完成后,右上角显示结果通知:
  - 全部成功: "导入完成!全部成功!共导入N条数据"
  - 部分失败: "导入完成!成功N条,失败M条"
    ↓
如果有失败记录:
  - 在页面操作栏显示"查看导入失败记录"按钮
  - 带tooltip显示上次导入信息

数据存储策略

使用localStorage存储导入任务状态,实现状态持久化:

存储Key: purchase_transaction_import_last_task

存储内容:

{
  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属性

data() {
  return {
    // ... 现有属性

    // 导入轮询定时器
    importPollingTimer: null,

    // 是否显示查看失败记录按钮
    showFailureButton: false,

    // 当前导入任务ID
    currentTaskId: null,

    // 失败记录对话框
    failureDialogVisible: false,
    failureList: [],
    failureLoading: false,
    failureTotal: 0,
    failureQueryParams: {
      pageNum: 1,
      pageSize: 10
    }
  }
}

新增computed属性

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

生命周期钩子修改

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相关属性:

<!-- 修改前 -->
<el-dialog
  :title="upload.title"
  :visible.sync="upload.open"
  width="400px"
  append-to-body
  @close="handleImportDialogClose"
  v-loading="upload.isUploading"
  element-loading-text="正在导入数据,请稍候..."
  element-loading-spinner="el-icon-loading"
  element-loading-background="rgba(0, 0, 0, 0.7)"
>

<!-- 修改后 -->
<el-dialog
  :title="upload.title"
  :visible.sync="upload.open"
  width="400px"
  append-to-body
  @close="handleImportDialogClose"
>

原因: 导入改为后台异步处理,不需要在对话框中显示loading。

2. 操作栏添加"查看导入失败记录"按钮

位置: 导入按钮和导出按钮之后

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

条件显示: v-if="showFailureButton" - 仅当有失败记录时显示

3. 新增导入失败记录对话框

位置: 导入结果对话框之后

<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="采购事项ID" prop="purchaseId" align="center" />
    <el-table-column label="项目名称" prop="projectName" align="center" :show-overflow-tooltip="true"/>
    <el-table-column label="标的物名称" prop="subjectName" align="center" :show-overflow-tooltip="true"/>
    <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>

显示字段:

  • 采购事项ID (purchaseId)
  • 项目名称 (projectName)
  • 标的物名称 (subjectName)
  • 失败原因 (errorMessage)

核心方法实现

1. handleFileSuccess - 上传成功处理

修改说明: 移除弹窗提示,改为通知+轮询

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 - 轮询导入状态

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

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状态管理

/**
 * 保存导入任务到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 - 恢复导入状态

/**
 * 恢复导入状态
 * 在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 - 获取导入提示

/**
 * 获取上次导入的提示信息
 */
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 - 查看失败记录

/**
 * 查看导入失败记录
 */
viewImportFailures() {
  this.failureDialogVisible = true;
  this.getFailureList();
}

8. getFailureList - 查询失败记录列表

/**
 * 查询失败记录列表
 */
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 - 清除历史记录

/**
 * 清除导入历史记录
 * 用户手动触发
 */
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()中新增属性

// 在data()返回对象中添加:
importPollingTimer: null,
showFailureButton: false,
currentTaskId: null,
failureDialogVisible: false,
failureList: [],
failureLoading: false,
failureTotal: 0,
failureQueryParams: {
  pageNum: 1,
  pageSize: 10
}

2. computed中新增属性

// 在computed中添加:
lastImportInfo() {
  const savedTask = this.getImportTaskFromStorage();
  if (savedTask && savedTask.totalCount) {
    return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}条`;
  }
  return '';
}

3. created钩子

// 在created()中添加:
this.restoreImportState();

4. beforeDestroy钩子

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

结语

本设计完全复用了员工信息维护的导入逻辑,实现了采购交易管理的后台异步导入功能。通过采用通知提示替代弹窗,避免了阻塞用户操作,提供了更好的用户体验。所有设计均已详细说明,可直接进入实施阶段。