# 文件上传与任务状态管理优化设计文档

**方案选择**：A - 渐进式增强（用户体验优先）  
**版本**：V1.0  
**日期**：2026-05-30  
**状态**：待审核

---

## 1. 设计概述

### 1.1 项目背景

基于《文件管理接口文档》（V2.12.0）规范，对现有文件上传功能和文件列表下的任务向量化/出题状态组件进行系统性用户体验优化。

### 1.2 优化目标

- **主要目标**：提升用户交互体验，增强功能易用性和反馈机制
- **次要目标**：改善性能表现，优化错误处理流程
- **非目标**：不进行架构重构，不改变核心数据流，不引入新技术栈

### 1.3 设计原则

1. **KISS原则**：保持简单，避免过度工程化
2. **增量改进**：每个优化点独立可回滚
3. **向后兼容**：不破坏现有API和组件接口
4. **用户驱动**：所有改进围绕实际使用场景
5. **渐进增强**：在现有代码基础上添加功能，不重写

---

## 2. 当前实现分析

### 2.1 现有架构

```
前端组件结构：
├── FileSelector.vue          # 文件选择器（用于AI对话中选择文件）
├── exam/
│   ├── UploadTaskItem.vue    # 任务状态展示卡片
│   ├── composables/
│   │   ├── useUploadTasks.js # 任务状态管理逻辑
│   │   └── useTaskManager.js # 任务管理器
│   ├── GeneratePanel.vue     # 向量化/出题面板
│   └── ExamModuleContainer.vue
├── api/
│   └── file.js               # 文件API接口定义
└── views/
    └── ReaderPage.vue        # 文档阅读页面
```

### 2.2 已识别问题清单

#### **P0 - 必须修复（影响核心功能）**

| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P0-01 | 无上传进度条显示 | 文件上传体验 | 🔴 高 |
| P0-02 | 错误状态无重试机制 | 任务失败处理 | 🔴 高 |
| P0-03 | 轮询策略固定（5秒间隔） | 性能浪费 | 🟡 中 |

#### **P1 - 应该改进（显著提升体验）**

| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P1-01 | 不支持拖拽上传 | 上传便捷性 | 🟡 中 |
| P1-02 | 无前端文件验证 | 错误预防 | 🟡 中 |
| P1-03 | 错误提示不够友好 | 用户理解 | 🟡 中 |
| P1-04 | 不支持批量操作 | 效率提升 | 🟢 低 |
| P1-05 | 移动端适配不完善 | 多设备支持 | 🟢 低 |

#### **P2 - 可以优化（锦上添花）**

| 问题ID | 问题描述 | 影响范围 | 严重程度 |
|--------|---------|----------|----------|
| P2-01 | 无状态变更通知提醒 | 用户感知 | 🟢 低 |
| P2-02 | 缺少加载骨架屏 | 视觉体验 | 🟢 低 |
| P2-03 | 任务列表无虚拟滚动 | 大列表性能 | 🟢 低 |

---

## 3. 详细设计方案

### 3.1 模块一：文件上传增强（优先级：🔴 最高）

#### **3.1.1 功能需求**

##### **FR-01: 上传进度条**
- **描述**：显示实时上传百分比和预计剩余时间
- **触发条件**：文件大小 > 1MB 或上传时间 > 2秒时自动显示
- **UI设计**：
  ```
  ┌─────────────────────────────────────┐
  │ 📄 制度管理办法.docx (2.3MB)         │
  │ ████████████░░░░░░ 65%  剩余 3秒    │
  └─────────────────────────────────────┘
  ```

##### **FR-02: 拖拽上传支持**
- **描述**：支持将文件从桌面/资源管理器拖拽到上传区域
- **技术方案**：HTML5 Drag and Drop API
- **交互细节**：
  - 拖拽悬停时：边框高亮 + 提示文字"释放以上传"
  - 拖拽离开时：恢复原状
  - 支持多文件同时拖拽

