# 文件查看模块 — 上传任务列表功能 实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在 ReadModule 中嵌入上传任务列表组件，实现文件上传后自动跟踪向量化+出题状态，支持轮询刷新、分页、筛选，基于文件管理接口文档中的 `processStepStatus`/`examStatus` 字段。

**Architecture:** 新建 `useUploadTasks.js` composable 管理全部状态和轮询逻辑；新建 `UploadTaskPanel.vue` 面板容器 + `UploadTaskItem.vue` 任务卡片；修改 `ReadModule.vue` 嵌入面板并联动上传成功事件。复用现有 `fileAPI.getFiles()` 接口获取数据，无需新增后端接口。

**Tech Stack:** Vue 3 Composition API (`<script setup>`)、design-tokens.css、Ant Design Vue（仅 message 组件）、原生 setInterval 轮询

---

## 接口文档依据

来自 [文件管理接口文档.md](../文件管理接口文档.md)：

| 字段 | 类型 | 用途 |
|------|------|------|
| `processStepStatus` | String(32) | 流程状态主字段 |
| `processStepMessage` | String(512) | 向量化详情/错误消息 |
| `examStatus` | String(20) | 出题状态 |
| `processMessage` | Text | 出题详情/错误消息 |

**processStepStatus 枚举值**：
- `UPLOADED` → 已上传
- `VECTORIZING` → 向量化中
- `VECTORIZED` → 向量化完成
- `VECTORIZE_FAILED` → 向量化失败
- `EXAM_GENERATING` → 出题中
- `EXAM_GENERATED` → 出题完成
- `EXAM_GENERATE_FAILED` → 出题失败
- `COMPLETED` → 全部流程完成

**examStatus 枚举值**：
- `UNGENERATED` / `GENERATING` / `GENERATED` / `FAILED`

---

## 文件变更总览

| 操作 | 文件路径 | 说明 |
|------|----------|------|
| **Create** | `src/components/exam/composables/useUploadTasks.js` | 任务数据管理 composable (~250行) |
| **Create** | `src/components/exam/UploadTaskPanel.vue` | 任务列表面板容器 (~180行) |
| **Create** | `src/components/exam/UploadTaskItem.vue` | 单个任务卡片 (~150行) |
| **Modify** | `src/components/ReadModule.vue` | 嵌入面板 + mapBackendToFrontend 补全 + 上传联动 |

---

## Phase 1: 数据层 — Composable

### Task 1: 创建 useUploadTasks.js

**Files:**
- Create: `src/components/exam/composables/useUploadTasks.js`

- [ ] **Step 1: 创建 composable 框架与导入**

```javascript
import { ref, computed, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { fileAPI } from '../../../api/file.js'

const POLL_INTERVAL = 5000 // 5秒轮询间隔

export function useUploadTasks() {
  const tasks = ref([])
  const loading = ref(false)
  const polling = ref(false)
  let pollTimer = null
  
  // 筛选
  const statusFilter = ref('all')
  const searchQuery = ref('')
  
  // 分页
  const currentPage = ref(1)
  const pageSize = ref(10)
}
```

- [ ] **Step 2: 实现状态解析函数 — parseVectorStatus**

```javascript
  function parseVectorStatus(task) {
    const pss = task.processStepStatus || ''
    const psm = task.processStepMessage || ''

    if (pss === 'VECTORIZING' || pss === 'EXAM_GENERATING') {
      return {
        phase: 'vectorize',
        status: 'processing',
        label: pss === 'VECTORIZING' ? '向量化中...' : '出题中...',
        message: null,
        color: 'info',
        animating: true,
        rawStatus: pss
      }
    }
    
    if (pss === 'VECTORIZED' || ['EXAM_GENERATED', 'COMPLETED'].includes(pss)) {
      return {
        phase: 'vectorize',
        status: 'success',
        label: pss === 'COMPLETED' ? '全部完成' : '向量化完成',
        message: null,
        color: 'success',
        animating: false,
        rawStatus: pss
      }
    }

    if (pss === 'VECTORIZE_FAILED') {
      return {
        phase: 'vectorize',
        status: 'error',
        label: '向量化失败',
        message: psm || '未知错误',
        color: 'error',
        animating: false,
        rawStatus: pss
      }
    }

    if (pss === 'UPLOADED' || !pss) {
      return {
        phase: 'vectorize',
        status: 'pending',
        label: '等待处理',
        message: null,
        color: 'pending',
        animating: false,
        rawStatus: pss || 'UPLOADED'
      }
    }

    if (pss === 'EXAM_GENERATE_FAILED') {
      return {
        phase: 'vectorize',
        status: 'success', // 向量化已成功，只是出题失败
        label: '向量化完成',
        message: null,
        color: 'success',
        animating: false,
        rawStatus: pss
      }
    }

    return {
      phase: 'vectorize',
      status: 'unknown',
      label: '未知',
      message: null,
      color: 'pending',
      animating: false,
      rawStatus: pss
    }
  }
```

