33 KiB
文件查看模块 — 上传任务列表功能 实施计划
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)"
><</button>
<span class="utp-page-info">{{ currentPage }}/{{ totalFilteredPages }}</span>
<button
class="utp-page-btn"
:disabled="currentPage >= totalFilteredPages"
@click="$emit('update:currentPage', currentPage + 1)"
>></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 中实际存在的变量名