##### **FR-03: 前端文件验证**
- **验证规则**：
  - ✅ 支持的文件类型：`.docx, .pdf, .txt, .md, .xlsx, .pptx`
  - ✅ 最大文件大小：50MB（可配置）
  - ✅ 文件名长度：≤200字符
  - ✅ 特殊字符检查（禁止 `\ / : * ? " < > |`）
- **错误提示**：
  - 类型不支持："❌ 不支持的文件格式，请上传 .docx/.pdf/.txt 等文档"
  - 文件过大："❌ 文件超过50MB限制，请压缩后重试"
  - 其他错误："⚠️ 文件验证失败：{具体原因}"

##### **FR-04: 上传前预览**
- **预览信息**：
  - 文件图标（根据扩展名显示不同图标）
  - 文件名（高亮显示）
  - 文件大小（格式化为KB/MB）
  - 文件类型标签（如"Word文档"、"PDF文件"）

##### **FR-05: 友好的错误处理**
- **错误分类**：
  - 🌐 **网络错误**：连接超时、服务器无响应
  - 📁 **文件错误**：格式不支持、文件损坏
  - 🔐 **权限错误**：未登录、无上传权限
  - ⚙️ **服务器错误**：500内部错误、存储空间不足
- **错误展示**：
  - 图标 + 标题 + 详细说明 + 操作建议
  - 示例：
    ```
    ⚠️ 上传失败
    
    原因：服务器响应超时（>60秒）
    
    建议：
    • 检查网络连接是否正常
    • 尝试压缩文件后重新上传
    • 如持续失败，请联系管理员
    
    [重新上传] [取消]
    ```

#### **3.1.2 技术实现方案**

##### **修改文件清单**：

| 文件路径 | 改动类型 | 改动量 | 说明 |
|---------|---------|--------|------|
| `src/components/FileSelector.vue` | 修改 | ~150行 | 添加拖拽、验证、进度条 |
| `src/api/file.js` | 修改 | ~20行 | 添加onUploadProgress回调 |
| `src/utils/fileValidator.js` | 新增 | ~80行 | 文件验证工具函数 |
| `src/components/ui/ProgressBar.vue` | 新增 | ~60行 | 可复用进度条组件 |
| `src/components/ui/DropZone.vue` | 新增 | ~90行 | 拖拽上传区域组件 |

##### **核心代码示例**：

**Axios进度监听** (`src/api/file.js`)：
```javascript
uploadFile: (formData, onProgress) => {
  return apiClient.post('/file/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (progressEvent) => {
      if (onProgress && progressEvent.total) {
        const percentCompleted = Math.round(
          (progressEvent.loaded * 100) / progressEvent.total
        )
        onProgress({
          loaded: progressEvent.loaded,
          total: progressEvent.total,
          percent: percentCompleted
        })
      }
    }
  })
}
```