- [ ] **Step 3: 实现状态解析函数 — parseExamStatus**

```javascript
  function parseExamStatus(task) {
    const es = task.examStatus || ''
    const pm = task.processMessage || ''
    const pss = task.processStepStatus || ''

    // 如果流程还在向量化阶段或更早，出题尚未开始
    if (['UPLOADED', 'VECTORIZING', 'VECTORIZE_FAILED'].includes(pss)) {
      return {
        phase: 'exam',
        status: 'pending',
        label: '-',
        message: null,
        color: 'pending',
        animating: false,
        rawStatus: es || 'UNGENERATED'
      }
    }

    if (es === 'GENERATING' || pss === 'EXAM_GENERATING') {
      return {
        phase: 'exam',
        status: 'processing',
        label: '生成题目中...',
        message: null,
        color: 'info',
        animating: true,
        rawStatus: es || pss
      }
    }

    if (es === 'GENERATED' || pss === 'EXAM_GENERATED' || pss === 'COMPLETED') {
      // 尝试从消息中提取题目数量
      let label = '题目生成完成'
      if (pm) {
        const match = pm.match(/共(\d+)道题/)
        if (match) label = `生成${match[1]}道题`
      }
      
      return {
        phase: 'exam',
        status: 'success',
        label,
        message: pm || null,
        color: 'success',
        animating: false,
        rawStatus: es || pss
      }
    }

    if (es === 'FAILED' || pss === 'EXAM_GENERATE_FAILED') {
      return {
        phase: 'exam',
        status: 'error',
        label: '出题失败',
        message: pm || '生成失败',
        color: 'error',
        animating: false,
        rawStatus: es || pss
      }
    }

    if (!es || es === 'UNGENERATED') {
      return {
        phase: 'exam',
        status: 'pending',
        label: '未出题',
        message: null,
        color: 'pending',
        animating: false,
        rawStatus: es || 'UNGENERATED'
      }
    }

    return {
      phase: 'exam',
      status: 'unknown',
      label: '-',
      message: null,
      color: 'pending',
      animating: false,
      rawStatus: es
    }
  }
```

- [ ] **Step 4: 实现 addTask、fetchTasks、refreshTasks**

```javascript
  function addTask(fileData) {
    if (!fileData?.id) return
    
    // 检查是否已存在
    const exists = tasks.value.some(t => t.id === fileData.id)
    if (exists) {
      // 更新已有任务
      const idx = tasks.value.findIndex(t => t.id === fileData.id)
      tasks.value[idx] = normalizeTask(fileData)
    } else {
      // 插入到列表头部
      tasks.value.unshift(normalizeTask(fileData))
    }
  }

  function normalizeTask(raw) {
    return {
      id: raw.id,
      fileName: raw.fileName || raw.file_name,
      filePath: raw.filePath || raw.file_path,
      fileSize: raw.fileSize || 0,
      fileType: raw.fileType || '',
      extension: raw.extension || '',
      deptId: raw.deptId || raw.department_id,
      deptName: raw.deptName || raw.dept_name || '',
      uploadUserId: raw.uploadUserId,
      uploadUserName: raw.uploadUserName || '',
      createTime: raw.createTime || raw.created_at || new Date().toISOString(),
      updateTime: raw.updateTime || raw.updated_at || new Date().toISOString(),
      // 关键状态字段
      processStepStatus: raw.processStepStatus || raw.process_step_status || '',
      processStepMessage: raw.processStepMessage || raw.process_step_message || '',
      examStatus: raw.examStatus || raw.exam_status || '',
      processMessage: raw.processMessage || raw.process_message || '',
      auditStatus: raw.auditStatus ?? 0,
      fileCategory: raw.fileCategory || raw.file_category || '',
      isPublic: raw.isPublic ?? 1
    }
  }

  async function fetchTasks() {
    loading.value = true
    try {
      const response = await fileAPI.getFiles({ pageNum: 1, pageSize: 100 })
      if (response.data.code === 200 && response.data.data?.list) {
        const list = response.data.data.list
        
        // 合并到现有任务列表（更新已有任务，添加新任务）
        list.forEach(item => {
          addTask(item)
        })
        
        console.log(`📋 [任务刷新] 共 ${tasks.value.length} 个任务`)
      }
    } catch (e) {
      console.error('加载任务列表失败:', e)
    } finally {
      loading.value = false
    }
  }

  async function refreshTasks() {
    await fetchTasks()
  }
```

