Files
aue/docs/superpowers/plans/2026-05-29-upload-task-panel-plan.md
2026-06-03 13:16:30 +08:00

33 KiB
Raw Blame History

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

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

字段 类型 用途
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 框架与导入

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
  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
  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
  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: 实现统计计算属性与筛选逻辑
  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: 实现轮询控制
  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: 导出所有接口
  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

<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

<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 之后添加:

    // 处理状态字段(用于上传任务追踪)
    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 之后)添加:

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

同时确保以下图标已导入(如果没有则添加):

ReloadOutlined, InboxOutlined, LoadingOutlined, CheckCircleOutlined,
CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, QuestionCircleOutlined
  • Step 2: 添加 composable 初始化

onMounted 之前(约 L2123 之前)添加:

// 上传任务管理
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修改为

    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"> 标签之后、内容区之前插入:

    <!-- 上传任务面板 -->
    <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"
    />

同时在工具栏区域(上传按钮附近)添加一个入口按钮:

<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 之前)添加:

  // 加载上传任务列表(如果有未完成的任务)
  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 中实际存在的变量名