**拖拽上传组件** (`src/components/ui/DropZone.vue`)：
```vue
<template>
  <div 
    class="drop-zone"
    :class="{ 'is-dragging': isDragging }"
    @dragenter.prevent="onDragEnter"
    @dragover.prevent="onDragOver"
    @dragleave.prevent="onDragLeave"
    @drop.prevent="onDrop"
    @click="$refs.fileInput.click()"
  >
    <div v-if="!isDragging" class="drop-zone-content">
      <InboxOutlined class="drop-icon" />
      <p class="drop-text">拖拽文件到此处，或<span class="link">点击选择</span></p>
      <p class="drop-hint">支持 .docx .pdf .txt .xlsx .pptx，最大 50MB</p>
    </div>
    <div v-else class="drop-zone-active">
      <span class="active-text">📥 释放文件以上传</span>
    </div>
    <input
      ref="fileInput"
      type="file"
      multiple
      :accept="acceptedTypes"
      @change="onFileSelected"
      style="display: none"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { InboxOutlined } from '@ant-design/icons-vue'

const props = defineProps({
  acceptedTypes: {
    type: String,
    default: '.docx,.pdf,.txt,.md,.xlsx,.pptx'
  },
  maxSize: {
    type: Number,
    default: 50 * 1024 * 1024 // 50MB
  }
})

const emit = defineEmits(['files-selected', 'error'])

const isDragging = ref(false)
const fileInput = ref(null)

function onDragEnter(e) {
  isDragging.value = true
}

function onDragOver(e) {
  e.dataTransfer.dropEffect = 'copy'
}

function onDragLeave(e) {
  if (!e.currentTarget.contains(e.relatedTarget)) {
    isDragging.value = false
  }
}

function onDrop(e) {
  isDragging.value = false
  const files = Array.from(e.dataTransfer.files)
  validateAndEmit(files)
}

function onFileSelected(e) {
  const files = Array.from(e.target.files)
  validateAndEmit(files)
  // 重置input以允许重复选择相同文件
  e.target.value = ''
}

function validateAndEmit(files) {
  const validFiles = []
  const errors = []

  files.forEach(file => {
    // 验证文件类型
    const ext = '.' + file.name.split('.').pop().toLowerCase()
    if (!props.acceptedTypes.includes(ext)) {
      errors.push({ file: file.name, reason: '不支持的文件格式' })
      return
    }

    // 验证文件大小
    if (file.size > props.maxSize) {
      errors.push({ file: file.name, reason: `文件过大 (${formatSize(file.size)}，限制${formatSize(props.maxSize)})` })
      return
    }

    validFiles.push(file)
  })

  if (validFiles.length > 0) {
    emit('files-selected', validFiles)
  }

  if (errors.length > 0) {
    emit('error', errors)
  }
}
</script>
```

---

### 3.2 模块二：任务状态优化（优先级：🟡 高）

#### **3.2.1 功能需求**

##### **FR-06: 一键重试按钮**
- **触发条件**：任务状态为 error 时显示
- **位置**：UploadTaskItem 组件的错误标签旁
- **行为**：
  1. 点击后立即调用重新处理API
  2. 按钮变为加载状态（转圈图标）
  3. 成功后自动刷新任务列表
  4. 失败后显示错误详情
- **UI设计**：
  ```
  ❌ 向量化失败  [🔄 重试] [📋 详情]
  ```

##### **FR-07: 批量删除功能**
- **使用场景**：清理大量失败/已完成的历史任务
- **交互流程**：
  1. 进入批量模式（勾选框出现）
  2. 选择多个任务（支持全选）
  3. 点击"删除选中项(N)"按钮
  4. 弹出确认对话框（显示即将删除的任务数）
  5. 执行删除并显示进度
- **权限控制**：仅管理员可见此功能

##### **FR-08: Toast通知系统**
- **触发事件**：
  - ✅ 上传成功："✅ 文件「{filename}」上传成功，正在处理中..."
  - ✅ 向量化完成："🎉 「{filename}」向量化完成，准备生成题目..."
  - ✅ 出题完成："📝 「{filename}」已生成{count}道题目"
  - ❌ 处理失败："❌ 「{filename}」处理失败：{原因}"
- **配置选项**：
  - 显示时长：成功=3秒，失败=5秒
  - 位置：右下角
  - 可关闭：是
  - 堆叠方式：垂直堆叠（最多3条同时显示）

##### **FR-09: 智能轮询策略**
- **当前问题**：固定5秒轮询，无论是否有活跃任务
- **优化方案**：

  | 任务状态 | 轮询间隔 | 说明 |
  |---------|---------|------|
  | 有处理中任务 | 3秒 | 快速反馈 |
  | 全部完成/失败 | 10秒 | 降低频率 |
  | 页面不可见 | 30秒 | 节省资源 |
  | 无任何任务 | 停止轮询 | 完全停止 |

- **实现方式**：使用 `document.visibilitychange` 事件检测页面可见性

##### **FR-10: 加载骨架屏**
- **应用场景**：
  1. 首次加载任务列表时
  2. 刷新任务状态时（>500ms）
- **UI效果**：灰色脉冲动画块模拟真实内容布局
- **组件复用**：创建通用 SkeletonCard 组件

#### **3.2.2 技术实现方案**

