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

## 文档信息

- **创建日期**: 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
<!-- 修改前 -->
<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. 操作栏添加"查看导入失败记录"按钮

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

```vue
<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. 新增导入失败记录对话框

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

```vue
<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 - 上传成功处理

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

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

---

## 结语

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