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

1140 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文件查看模块 — 上传任务列表功能 实施计划
> **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 中实际存在的变量名