##### **修改文件清单**：

| 文件路径 | 改动类型 | 改动量 | 说明 |
|---------|---------|--------|------|
| `src/components/exam/UploadTaskItem.vue` | 修改 | ~40行 | 添加重试按钮 |
| `src/components/exam/composables/useUploadTasks.js` | 修改 | ~80行 | 智能轮询+批量操作 |
| `src/components/ui/SkeletonCard.vue` | 新增 | ~50行 | 骨架屏组件 |
| `src/components/ui/ToastNotification.vue` | 新增 | ~90行 | 通知组件 |
| `src/utils/notification.js` | 新增 | ~60行 | 通知工具函数 |

##### **核心代码示例**：

**智能轮询** (`useUploadTasks.js` 修改部分)：
```javascript
// 新增智能轮询配置
const POLL_CONFIG = {
  active: 3000,      // 有活跃任务时：3秒
  idle: 10000,       // 全部空闲时：10秒
  hidden: 30000,     // 页面隐藏时：30秒
  maxInterval: 30000 // 最大间隔上限
}

let currentInterval = POLL_CONFIG.idle
let visibilityHandler = null

function updatePollingStrategy() {
  const hasActiveTasks = shouldPoll()
  const isHidden = document.hidden

  let newInterval
  if (isHidden) {
    newInterval = POLL_CONFIG.hidden
  } else if (hasActiveTasks) {
    newInterval = POLL_CONFIG.active
  } else {
    newInterval = POLL_CONFIG.idle
  }

  // 仅当间隔变化时才重启定时器
  if (newInterval !== currentInterval) {
    currentInterval = newInterval
    stopPolling()
    if (tasks.value.length > 0) {
      startPolling()
    }
    console.log(`[useUploadTasks] 轮询间隔调整为 ${currentInterval}ms`)
  }
}

// 监听页面可见性
function setupVisibilityListener() {
  if (visibilityHandler) return
  
  visibilityHandler = () => {
    updatePollingStrategy()
  }
  document.addEventListener('visibilitychange', visibilityHandler)
}

onMounted(() => {
  setupVisibilityListener()
})

onUnmounted(() => {
  if (visibilityHandler) {
    document.removeEventListener('visibilitychange', visibilityHandler)
  }
})
```

**一键重试** (`UploadTaskItem.vue` 修改部分)：
```vue
<template>
  <!-- 在错误标签旁添加重试按钮 -->
  <span v-if="vectorStatus.status === 'error'" class="uti-tag error">
    {{ vectorStatus.label }}
    <button class="retry-btn" @click="handleRetry" :disabled="retrying">
      <LoadingOutlined v-if="retrying" spin />
      <ReloadOutlined v-else />
      重试
    </button>
  </span>
</template>

<script setup>
import { ref } from 'vue'
import { ReloadOutlined, LoadingOutlined } from '@ant-design/icons-vue'

const props = defineProps({
  task: Object,
  vectorStatus: Object,
  examStatus: Object
})

const emit = defineEmits(['retry'])
const retrying = ref(false)

async function handleRetry() {
  retrying.value = true
  try {
    await emit('retry', props.task.id)
  } finally {
    setTimeout(() => { retrying.value = false }, 1000)
  }
}
</script>
```

---

### 3.3 模块三：移动端适配（优先级：🟢 中）

#### **3.3.1 适配策略**

##### **断点定义**：

| 断点名称 | 宽度范围 | 目标设备 |
|---------|---------|---------|
| `sm` | ≥640px | 大屏手机横屏/小平板 |
| `md` | ≥768px | 平板竖屏 |
| `lg` | ≥1024px | 桌面端 |

##### **关键改动**：

1. **UploadTaskItem 组件**：
   - 字体缩小（12px → 11px）
   - 状态标签换行显示
   - 错误信息默认展开（无需点击"详情"）

2. **文件列表**：
   - 卡片布局改为单列
   - 操作按钮改为底部固定栏
   - 添加下拉刷新手势支持