- [ ] **Step 5: 实现统计计算属性与筛选逻辑**

```javascript
  const stats = computed(() => {
    const total = tasks.value.length
    const processing = tasks.value.filter(t => {
      const vs = parseVectorStatus(t)
      const es = parseExamStatus(t)
      return vs.status === 'processing' || es.status === 'processing'
    }).length
    const success = tasks.value.filter(t => {
      const vs = parseVectorStatus(t)
      const es = parseExamStatus(t)
      return vs.status === 'success' && (es.status === 'success' || es.status === 'pending')
    }).length
    const failed = tasks.value.filter(t => {
      const vs = parseVectorStatus(t)
      const es = parseExamStatus(t)
      return vs.status === 'error' || es.status === 'error'
    }).length
    return { total, processing, success, failed }
  })

  const filteredTasks = computed(() => {
    let result = [...tasks.value]

    // 状态筛选
    if (statusFilter.value !== 'all') {
      result = result.filter(t => {
        const vs = parseVectorStatus(t)
        const es = parseExamStatus(t)
        
        switch (statusFilter.value) {
          case 'processing':
            return vs.status === 'processing' || es.status === 'processing'
          case 'success':
            return vs.status === 'success' && es.status !== 'error'
          case 'failed':
            return vs.status === 'error' || es.status === 'error'
          case 'pending':
            return vs.status === 'pending' || (vs.status === 'success' && es.status === 'pending')
          default:
            return true
        }
      })
    }

    // 搜索筛选
    if (searchQuery.value.trim()) {
      const kw = searchQuery.value.toLowerCase()
      result = result.filter(t =>
        (t.fileName || '').toLowerCase().includes(kw) ||
        (t.deptName || '').toLowerCase().includes(kw)
      )
    }

    return result
  })

  const paginatedTasks = computed(() => {
    const start = (currentPage.value - 1) * pageSize.value
    return filteredTasks.value.slice(start, start + pageSize.value)
  })

  const totalFilteredPages = computed(() =>
    Math.ceil(filteredTasks.value.length / pageSize.value)
  )
```

- [ ] **Step 6: 实现轮询控制**

```javascript
  function shouldPoll() {
    return tasks.value.some(t => {
      const vs = parseVectorStatus(t)
      const es = parseExamStatus(t)
      return vs.animating || es.animating
    })
  }

  async function pollOnce() {
    if (!shouldPoll()) {
      stopPolling()
      return
    }
    await fetchTasks()
  }

  function startPolling() {
    if (pollTimer) return
    polling.value = true
    pollTimer = setInterval(async () => {
      try {
        await pollOnce()
      } catch (e) {
        console.error('轮询失败:', e)
      }
    }, POLL_INTERVAL)
  }

  function stopPolling() {
    if (pollTimer) {
      clearInterval(pollTimer)
      pollTimer = null
    }
    polling.value = false
  }

  onUnmounted(() => {
    stopPolling()
  })
```

- [ ] **Step 7: 导出所有接口**

```javascript
  return {
    // 状态
    tasks, loading, polling,
    // 筛选
    statusFilter, searchQuery,
    // 分页
    currentPage, pageSize,
    // 计算属性
    stats, filteredTasks, paginatedTasks, totalFilteredPages,
    // 核心方法
    addTask, fetchTasks, refreshTasks,
    parseVectorStatus, parseExamStatus,
    startPolling, stopPolling, shouldPoll
  }
}
```

---

## Phase 2: UI 组件

### Task 2: 创建 UploadTaskItem.vue

**Files:**
- Create: `src/components/exam/UploadTaskItem.vue`

- [ ] **Step 1: 创建完整组件（Template + Script + Style）**

