1140 lines
33 KiB
Markdown
1140 lines
33 KiB
Markdown
# 文件查看模块 — 上传任务列表功能 实施计划
|
||
|
||
> **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)"
|
||
><</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` 之后添加:
|
||
|
||
```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 中实际存在的变量名
|