3. **上传区域**：
   - 拖拽区域全屏宽度
   - 点击区域增大（最小44px触控目标）

---

## 4. 数据流与接口变更

### 4.1 API接口调整

#### **新增接口**（可选，如后端已支持则使用）：

| 接口 | 方法 | 用途 |
|------|------|------|
| `/api/file/{id}/retry` | POST | 重试失败任务 |
| `/api/file/batch-delete` | POST | 批量删除任务 |

#### **兼容性处理**：

如果后端暂不支持上述接口，前端降级方案：
- **重试功能**：调用原有上传接口重新上传同一文件
- **批量删除**：循环调用单个删除接口（带loading状态）

### 4.2 数据流图

```
用户操作
  ↓
FileSelector.vue（拖拽/选择文件）
  ↓
fileValidator.js（前端验证）
  ├─ 通过 → 显示预览 → 开始上传
  └─ 失败 → 显示友好错误提示
       ↓
api/file.js uploadFile（带进度回调）
  ↓
ProgressBar.vue（实时更新进度）
  ↓
上传完成
  ↓
useUploadTasks.js（添加任务到列表）
  ↓
智能轮询（3-30秒动态调整）
  ↓
UploadTaskItem.vue（展示状态）
  ├─ 处理中 → 动画图标
  ├─ 完成 → 成功图标 + Toast通知
  └─ 失败 → 错误图标 + 重试按钮
```

---

## 5. 性能与兼容性考虑

### 5.1 性能优化措施

1. **按需加载**：新组件使用动态导入
   ```javascript
   const DropZone = defineAsyncComponent(() => import('./components/ui/DropZone.vue'))
   ```

2. **防抖/节流**：
   - 搜索输入：debounce 300ms
   - 窗口resize：throttle 150ms
   - 滚动事件：passive listener

3. **内存管理**：
   - 轮询定时器在组件卸载时清除
   - 大文件上传完成后释放引用
   - 图片/文件预览使用URL.revokeObjectURL()

### 5.2 浏览器兼容性

| 功能 | 最低版本 | 降级方案 |
|------|---------|---------|
| Drag & Drop API | IE10+, 所有现代浏览器 | 回退到点击上传 |
| Progress Event | IE10+, 所有现代浏览器 | 不显示进度条 |
| Visibility API | IE10+, Chrome 13+ | 固定10秒轮询 |
| CSS Grid/Flexbox | IE11+ (partial), 现代浏览器全支持 | 使用float fallback |

---

## 6. 测试计划

### 6.1 单元测试

| 测试场景 | 输入 | 预期输出 | 优先级 |
|---------|------|---------|--------|
| 文件类型验证 | `test.exe` | 返回错误"不支持的格式" | P0 |
| 文件大小验证 | 60MB文件 | 返回错误"超过50MB限制" | P0 |
| 进度计算 | loaded=50, total=100 | percent=50 | P0 |
| 拖拽事件处理 | 有效文件 | 触发files-selected事件 | P1 |
| 轮询间隔调整 | 页面hidden | 间隔变为30秒 | P1 |
| 重试按钮点击 | error状态任务 | 调用retry事件 | P0 |

### 6.2 集成测试

1. **上传流程测试**：
   - 选择文件 → 验证 → 上传 → 进度显示 → 完成
   - 拖拽文件 → 验证 → 上传 → 进度显示 → 完成
   - 选择无效文件 → 显示错误 → 修正后重试

2. **任务状态测试**：
   - 上传后观察状态流转（UPLOADED→VECTORIZING→VECTORIZED→EXAM_GENERATING→COMPLETED）
   - 模拟失败场景 → 点击重试 → 验证重试逻辑
   - 批量选择 → 删除 → 确认删除成功

3. **边界情况测试**：
   - 同时上传10个文件
   - 网络中断后恢复
   - 页面刷新后状态保持
   - 移动端触摸操作

### 6.3 兼容性测试

- ✅ Chrome 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Edge 90+
- ⚠️ iOS Safari 14+（移动端）
- ⚠️ Android Chrome 90+

---