```html
<template>
  <div class="uti-card">
    <!-- 文件基本信息 -->
    <div class="uti-header">
      <div class="uti-file-icon">
        <FileTextOutlined />
      </div>
      <div class="uti-file-info">
        <div class="uti-filename" :title="task.fileName">{{ task.fileName }}</div>
        <div class="uti-meta">
          {{ formatFileSize(task.fileSize) }}
          <span v-if="task.deptName" class="uti-dept">· {{ task.deptName }}</span>
          <span v-if="task.createTime">· {{ formatDate(task.createTime) }}</span>
        </div>
      </div>
    </div>

    <!-- 向量化状态条 -->
    <div :class="['uti-status-bar', vectorStatus.color]">
      <span class="uti-status-icon">
        <LoadingOutlined v-if="vectorStatus.animating" class="spin" />
        <CheckCircleOutlined v-else-if="vectorStatus.status === 'success'" />
        <CloseCircleOutlined v-else-if="vectorStatus.status === 'error'" />
        <ClockCircleOutlined v-else />
      </span>
      <span class="uti-status-label">{{ vectorStatus.label }}</span>
      <button 
        v-if="vectorStatus.status === 'error' && vectorStatus.message"
        class="uti-expand-btn"
        @click="showVectorError = !showVectorError"
      >
        {{ showVectorError ? '收起' : '详情' }}
      </button>
    </div>
    <div v-if="showVectorError && vectorStatus.message" class="uti-error-msg">
      {{ vectorStatus.message }}
    </div>

    <!-- 出题状态条 -->
    <div :class="['uti-status-bar', examStatus.color]">
      <span class="uti-status-icon">
        <LoadingOutlined v-if="examStatus.animating" class="spin" />
        <CheckCircleOutlined v-else-if="examStatus.status === 'success'" />
        <CloseCircleOutlined v-else-if="examStatus.status === 'error'" />
        <MinusCircleOutlined v-else-if="examStatus.status === 'pending'" />
        <QuestionCircleOutlined v-else />
      </span>
      <span class="uti-status-label">{{ examStatus.label }}</span>
      <button 
        v-if="examStatus.status === 'error' && examStatus.message"
        class="uti-expand-btn"
        @click="showExamError = !showExamError"
      >
        {{ showExamError ? '收起' : '详情' }}
      </button>
    </div>
    <div v-if="showExamError && examStatus.message" class="uti-error-msg">
      {{ examStatus.message }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import {
  FileTextOutlined, LoadingOutlined, CheckCircleOutlined,
  CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined,
  QuestionCircleOutlined
} from '@ant-design/icons-vue'

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

const showVectorError = ref(false)
const showExamError = ref(false)

function formatFileSize(bytes) {
  if (!bytes || bytes === 0) return '0 B'
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}

function formatDate(val) {
  if (!val) return ''
  try {
    const d = new Date(val)
    if (isNaN(d.getTime())) return String(val)
    const pad = n => String(n).padStart(2, '0')
    return `${d.getMonth()+1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
  } catch { return String(val) }
}
</script>

<style scoped>
.uti-card {
  padding: var(--space-lg);
  background: var(--bg-card);
  border: 1px solid var(--border-light);
  border-radius: var(--radius-lg);
  transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
}
.uti-card:hover {
  border-color: var(--border-medium);
  box-shadow: var(--shadow-sm);
}

.uti-header {
  display: flex;
  align-items: flex-start;
  gap: var(--space-md);
  margin-bottom: var(--space-md);
}

.uti-file-icon {
  font-size: var(--font-2xl);
  color: var(--text-tertiary);
  flex-shrink: 0;
  background: var(--bg-container);
  padding: var(--space-md);
  border-radius: var(--radius-md);
}

.uti-file-info {
  flex: 1;
  min-width: 0;
}