## 7. 实施路线图

### Phase 1：核心功能（第1天）- 6小时

**上午（3小时）**：
- [ ] 创建 `src/utils/fileValidator.js` 文件验证工具
- [ ] 创建 `src/components/ui/ProgressBar.vue` 进度条组件
- [ ] 修改 `src/api/file.js` 添加进度回调支持

**下午（3小时）**：
- [ ] 创建 `src/components/ui/DropZone.vue` 拖拽组件
- [ ] 修改 `src/components/FileSelector.vue` 集成新功能
- [ ] 测试上传流程（正常/异常场景）

### Phase 2：任务状态优化（第2天）- 6小时

**上午（3小时）**：
- [ ] 修改 `UploadTaskItem.vue` 添加重试按钮
- [ ] 修改 `useUploadTasks.js` 实现智能轮询
- [ ] 创建 `src/components/ui/ToastNotification.vue`

**下午（3小时）**：
- [ ] 实现批量删除功能
- [ ] 创建骨架屏组件
- [ ] 测试任务状态流转

### Phase 3：打磨与测试（第3天）- 6小时

**上午（3小时）**：
- [ ] 移动端响应式适配
- [ ] 跨浏览器测试
- [ ] 性能 profiling

**下午（3小时）**：
- [ ] 用户体验走查
- [ ] Bug修复
- [ ] 文档编写

---

## 8. 风险评估与缓解

### 8.1 技术风险

| 风险 | 可能性 | 影响 | 缓解措施 |
|------|-------|------|---------|
| 后端不支持重试API | 中 | 中 | 前端降级为重新上传 |
| 大文件上传内存溢出 | 低 | 高 | 使用分片上传（Phase 2考虑） |
| 拖拽API兼容性问题 | 低 | 低 | Feature detection + 降级方案 |
| 轮询性能开销 | 低 | 中 | 智能间隔 + 页面不可见时暂停 |

### 8.2 业务风险

| 风险 | 可能性 | 影响 | 缓解措施 |
|------|-------|------|---------|
| 用户不接受新交互 | 低 | 低 | A/B测试 + 快速回滚能力 |
| 移动端体验不佳 | 中 | 中 | 充分的设备测试 |
| 与现有工作流冲突 | 低 | 中 | 保持向后兼容 + 渐进启用 |

---

## 9. 成功指标（KPIs）

### 9.1 定量指标

| 指标 | 当前值 | 目标值 | 测量方法 |
|------|-------|--------|---------|
| 上传操作成功率 | 未知 | ≥98% | 错误日志统计 |
| 平均上传等待感知时间 | 未知 | 减少30% | 用户调研 |
| 任务状态查询次数/会话 | 固定12次/分钟 | 动态3-30次 | 性能监控 |
| 错误重试成功率 | 0% | ≥80% | 重试按钮点击率 |

### 9.2 定性指标

- ✅ 用户能够清晰了解上传进度
- ✅ 任务失败时有明确的解决路径（重试按钮）
- ✅ 移动端操作流畅无明显卡顿
- ✅ 错误信息易于理解和行动

---

## 10. 附录

### 10.1 参考文档

- [文件管理接口文档 V2.12.0](./文件管理接口文档.md)
- Vue 3 官方文档：https://vuejs.org/
- Ant Design Vue 组件库：https://antdv.com/
- MDN Drag & Drop API：https://developer.mozilla.org/en-US/docs/Web/API/Drag_and_Drop

### 10.2 术语表

| 术语 | 定义 |
|------|------|
| **向量化(Vectorize)** | 将文档内容转换为向量表示，用于语义搜索 |
| **出题(Exam Generation)** | 基于文档内容自动生成考试题目 |
| **轮询(Polling)** | 客户端定期向服务器请求最新状态的机制 |
| **骨架屏(Skeleton)** | 内容加载时的占位动画效果 |
| **Toast通知** | 短暂出现的消息提示，通常位于屏幕角落 |

---

**文档结束**

*请审核本设计文档，确认后我们将进入实施阶段。*