.uti-filename {
  font-size: var(--font-lg);
  font-weight: var(--weight-semibold);
  color: var(--text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.uti-meta {
  font-size: var(--font-xs);
  color: var(--text-disabled);
  margin-top: 3px;
  display: flex;
  align-items: center;
  gap: 6px;
}

.uti-dept {
  color: var(--text-secondary);
}

/* 状态条 */
.uti-status-bar {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 6px 12px;
  border-radius: var(--radius-md);
  margin-top: var(--space-xs);
  font-size: var(--font-md);
  transition: background var(--transition-fast);
}

.uti-status-bar.pending {
  background: var(--bg-container);
  color: var(--text-disabled);
}
.uti-status-bar.info {
  background: var(--bg-info, #E7F5FF);
  color: var(--color-info, #1971C2);
}
.uti-status-bar.success {
  background: var(--bg-success, #EBFBEE);
  color: var(--color-success, #2B8A3E);
}
.uti-status-bar.error {
  background: var(--bg-error, #FFF5F5);
  color: var(--color-error, #C92A2A);
}

.uti-status-icon {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}

.spin {
  animation: uti-spin 1s linear infinite;
}
@keyframes uti-spin { to { transform: rotate(360deg); } }

.uti-status-label {
  flex: 1;
  font-weight: var(--weight-medium);
}

.uti-expand-btn {
  background: none; border: none; cursor: pointer;
  font-size: var(--font-xs); color: inherit; opacity: 0.7;
  padding: 0 4px; text-decoration: underline;
}
.uti-expand-btn:hover { opacity: 1; }

.uti-error-msg {
  margin-top: var(--space-xs);
  padding: var(--space-sm) var(--space-md);
  background: var(--bg-error);
  border-radius: var(--radius-sm);
  font-size: var(--font-xs);
  color: var(--color-error);
  line-height: 1.5;
  word-break: break-all;
}
</style>
```

---

### Task 3: 创建 UploadTaskPanel.vue

**Files:**
- Create: `src/components/exam/UploadTaskPanel.vue`

- [ ] **Step 1: 创建完整组件（Template + Script + Style）**

```html
<template>
  <div class="utp-panel">
    <!-- 头部 -->
    <div class="utp-header">
      <div class="utp-title-row">
        <h3 class="utp-title">📋 上传任务</h3>
        <button class="utp-refresh-btn" :disabled="loading" @click="$emit('refresh')" title="手动刷新">
          <ReloadOutlined :class="{ spin: loading }" />
          刷新
        </button>
      </div>
      
      <!-- 统计标签栏 -->
      <div class="utp-stats">
        <button 
          :class="['utp-stat-tag', { active: statusFilter === 'all' }]"
          @click="$emit('update:statusFilter', 'all')"
        >全部({{ stats.total }})</button>
        <button 
          :class="['utp-stat-tag processing', { active: statusFilter === 'processing' }]"
          @click="$emit('update:statusFilter', 'processing')"
        >处理中({{ stats.processing }})</button>
        <button 
          :class="['utp-stat-tag success', { active: statusFilter === 'success' }]"
          @click="$emit('update:statusFilter', 'success')"
        >成功({{ stats.success }})</button>
        <button 
          :class="['utp-stat-tag error', { active: statusFilter === 'failed' }]"
          @click="$emit('update:statusFilter', 'failed')"
        >失败({{ stats.failed }})</button>
      </div>
    </div>

    <!-- 工具栏 -->
    <div class="utp-toolbar">
      <input
        class="utp-search"
        placeholder="搜索文件名..."
        :value="searchQuery"
        @input="$emit('update:searchQuery', $event.target.value)"
      />
      <span class="utp-polling-badge" v-if="polling">
        <LoadingOutlined class="spin" /> 自动刷新中
      </span>
    </div>

    <!-- 任务列表 -->
    <div class="utp-list">
      <div v-if="loading && paginatedTasks.length === 0" class="utp-state">
        <LoadingOutlined class="spin utp-loading-icon" />
        加载中...
      </div>
      <div v-else-if="paginatedTasks.length === 0" class="utp-state">
        <InboxOutlined class="utp-empty-icon" />
        <p>暂无上传任务</p>
      </div>
      <UploadTaskItem
        v-for="task in paginatedTasks"
        :key="task.id"
        :task="task"
        :vector-status="parseVectorStatus(task)"
        :exam-status="parseExamStatus(task)"
      />

      <!-- 分页器 -->
      <div v-if="totalFilteredPages > 1" class="utp-pagination">
        <button
          class="utp-page-btn"
          :disabled="currentPage <= 1"
          @click="$emit('update:currentPage', currentPage - 1)"
        >&lt;</button>
        <span class="utp-page-info">{{ currentPage }}/{{ totalFilteredPages }}</span>
        <button
          class="utp-page-btn"
          :disabled="currentPage >= totalFilteredPages"
          @click="$emit('update:currentPage', currentPage + 1)"
        >&gt;</button>
      </div>
    </div>
  </div>
</template>

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

defineProps({
  stats: { type: Object, required: true },
  statusFilter: { type: String, default: 'all' },
  searchQuery: { type: String, default: '' },
  currentPage: { type: Number, default: 1 },
  paginatedTasks: { type: Array, default: () => [] },
  totalFilteredPages: { type: Number, default: 0 },
  loading: { type: Boolean, default: false },
  polling: { type: Boolean, default: false },
  parseVectorStatus: { type: Function, required: true },
  parseExamStatus: { type: Function, required: true }
})

defineEmits(['refresh', 'update:statusFilter', 'update:searchQuery', 'update:currentPage'])
</script>

<style scoped>
.utp-panel {
  background: var(--bg-container);
  border: 1px solid var(--border-light);
  border-radius: var(--radius-xl);
  overflow: hidden;
}

.utp-header {
  padding: var(--space-lg) var(--space-xl);
  border-bottom: 1px solid var(--border-light);
  background: var(--bg-card);
}

.utp-title-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: var(--space-sm);
}

.utp-title {
  margin: 0;
  font-size: var(--font-xl);
  font-weight: var(--weight-bold);
  color: var(--text-primary);
}

.utp-refresh-btn {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 4px 12px; border: 1px solid var(--border-default);
  border-radius: var(--radius-md); background: var(--bg-card);
  font-size: var(--font-md); cursor: pointer;
  color: var(--text-secondary); transition: all var(--transition-fast);
}
.utp-refresh-btn:hover:not(:disabled) { border-color: var(--text-primary); color: var(--text-primary); }
.utp-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }

.utp-stats {
  display: flex; gap: var(--space-xs); flex-wrap: wrap;
}

.utp-stat-tag {
  padding: 3px 10px; border: none; border-radius: var(--radius-md);
  background: var(--bg-container); font-size: var(--font-xs);
  font-weight: var(--weight-medium); cursor: pointer;
  color: var(--text-secondary); transition: all var(--transition-fast);
}
.utp-stat-tag.active { background: var(--text-primary); color: var(--text-inverse); }
.utp-stat-tag.processing.active { background: var(--color-info, #1971C2); }
.utp-stat-tag.success.active { background: var(--color-success, #2B8A3E); }
.utp-stat-tag.error.active { background: var(--color-error, #C92A2A); }

.utp-toolbar {
  display: flex; align-items: center; gap: var(--space-sm);
  padding: var(--space-sm) var(--space-xl);
  border-bottom: 1px solid var(--border-light);
  background: var(--bg-container);
}

.utp-search {
  flex: 1; max-width: 240px; padding: 5px 10px;
  border: 1px solid var(--border-default); border-radius: var(--radius-md);
  font-size: var(--font-md); outline: none; box-sizing: border-box;
}
.utp-search:focus { border-color: var(--text-primary); box-shadow: 0 0 0 2px var(--color-primary-alpha-15); }

.utp-polling-badge {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--font-xs); color: var(--color-info, #1971C2);
  font-weight: var(--weight-medium);
}

.utp-list {
  padding: var(--space-md) var(--space-xl) var(--space-xl);
  display: flex; flex-direction: column; gap: var(--space-sm);
  min-height: 120px;
}

.utp-state {
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-sm); padding: 40px 20px;
  color: var(--text-disabled); font-size: var(--font-lg);
}

.utp-loading-icon { font-size: 24px; }
.utp-empty-icon { font-size: 36px; }

.spin { animation: utp-spin 1s linear infinite; }
@keyframes utp-spin { to { transform: rotate(360deg); } }

.utp-pagination {
  display: flex; justify-content: center; align-items: center;
  gap: var(--space-sm); margin-top: var(--space-md);
  padding-top: var(--space-md); border-top: 1px solid var(--border-light);
}

.utp-page-btn {
  width: 28px; height: 28px; border: 1px solid var(--border-default);
  border-radius: var(--radius-md); background: var(--bg-card);
  cursor: pointer; display: flex; align-items: center; justify-content: center;
  font-size: var(--font-md); color: var(--text-secondary);
  transition: all var(--transition-fast); padding: 0;
}
.utp-page-btn:hover:not(:disabled) { border-color: var(--text-primary); color: var(--text-primary); }
.utp-page-btn:disabled { opacity: 0.35; cursor: not-allowed; }

.utp-page-info {
  font-size: var(--font-md); color: var(--text-disabled);
  min-width: 40px; text-align: center;
}
</style>
```

---

## Phase 3: ReadModule 集成

### Task 4: 补全 mapBackendToFrontend 字段映射

**Files:**
- Modify: `src/components/ReadModule.vue:1388-1414` (mapBackendToFrontend 函数)

- [ ] **Step 1: 在 mapBackendToFrontend 返回对象中添加 4 个状态字段**

在现有返回对象的 `fileCategory: backendFile.fileCategory` 之后添加：

```javascript
    // 处理状态字段（用于上传任务追踪）
    processStepStatus: backendFile.processStepStatus || backendFile.process_step_status || '',
    processStepMessage: backendFile.processStepMessage || backendFile.process_step_message || '',
    examStatus: backendFile.examStatus || backendFile.exam_status || '',
    processMessage: backendFile.processMessage || backendFile.process_message || '',
```

---

### Task 5: 在 ReadModule 中嵌入 UploadTaskPanel

**Files:**
- Modify: `src/components/ReadModule.vue`
  - Template 区域：在 Tab 栏下方添加面板
  - Script 区域：引入 composable 和组件，绑定上传成功事件

- [ ] **Step 1: 添加 import 语句**

在现有的 `import` 块中（约 L1072 之后）添加：

```javascript
import UploadTaskPanel from './exam/UploadTaskPanel.vue'
import { useUploadTasks } from './exam/composables/useUploadTasks.js'
```

同时确保以下图标已导入（如果没有则添加）：
```javascript
ReloadOutlined, InboxOutlined, LoadingOutlined, CheckCircleOutlined,
CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, QuestionCircleOutlined
```

- [ ] **Step 2: 添加 composable 初始化**

在 `onMounted` 之前（约 L2123 之前）添加：

```javascript
// 上传任务管理
const showTaskPanel = ref(false)
const {
  tasks: uploadTasks,
  loading: taskLoading,
  polling: taskPolling,
  statusFilter: taskStatusFilter,
  searchQuery: taskSearchQuery,
  currentPage: taskCurrentPage,
  stats: taskStats,
  paginatedTasks: taskPaginatedTasks,
  totalFilteredPages: taskTotalPages,
  parseVectorStatus,
  parseExamStatus,
  addTask: addUploadTask,
  fetchTasks: fetchUploadTasks,
  refreshTasks: refreshUploadTasks,
  startPolling: startTaskPolling,
  stopPolling: stopTaskPolling
} = useUploadTasks()
```

- [ ] **Step 3: 修改 handleUploadSubmit 成功回调**

将现有的上传成功代码块（约 L2363-L2377）修改为：

```javascript
    if (response.data.code === 200) {
      const uploadedFile = response.data.data
      
      const fileTypeText = uploadForm.isPublic ? '公开文件' : '个人文件'
      
      let successMsg = `${fileTypeText}"${uploadForm.file.name}"上传成功！`
      if (!isAdminUser(userInfo.value) && uploadForm.isPublic) {
        successMsg += ' 文件已进入待审批队列，请等待管理员审核！'
      }
      
      message.success(successMsg)
      closeUploadModal()

      // 添加到上传任务列表
      addUploadTask(uploadedFile)
      showTaskPanel.value = true
      startTaskPolling()
      
      await loadFileList(null, true)
      if (canApproveFile.value) {
        loadPendingFiles()
      }
    } else {
```

- [ ] **Step 4: 在模板中添加 UploadTaskPanel 组件**

在 `<div class="read-module-tabs">` 标签之后、内容区之前插入：

```html
    <!-- 上传任务面板 -->
    <UploadTaskPanel
      v-if="showTaskPanel"
      :stats="taskStats"
      v-model:status-filter="taskStatusFilter"
      v-model:search-query="taskSearchQuery"
      v-model:current-page="taskCurrentPage"
      :paginated-tasks="taskPaginatedTasks"
      :total-filtered-pages="taskTotalPages"
      :loading="taskLoading"
      :polling="taskPolling"
      :parse-vector-status="parseVectorStatus"
      :parse-exam-status="parseExamStatus"
      @refresh="refreshUploadTasks"
    />
```

同时在工具栏区域（上传按钮附近）添加一个入口按钮：

```html
<button
  v-if="uploadTasks.length > 0"
  class="btn btn-secondary btn-sm"
  style="margin-left: auto;"
  @click="showTaskPanel = !showTaskPanel"
>
  📋 上传任务 ({{ uploadTasks.length }})
</button>
```

- [ ] **Step 5: 在 onMounted 中初始化任务列表**

在 `onMounted` 的最后（约 L2143 之前）添加：

```javascript
  // 加载上传任务列表（如果有未完成的任务）
  await fetchUploadTasks()
  if (tasks.value.some(t => {
    const pss = t.processStepStatus
    return ['UPLOADED','VECTORIZING','EXAM_GENERATING'].includes(pss)
  })) {
    showTaskPanel.value = true
    startTaskPolling()
  }
```

注意：这里需要使用 composable 内部的 `tasks` 引用。由于 composable 已经导出了 `tasks`，直接使用即可。

---

## Phase 4: 验证

### Task 6: 构建验证与功能回归测试

**Files:** 无新建文件

- [ ] **Step 1: 运行前端构建验证**

Run: `npm run build`
Expected: Vite 构建成功，零编译错误

- [ ] **Step 2: VS Code 诊断检查**

对以下文件运行 GetDiagnostics，确认零错误：
- `src/components/exam/composables/useUploadTasks.js`
- `src/components/exam/UploadTaskPanel.vue`
- `src/components/exam/UploadTaskItem.vue`
- `src/components/ReadModule.vue`

- [ ] **Step 3: 功能清单验证**

逐一确认以下功能路径正常工作：
- [ ] 上传文件后自动弹出任务面板
- [ ] 任务面板显示正确的文件信息（名称、大小、部门、时间）
- [ ] 向量化状态正确显示（等待→处理中动画→成功/失败）
- [ ] 出题状态正确显示（未开始→处理中动画→成功含题目数/失败含错误消息）
- [ ] 错误消息可展开/收起查看详情
- [ ] 统计标签栏数字准确（全部/处理中/成功/失败）
- [ ] 点击统计标签可筛选对应状态的任务
- [ ] 搜索框支持按文件名和部门名搜索
- [ ] 分页器正常工作
- [ ] 手动刷新按钮可触发重新加载
- [ ] 自动轮询在有"处理中"任务时启动，全部完成后停止
- [ ] 关闭面板后再打开，数据保持不变
- [ ] 现有文件列表功能不受影响（部门筛选、分类切换等）

- [ ] **Step 4: 清理调试日志**

移除所有 `console.log` 调试语句（保留 `console.error`），包括：
- `useUploadTasks.js` 中的 `console.log('📋 [任务刷新]...')`
- `ReadModule.vue` 中之前添加的部门筛选调试日志（如果还在）

---

## 自检清单

### Spec 覆盖度

| 设计文档需求 | 对应 Task | 状态 |
|------------|----------|------|
| 集成上传任务列表组件 | Task 3 + Task 5 | ✅ |
| 解析 processStepMessage 判断向量化状态 | Task 1 Step 2 | ✅ |
| 解析 processMessage 判断出题状态 | Task 1 Step 3 | ✅ |
| 向量化成功/失败状态展示 | Task 2 | ✅ |
| 出题成功/失败状态展示 | Task 2 | ✅ |
| 处理中状态（带动画） | Task 2 | ✅ |
| 实时更新（自动轮询） | Task 1 Step 6 | ✅ |
| 分页功能 | Task 1 Step 5 + Task 3 | ✅ |
| 状态筛选功能 | Task 1 Step 5 + Task 3 | ✅ |
| 手动刷新按钮 | Task 3 | ✅ |
| 符合现有 UI/UX 规范（design-tokens） | Task 2 + Task 3 CSS | ✅ |
| 嵌入 ReadModule | Task 5 | ✅ |
| 上传后自动加入任务列表 | Task 5 Step 3 | ✅ |

### 占位符扫描
- [ ] 无 TBD/TODO 占位符
- [ ] 无 "类似 Task N" 引用
- [ ] 所有代码步骤包含完整实现

### 类型一致性
- [ ] `parseVectorStatus` / `parseExamStatus` 在 Task 1 定义，Task 2/3 通过 props 传递一致
- [ ] composable 导出名称在 Task 1 定义，Task 5 解构使用一致
- [ ] CSS 变量使用 design-tokens.css 中实际存在的变量名
