2429 lines
55 KiB
Markdown
2429 lines
55 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:** 优化文件上传用户体验(进度条、拖拽、验证)和任务状态管理(重试、智能轮询、通知),提升系统易用性和反馈机制
|
||
|
||
**Architecture:** 在现有Vue 3组件架构上进行增量式增强,新增5个UI组件和2个工具函数,修改3个现有文件,保持向后兼容性
|
||
|
||
**Tech Stack:** Vue 3 Composition API, Axios, Ant Design Vue Icons, HTML5 Drag & Drop API, CSS3 Animations
|
||
|
||
---
|
||
|
||
## 📁 文件结构规划
|
||
|
||
### 新增文件(7个)
|
||
|
||
```
|
||
src/
|
||
├── utils/
|
||
│ └── fileValidator.js # 文件验证工具(类型、大小、名称)
|
||
├── components/ui/
|
||
│ ├── ProgressBar.vue # 可复用上传进度条组件
|
||
│ ├── DropZone.vue # 拖拽上传区域组件
|
||
│ ├── ToastNotification.vue # Toast通知组件
|
||
│ └── SkeletonCard.vue # 骨架屏加载占位组件
|
||
└── utils/
|
||
└── notification.js # 通知显示工具函数
|
||
```
|
||
|
||
### 修改文件(3个)
|
||
|
||
```
|
||
src/
|
||
├── api/
|
||
│ └── file.js # 添加uploadFile进度回调参数
|
||
├── components/
|
||
│ ├── FileSelector.vue # 集成拖拽、验证、进度条功能
|
||
│ └── exam/
|
||
│ ├── UploadTaskItem.vue # 添加一键重试按钮
|
||
│ └── composables/
|
||
│ └── useUploadTasks.js # 实现智能轮询+批量操作
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: 创建文件验证工具函数
|
||
|
||
**Files:**
|
||
- Create: `src/utils/fileValidator.js`
|
||
- Test: 手动测试(浏览器控制台)
|
||
|
||
**目标:** 实现前端文件验证逻辑,支持类型检查、大小限制、文件名合法性校验
|
||
|
||
- [ ] **Step 1: 编写 fileValidator.js 基础结构**
|
||
|
||
```javascript
|
||
/**
|
||
* 文件验证工具函数
|
||
* 用于在上传前对文件进行前端验证,减少无效请求
|
||
*/
|
||
|
||
// 支持的文件扩展名白名单
|
||
const ALLOWED_EXTENSIONS = [
|
||
'.docx', '.pdf', '.txt', '.md',
|
||
'.xlsx', '.pptx', '.doc', '.xls', '.ppt'
|
||
]
|
||
|
||
// 最大文件大小:50MB
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||
|
||
// 最大文件名长度
|
||
const MAX_FILENAME_LENGTH = 200
|
||
|
||
// 禁止的文件名字符
|
||
const INVALID_FILENAME_CHARS = /[\\/:*?"<>|]/
|
||
|
||
/**
|
||
* 验证单个文件
|
||
* @param {File} file - File对象
|
||
* @returns {{ valid: boolean, errors: string[] }}
|
||
*/
|
||
export function validateFile(file) {
|
||
const errors = []
|
||
|
||
// 1. 检查文件是否存在
|
||
if (!file) {
|
||
errors.push('未选择文件')
|
||
return { valid: false, errors }
|
||
}
|
||
|
||
// 2. 检查文件名长度
|
||
if (file.name.length > MAX_FILENAME_LENGTH) {
|
||
errors.push(`文件名过长 (${file.name.length}字符,限制${MAX_FILENAME_LENGTH}字符)`)
|
||
}
|
||
|
||
// 3. 检查文件名是否包含非法字符
|
||
if (INVALID_FILENAME_CHARS.test(file.name)) {
|
||
errors.push('文件名包含非法字符 (\\ / : * ? " < > |)')
|
||
}
|
||
|
||
// 4. 检查文件扩展名
|
||
const ext = getFileExtension(file.name)
|
||
if (!ALLOWED_EXTENSIONS.includes(ext.toLowerCase())) {
|
||
errors.push(`不支持的文件格式 (.${ext}),支持格式:${ALLOWED_EXTENSIONS.join(', ')}`)
|
||
}
|
||
|
||
// 5. 检查文件大小
|
||
if (file.size > MAX_FILE_SIZE) {
|
||
errors.push(`文件过大 (${formatFileSize(file.size)},限制${formatFileSize(MAX_FILE_SIZE)})`)
|
||
}
|
||
|
||
// 6. 检查文件大小是否为0(空文件)
|
||
if (file.size === 0) {
|
||
errors.push('文件为空(0字节)')
|
||
}
|
||
|
||
return {
|
||
valid: errors.length === 0,
|
||
errors,
|
||
ext,
|
||
size: file.size
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量验证多个文件
|
||
* @param {File[]} files - File对象数组
|
||
* @returns {{ validFiles: File[], invalidFiles: { file: File, errors: string[] }[] }}
|
||
*/
|
||
export function validateFiles(files) {
|
||
const validFiles = []
|
||
const invalidFiles = []
|
||
|
||
files.forEach(file => {
|
||
const result = validateFile(file)
|
||
if (result.valid) {
|
||
validFiles.push(file)
|
||
} else {
|
||
invalidFiles.push({
|
||
file,
|
||
errors: result.errors
|
||
})
|
||
}
|
||
})
|
||
|
||
return { validFiles, invalidFiles }
|
||
}
|
||
|
||
/**
|
||
* 获取文件扩展名(含点号)
|
||
* @param {string} filename - 文件名
|
||
* @returns {string}
|
||
*/
|
||
function getFileExtension(filename) {
|
||
const lastDotIndex = filename.lastIndexOf('.')
|
||
if (lastDotIndex === -1) return ''
|
||
return filename.substring(lastDotIndex).toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小为可读字符串
|
||
* @param {number} bytes - 字节数
|
||
* @returns {string}
|
||
*/
|
||
export function formatFileSize(bytes) {
|
||
if (!bytes || bytes === 0) return '0B'
|
||
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||
const size = parseFloat((bytes / Math.pow(1024, i)).toFixed(1))
|
||
|
||
return `${size}${units[i]}`
|
||
}
|
||
|
||
/**
|
||
* 根据扩展名获取文件类型标签
|
||
* @param {string} ext - 扩展名(含点号)
|
||
* @returns {string}
|
||
*/
|
||
export function getFileTypeLabel(ext) {
|
||
const typeMap = {
|
||
'.docx': 'Word文档',
|
||
'.doc': 'Word文档',
|
||
'.pdf': 'PDF文档',
|
||
'.xlsx': 'Excel表格',
|
||
'.xls': 'Excel表格',
|
||
'.pptx': 'PowerPoint演示',
|
||
'.ppt': 'PowerPoint演示',
|
||
'.txt': '文本文件',
|
||
'.md': 'Markdown文档'
|
||
}
|
||
|
||
return typeMap[ext.toLowerCase()] || '未知类型'
|
||
}
|
||
|
||
/**
|
||
* 根据扩展名获取对应的图标组件名
|
||
* @param {string} ext - 扩展名(含点号)
|
||
* @returns {string} Ant Design图标组件名
|
||
*/
|
||
export function getFileIconName(ext) {
|
||
const iconMap = {
|
||
'.docx': 'FileWordOutlined',
|
||
'.doc': 'FileWordOutlined',
|
||
'.pdf': 'FilePdfOutlined',
|
||
'.xlsx': 'FileExcelOutlined',
|
||
'.xls': 'FileExcelOutlined',
|
||
'.pptx': 'FilePptOutlined',
|
||
'.ppt': 'FilePptOutlined',
|
||
'.txt': 'FileTextOutlined',
|
||
'.md': 'FileTextOutlined'
|
||
}
|
||
|
||
return iconMap[ext.toLowerCase()] || 'FileOutlined'
|
||
}
|
||
|
||
export default {
|
||
validateFile,
|
||
validateFiles,
|
||
formatFileSize,
|
||
getFileTypeLabel,
|
||
getFileIconName,
|
||
ALLOWED_EXTENSIONS,
|
||
MAX_FILE_SIZE
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 测试验证函数**
|
||
|
||
在浏览器控制台执行:
|
||
```javascript
|
||
// 导入模块后测试
|
||
import { validateFile, validateFiles, formatFileSize } from './utils/fileValidator.js'
|
||
|
||
// 测试1: 有效文件
|
||
const validFile = new File(['test'], 'document.docx', { type: 'application/docx' })
|
||
console.log(validateFile(validFile))
|
||
// 预期: { valid: true, errors: [], ext: '.docx', size: 4 }
|
||
|
||
// 测试2: 无效扩展名
|
||
const invalidExt = new File(['test'], 'script.exe', {})
|
||
console.log(validateFile(invalidExt))
|
||
// 预期: { valid: false, errors: ["不支持的文件格式 (.exe)..."] }
|
||
|
||
// 测试3: 文件过大(模拟)
|
||
const largeFile = new File(new Array(60*1024*1024), 'large.docx', {})
|
||
console.log(validateFile(largeFile))
|
||
// 预期: { valid: false, errors: ["文件过大..."] }
|
||
|
||
// 测试4: 格式化文件大小
|
||
console.log(formatFileSize(1024 * 1024 * 2.3))
|
||
// 预期: "2.3MB"
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/utils/fileValidator.js
|
||
git commit -m "feat: add file validation utility with type/size/name checks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 创建上传进度条组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/ProgressBar.vue`
|
||
|
||
**目标:** 实现可视化上传进度条,支持百分比显示、动画效果、状态切换
|
||
|
||
- [ ] **Step 1: 编写 ProgressBar.vue 组件**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="progress-bar-wrapper">
|
||
<!-- 文件信息行 -->
|
||
<div class="progress-info" v-if="showInfo">
|
||
<span class="progress-filename" :title="filename">{{ filename }}</span>
|
||
<span class="progress-stats">{{ statsText }}</span>
|
||
</div>
|
||
|
||
<!-- 进度条轨道 -->
|
||
<div class="progress-track" :class="[status]">
|
||
<div
|
||
class="progress-fill"
|
||
:style="{ width: percent + '%' }"
|
||
:class="{ animating: status === 'uploading' }"
|
||
>
|
||
<span v-if="percent >= 10" class="progress-text">{{ percent }}%</span>
|
||
</div>
|
||
|
||
<!-- 上传中状态图标 -->
|
||
<LoadingOutlined v-if="status === 'uploading'" class="progress-icon spin" />
|
||
|
||
<!-- 成功状态图标 -->
|
||
<CheckCircleOutlined v-else-if="status === 'success'" class="progress-icon success" />
|
||
|
||
<!-- 失败状态图标 -->
|
||
<CloseCircleOutlined v-else-if="status === 'error'" class="progress-icon error" />
|
||
</div>
|
||
|
||
<!-- 状态文字提示 -->
|
||
<div class="progress-hint" v-if="hintText">{{ hintText }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||
|
||
const props = defineProps({
|
||
percent: {
|
||
type: Number,
|
||
default: 0,
|
||
validator: (val) => val >= 0 && val <= 100
|
||
},
|
||
status: {
|
||
type: String,
|
||
default: 'idle', // idle | uploading | success | error
|
||
validator: (val) => ['idle', 'uploading', 'success', 'error'].includes(val)
|
||
},
|
||
filename: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
loaded: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
total: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
showInfo: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
error: {
|
||
type: String,
|
||
default: ''
|
||
}
|
||
})
|
||
|
||
const statsText = computed(() => {
|
||
if (props.total > 0) {
|
||
const loadedMB = (props.loaded / 1024 / 1024).toFixed(1)
|
||
const totalMB = (props.total / 1024 / 1024).toFixed(1)
|
||
return `${loadedMB}MB / ${totalMB}MB`
|
||
}
|
||
return ''
|
||
})
|
||
|
||
const hintText = computed(() => {
|
||
switch (props.status) {
|
||
case 'uploading':
|
||
return `上传中 ${props.percent}%...`
|
||
case 'success':
|
||
return '✅ 上传成功'
|
||
case 'error':
|
||
return `❌ 上传失败${props.error ? ': ' + props.error : ''}`
|
||
default:
|
||
return ''
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.progress-bar-wrapper {
|
||
width: 100%;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
.progress-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.progress-filename {
|
||
font-weight: 500;
|
||
color: #1f2937;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 60%;
|
||
}
|
||
|
||
.progress-stats {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.progress-track {
|
||
position: relative;
|
||
height: 8px;
|
||
background: #e5e7eb;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
transition: background-color 0.3s ease;
|
||
}
|
||
|
||
.progress-track.uploading {
|
||
background: #dbeafe;
|
||
}
|
||
|
||
.progress-track.success {
|
||
background: #d1fae5;
|
||
}
|
||
|
||
.progress-track.error {
|
||
background: #fee2e2;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
padding-right: 8px;
|
||
min-width: 20px;
|
||
}
|
||
|
||
.progress-track.uploading .progress-fill {
|
||
background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 50%, #3b82f6 100%);
|
||
background-size: 200% 100%;
|
||
animation: progress-shimmer 1.5s infinite;
|
||
}
|
||
|
||
.progress-track.success .progress-fill {
|
||
background: #10b981;
|
||
}
|
||
|
||
.progress-track.error .progress-fill {
|
||
background: #ef4444;
|
||
}
|
||
|
||
.progress-fill.animating {
|
||
animation: progress-pulse 1s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes progress-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
|
||
@keyframes progress-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
.progress-text {
|
||
color: white;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
}
|
||
|
||
.progress-icon {
|
||
position: absolute;
|
||
right: 8px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 14px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.progress-icon.success {
|
||
color: #10b981;
|
||
}
|
||
|
||
.progress-icon.error {
|
||
color: #ef4444;
|
||
}
|
||
|
||
.progress-hint {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* 响应式适配 */
|
||
@media (max-width: 640px) {
|
||
.progress-info {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 2px;
|
||
}
|
||
|
||
.progress-filename {
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 测试组件渲染**
|
||
|
||
在任意父组件中使用:
|
||
```vue
|
||
<ProgressBar
|
||
:percent="65"
|
||
status="uploading"
|
||
filename="制度管理办法.docx"
|
||
:loaded="1500000"
|
||
:total="2300000"
|
||
/>
|
||
```
|
||
|
||
预期效果:
|
||
- 显示蓝色渐变进度条,65%填充
|
||
- 文件名 + "1.5MB / 2.3MB"
|
||
- 提示文字:"上传中 65%..."
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/ProgressBar.vue
|
||
git commit -m "feat: add ProgressBar component with animation and status support"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 创建拖拽上传区域组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/DropZone.vue`
|
||
|
||
**目标:** 实现HTML5拖拽上传区域,支持多文件拖放、视觉反馈、自动验证
|
||
|
||
- [ ] **Step 1: 编写 DropZone.vue 组件**
|
||
|
||
```vue
|
||
<template>
|
||
<div
|
||
class="drop-zone"
|
||
:class="{
|
||
'is-dragging': isDragging,
|
||
'is-disabled': disabled,
|
||
'has-files': selectedFiles.length > 0
|
||
}"
|
||
@dragenter.prevent="onDragEnter"
|
||
@dragover.prevent="onDragOver"
|
||
@dragleave.prevent="onDragLeave"
|
||
@drop.prevent="onDrop"
|
||
@click="handleClick"
|
||
>
|
||
<!-- 默认状态内容 -->
|
||
<div v-if="!isDragging && !disabled" class="drop-zone-content">
|
||
<InboxOutlined class="drop-icon" :style="{ fontSize: iconSize + 'px' }" />
|
||
<p class="drop-text">
|
||
拖拽文件到此处,或<span class="link">点击选择</span>
|
||
</p>
|
||
<p class="drop-hint">
|
||
支持 {{ acceptedTypesDisplay }},最大 {{ maxSizeDisplay }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 拖拽悬停状态 -->
|
||
<div v-else-if="isDragging && !disabled" class="drop-zone-active">
|
||
<div class="active-border"></div>
|
||
<span class="active-text">
|
||
<CloudUploadOutlined class="active-icon" />
|
||
释放文件以上传
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 禁用状态 -->
|
||
<div v-else-if="disabled" class="drop-zone-disabled">
|
||
<StopOutlined class="drop-icon" />
|
||
<p class="drop-text">上传功能已禁用</p>
|
||
</div>
|
||
|
||
<!-- 已选文件预览 -->
|
||
<div v-if="selectedFiles.length > 0" class="selected-files-preview">
|
||
<div class="preview-header">
|
||
<CheckCircleOutlined class="preview-success-icon" />
|
||
<span>已选择 {{ selectedFiles.length }} 个文件</span>
|
||
<button class="clear-btn" @click.stop="clearFiles">清除</button>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(file, index) in selectedFiles.slice(0, maxPreviewCount)"
|
||
:key="index"
|
||
class="preview-item"
|
||
>
|
||
<FileTextOutlined class="preview-file-icon" />
|
||
<span class="preview-name" :title="file.name">{{ file.name }}</span>
|
||
<span class="preview-size">{{ formatSize(file.size) }}</span>
|
||
</div>
|
||
<div v-if="selectedFiles.length > maxPreviewCount" class="preview-more">
|
||
还有 {{ selectedFiles.length - maxPreviewCount }} 个文件...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 隐藏的文件input -->
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
:multiple="multiple"
|
||
:accept="acceptedTypes"
|
||
:disabled="disabled"
|
||
@change="onFileSelected"
|
||
style="display: none"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import {
|
||
InboxOutlined, CloudUploadOutlined, StopOutlined,
|
||
CheckCircleOutlined, FileTextOutlined
|
||
} from '@ant-design/icons-vue'
|
||
import { validateFiles, formatFileSize } from '../../utils/fileValidator.js'
|
||
|
||
const props = defineProps({
|
||
acceptedTypes: {
|
||
type: String,
|
||
default: '.docx,.pdf,.txt,.md,.xlsx,.pptx'
|
||
},
|
||
maxSize: {
|
||
type: Number,
|
||
default: 50 * 1024 * 1024 // 50MB
|
||
},
|
||
multiple: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
disabled: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
iconSize: {
|
||
type: Number,
|
||
default: 48
|
||
},
|
||
maxPreviewCount: {
|
||
type: Number,
|
||
default: 3
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['files-selected', 'error', 'clear'])
|
||
|
||
const isDragging = ref(false)
|
||
const fileInputRef = ref(null)
|
||
const selectedFiles = ref([])
|
||
|
||
const acceptedTypesDisplay = computed(() => {
|
||
return props.acceptedTypes.split(',').map(t => t.trim().replace('.', '')).join(' ')
|
||
})
|
||
|
||
const maxSizeDisplay = computed(() => {
|
||
return formatFileSize(props.maxSize)
|
||
})
|
||
|
||
function onDragEnter(e) {
|
||
if (props.disabled) return
|
||
isDragging.value = true
|
||
}
|
||
|
||
function onDragOver(e) {
|
||
if (props.disabled) return
|
||
e.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
|
||
function onDragLeave(e) {
|
||
// 仅当鼠标完全离开元素时才取消高亮
|
||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||
isDragging.value = false
|
||
}
|
||
}
|
||
|
||
function onDrop(e) {
|
||
isDragging.value = false
|
||
|
||
if (props.disabled) return
|
||
|
||
const files = Array.from(e.dataTransfer.files)
|
||
processFiles(files)
|
||
}
|
||
|
||
function handleClick() {
|
||
if (props.disabled) return
|
||
|
||
// 如果已选择文件且不允许多选,则清空重新选择
|
||
if (selectedFiles.value.length > 0 && !props.multiple) {
|
||
clearFiles()
|
||
}
|
||
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
function onFileSelected(e) {
|
||
const files = Array.from(e.target.files)
|
||
processFiles(files)
|
||
|
||
// 重置input以允许重复选择相同文件
|
||
e.target.value = ''
|
||
}
|
||
|
||
function processFiles(files) {
|
||
if (files.length === 0) return
|
||
|
||
// 如果不支持多选,只取第一个
|
||
const filesToProcess = props.multiple ? files : [files[0]]
|
||
|
||
const { validFiles, invalidFiles } = validateFiles(filesToProcess)
|
||
|
||
// 发出有效文件事件
|
||
if (validFiles.length > 0) {
|
||
if (props.multiple) {
|
||
selectedFiles.value = [...selectedFiles.value, ...validFiles]
|
||
} else {
|
||
selectedFiles.value = validFiles
|
||
}
|
||
emit('files-selected', validFiles)
|
||
}
|
||
|
||
// 发出错误事件
|
||
if (invalidFiles.length > 0) {
|
||
emit('error', invalidFiles)
|
||
}
|
||
}
|
||
|
||
function clearFiles() {
|
||
selectedFiles.value = []
|
||
emit('clear')
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
return formatFileSize(bytes)
|
||
}
|
||
|
||
// 暴露方法给父组件
|
||
defineExpose({ clearFiles })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.drop-zone {
|
||
position: relative;
|
||
border: 2px dashed #d1d5db;
|
||
border-radius: 12px;
|
||
padding: 32px 24px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
background: #fafafa;
|
||
user-select: none;
|
||
}
|
||
|
||
.drop-zone:hover:not(.is-disabled):not(.is-dragging) {
|
||
border-color: #9ca3af;
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.drop-zone.is-dragging {
|
||
border-color: #3b82f6;
|
||
background: #eff6ff;
|
||
transform: scale(1.01);
|
||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.drop-zone.is-disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.drop-zone-content {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.drop-icon {
|
||
color: #9ca3af;
|
||
margin-bottom: 12px;
|
||
display: block;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
|
||
.drop-text {
|
||
margin: 0 0 8px 0;
|
||
font-size: 15px;
|
||
color: #374151;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.drop-text .link {
|
||
color: #2563eb;
|
||
text-decoration: underline;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.drop-hint {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
/* 拖拽悬停状态 */
|
||
.drop-zone-active {
|
||
position: relative;
|
||
padding: 40px 24px;
|
||
}
|
||
|
||
.active-border {
|
||
position: absolute;
|
||
inset: 0;
|
||
border: 3px dashed #3b82f6;
|
||
border-radius: 12px;
|
||
animation: border-pulse 1s ease-in-out infinite;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@keyframes border-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
.active-text {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.active-icon {
|
||
font-size: 20px;
|
||
}
|
||
|
||
/* 已选文件预览 */
|
||
.selected-files-preview {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.preview-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #059669;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.preview-success-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.clear-btn {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: #dc2626;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.clear-btn:hover {
|
||
background: #fee2e2;
|
||
}
|
||
|
||
.preview-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.preview-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 10px;
|
||
background: #f9fafb;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.preview-file-icon {
|
||
color: #6b7280;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.preview-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: #374151;
|
||
}
|
||
|
||
.preview-size {
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.preview-more {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
text-align: center;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
/* 响应式适配 */
|
||
@media (max-width: 640px) {
|
||
.drop-zone {
|
||
padding: 24px 16px;
|
||
}
|
||
|
||
.drop-zone-active {
|
||
padding: 32px 16px;
|
||
}
|
||
|
||
.preview-item {
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 测试拖拽功能**
|
||
|
||
在页面中使用:
|
||
```vue
|
||
<DropZone
|
||
@files-selected="handleUpload"
|
||
@error="handleError"
|
||
/>
|
||
```
|
||
|
||
测试场景:
|
||
1. 从桌面拖拽 .docx 文件到区域 → 触发 files-selected 事件
|
||
2. 拖拽 .exe 文件 → 触发 error 事件,显示错误信息
|
||
3. 点击区域 → 打开文件选择对话框
|
||
4. 同时拖拽多个文件 → 全部添加到预览列表
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/DropZone.vue
|
||
git commit -m "feat: add DropZone component with drag-and-drop and validation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 修改API层添加进度回调
|
||
|
||
**Files:**
|
||
- Modify: `src/api/file.js` (第67-73行)
|
||
|
||
**目标:** 为 uploadFile 方法添加 onUploadProgress 回调参数,支持实时进度监控
|
||
|
||
- [ ] **Step 1: 修改 uploadFile 方法签名和实现**
|
||
|
||
找到 `src/api/file.js` 第67-73行的 `uploadFile` 方法:
|
||
|
||
```javascript
|
||
// 当前代码:
|
||
uploadFile: (formData) => {
|
||
return apiClient.post('/file/upload', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data'
|
||
}
|
||
})
|
||
},
|
||
```
|
||
|
||
替换为:
|
||
|
||
```javascript
|
||
uploadFile: (formData, onProgress) => {
|
||
return apiClient.post('/file/upload', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data'
|
||
},
|
||
onUploadProgress: (progressEvent) => {
|
||
// 仅在有回调函数且存在total时才触发
|
||
if (typeof onProgress === 'function' && progressEvent.total) {
|
||
const percentCompleted = Math.round(
|
||
(progressEvent.loaded * 100) / progressEvent.total
|
||
)
|
||
|
||
onProgress({
|
||
loaded: progressEvent.loaded,
|
||
total: progressEvent.total,
|
||
percent: percentCompleted,
|
||
// 计算预估剩余时间(简单估算)
|
||
estimatedTimeRemaining: calculateRemainingTime(progressEvent)
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: 添加辅助计算函数**
|
||
|
||
在 `api/file.js` 文件末尾(第124行之后)添加:
|
||
|
||
```javascript
|
||
/**
|
||
* 简单估算剩余时间(基于最近速度)
|
||
* @param {ProgressEvent} progressEvent
|
||
* @returns {number|null} 预估剩余秒数,无法估算时返回null
|
||
*/
|
||
function calculateRemainingTime(progressEvent) {
|
||
if (!progressEvent.total || progressEvent.total === 0) return null
|
||
|
||
const loaded = progressEvent.loaded
|
||
const total = progressEvent.total
|
||
const remaining = total - loaded
|
||
|
||
// 简单线性估算(实际应使用更复杂的算法)
|
||
// 这里假设速度恒定,返回秒数
|
||
if (remaining <= 0) return 0
|
||
|
||
// 由于无法获取精确时间戳,这里返回null
|
||
// 实际应用中可以使用 performance.now() 记录时间差
|
||
return null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 测试新接口**
|
||
|
||
```javascript
|
||
// 使用示例:
|
||
import { fileAPI } from './api/file.js'
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', fileObject)
|
||
|
||
fileAPI.uploadFile(formData, (progress) => {
|
||
console.log(`上传进度: ${progress.percent}%`)
|
||
console.log(`已上传: ${(progress.loaded / 1024 / 1024).toFixed(1)}MB`)
|
||
// 更新UI进度条...
|
||
}).then(response => {
|
||
console.log('上传完成:', response.data)
|
||
}).catch(error => {
|
||
console.error('上传失败:', error)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/api/file.js
|
||
git commit -m "feat: add upload progress callback to fileAPI.uploadFile method"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 集成增强功能到 FileSelector 组件
|
||
|
||
**Files:**
|
||
- Modify: `src/components/FileSelector.vue`
|
||
|
||
**目标:** 将拖拽上传、文件验证、进度条集成到现有的 FileSelector 组件中
|
||
|
||
- [ ] **Step 1: 导入新组件和工具函数**
|
||
|
||
在 `<script setup>` 部分顶部添加导入:
|
||
|
||
```javascript
|
||
import { ref, watch, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
FileTextOutlined, SearchOutlined, DownOutlined,
|
||
CloseOutlined, LeftOutlined, RightOutlined,
|
||
DatabaseOutlined, UnorderedListOutlined, CopyOutlined,
|
||
InboxOutlined, FileSearchOutlined, LoadingOutlined,
|
||
CloudUploadOutlined, UploadOutlined, DeleteOutlined
|
||
} from '@ant-design/icons-vue'
|
||
// 新增导入:
|
||
import DropZone from './ui/DropZone.vue'
|
||
import ProgressBar from './ui/ProgressBar.vue'
|
||
import { formatFileSize, validateFile } from '../utils/fileValidator.js'
|
||
import { fileAPI } from '../../api/file.js'
|
||
```
|
||
|
||
- [ ] **Step 2: 添加响应式变量和方法**
|
||
|
||
在 script 中添加新的状态变量和方法:
|
||
|
||
```javascript
|
||
// ===== 新增:上传相关状态 =====
|
||
const uploadingFiles = ref([]) // 正在上传的文件列表
|
||
const uploadProgress = ref({}) // 进度映射 { fileId: { percent, status } }
|
||
|
||
/**
|
||
* 处理文件选择/拖拽
|
||
*/
|
||
async function handleFilesSelected(files) {
|
||
for (const file of files) {
|
||
await uploadSingleFile(file)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传单个文件
|
||
*/
|
||
async function uploadSingleFile(file) {
|
||
const validation = validateFile(file)
|
||
|
||
if (!validation.valid) {
|
||
message.error(`${file.name}: ${validation.errors.join('; ')}`)
|
||
return
|
||
}
|
||
|
||
const fileId = Date.now() + '_' + Math.random().toString(36).substr(2, 9)
|
||
|
||
// 初始化进度状态
|
||
uploadProgress.value[fileId] = {
|
||
percent: 0,
|
||
status: 'uploading',
|
||
loaded: 0,
|
||
total: file.size
|
||
}
|
||
|
||
uploadingFiles.value.push({
|
||
id: fileId,
|
||
name: file.name,
|
||
size: file.size
|
||
})
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
// 调用带进度的上传接口
|
||
const response = await fileAPI.uploadFile(formData, (progress) => {
|
||
uploadProgress.value[fileId] = {
|
||
...progress,
|
||
status: 'uploading'
|
||
}
|
||
})
|
||
|
||
// 更新为成功状态
|
||
uploadProgress.value[fileId] = {
|
||
...uploadProgress.value[fileId],
|
||
percent: 100,
|
||
status: 'success'
|
||
}
|
||
|
||
message.success(`✅ "${file.name}" 上传成功!`)
|
||
|
||
// 刷新文件列表
|
||
emit('file-uploaded', response.data?.data)
|
||
|
||
// 3秒后从上传列表移除
|
||
setTimeout(() => {
|
||
uploadingFiles.value = uploadingFiles.value.filter(f => f.id !== fileId)
|
||
delete uploadProgress.value[fileId]
|
||
}, 3000)
|
||
|
||
} catch (error) {
|
||
// 更新为失败状态
|
||
uploadProgress.value[fileId] = {
|
||
...uploadProgress.value[fileId],
|
||
status: 'error',
|
||
error: error.response?.data?.message || error.message || '网络错误'
|
||
}
|
||
|
||
message.error(`❌ "${file.name}" 上传失败`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理验证错误
|
||
*/
|
||
function handleValidationError(errors) {
|
||
errors.forEach(err => {
|
||
message.warning(`⚠️ ${err.file.name}: ${err.errors.join(', ')}`)
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 template 中添加上传UI**
|
||
|
||
在 template 的合适位置(建议在 selector-panel 内部顶部)添加:
|
||
|
||
```vue
|
||
<!-- ===== 新增:增强型上传区域 ===== -->
|
||
<div class="enhanced-upload-section">
|
||
<DropZone
|
||
@files-selected="handleFilesSelected"
|
||
@error="handleValidationError"
|
||
:multiple="true"
|
||
:accepted-types="'.docx,.pdf,.txt,.md,.xlsx,.pptx'"
|
||
:max-size="52428800"
|
||
/>
|
||
|
||
<!-- 正在上传的文件列表 -->
|
||
<div v-if="uploadingFiles.length > 0" class="uploading-list">
|
||
<div
|
||
v-for="file in uploadingFiles"
|
||
:key="file.id"
|
||
class="uploading-item"
|
||
>
|
||
<ProgressBar
|
||
:percent="uploadProgress[file.id]?.percent || 0"
|
||
:status="uploadProgress[file.id]?.status || 'idle'"
|
||
:filename="file.name"
|
||
:loaded="uploadProgress[file.id]?.loaded || 0"
|
||
:total="file.size"
|
||
:error="uploadProgress[file.id]?.error"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: 添加样式**
|
||
|
||
在 `<style scoped>` 部分添加:
|
||
|
||
```css
|
||
.enhanced-upload-section {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.uploading-list {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.uploading-item {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 测试完整上传流程**
|
||
|
||
手动测试:
|
||
1. 点击上传区域 → 选择有效文件 → 出现进度条 → 上传成功 → 3秒后消失
|
||
2. 拖拽多个文件 → 多个进度条同时显示 → 依次完成
|
||
3. 尝试上传 .exe 文件 → 显示友好错误提示
|
||
4. 断网状态下上传 → 显示错误状态 + 错误信息
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/FileSelector.vue
|
||
git commit -m "feat: integrate drag-drop upload, validation, and progress bar into FileSelector"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 为 UploadTaskItem 添加重试按钮
|
||
|
||
**Files:**
|
||
- Modify: `src/components/exam/UploadTaskItem.vue`
|
||
|
||
**目标:** 在任务状态卡片中添加一键重试按钮,方便用户快速恢复失败的任务
|
||
|
||
- [ ] **Step 1: 修改 template 部分**
|
||
|
||
在第28-33行的错误详情按钮后面,添加重试按钮:
|
||
|
||
当前代码:
|
||
```html
|
||
<button
|
||
v-if="(vectorStatus.status === 'error' && vectorStatus.message) || (examStatus.status === 'error' && examStatus.message)"
|
||
class="uti-detail"
|
||
@click="toggleError"
|
||
>详情</button>
|
||
```
|
||
|
||
替换为:
|
||
```html
|
||
<button
|
||
v-if="(vectorStatus.status === 'error' && vectorStatus.message) || (examStatus.status === 'error' && examStatus.message)"
|
||
class="uti-detail"
|
||
@click="toggleError"
|
||
>详情</button>
|
||
|
||
<!-- 新增:一键重试按钮 -->
|
||
<button
|
||
v-if="vectorStatus.status === 'error'"
|
||
class="uti-retry"
|
||
@click="handleRetry"
|
||
:disabled="isRetrying"
|
||
>
|
||
<LoadingOutlined v-if="isRetrying" spin />
|
||
<ReloadOutlined v-else />
|
||
重试
|
||
</button>
|
||
```
|
||
|
||
- [ ] **Step 2: 修改 script 部分**
|
||
|
||
在第42-58行的 `<script setup>` 部分添加:
|
||
|
||
```javascript
|
||
import { ref } from 'vue'
|
||
import {
|
||
FileTextOutlined, LoadingOutlined, CheckCircleOutlined,
|
||
CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined,
|
||
QuestionCircleOutlined, ReloadOutlined // 新增导入
|
||
} from '@ant-design/icons-vue'
|
||
|
||
defineProps({
|
||
task: { type: Object, required: true },
|
||
vectorStatus: { type: Object, required: true },
|
||
examStatus: { type: Object, required: true }
|
||
})
|
||
|
||
const emit = defineEmits(['retry']) // 新增:定义retry事件
|
||
|
||
const showError = ref(false)
|
||
const isRetrying = ref(false) // 新增:重试状态
|
||
|
||
function toggleError() { showError.value = !showError.value }
|
||
|
||
// 新增:处理重试点击
|
||
async function handleRetry() {
|
||
if (isRetrying.value) return
|
||
|
||
isRetrying.value = true
|
||
|
||
try {
|
||
emit('retry', {
|
||
taskId: task.id,
|
||
fileName: task.fileName
|
||
})
|
||
|
||
// 模拟等待响应(实际由父组件控制)
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
} finally {
|
||
// 1秒后重置按钮状态
|
||
setTimeout(() => {
|
||
isRetrying.value = false
|
||
}, 1000)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 添加重试按钮样式**
|
||
|
||
在 `<style scoped>` 部分的 `.uti-detail` 样式后添加:
|
||
|
||
```css
|
||
.uti-retry {
|
||
margin-left: auto;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
font-size: var(--font-xs, 12px);
|
||
padding: 2px 10px;
|
||
border-radius: var(--radius-sm, 4px);
|
||
transition: all 0.2s ease;
|
||
line-height: 1.7;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.uti-retry:hover:not(:disabled) {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35);
|
||
}
|
||
|
||
.uti-retry:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 测试重试功能**
|
||
|
||
在父组件中使用:
|
||
```vue
|
||
<UploadTaskItem
|
||
:task="task"
|
||
:vector-status="vs"
|
||
:exam-status="es"
|
||
@retry="handleTaskRetry"
|
||
/>
|
||
|
||
<script setup>
|
||
function handleTaskRetry({ taskId, fileName }) {
|
||
console.log(`重试任务: ${fileName} (ID: ${taskId})`)
|
||
// 调用重试API或重新上传逻辑
|
||
}
|
||
</script>
|
||
```
|
||
|
||
预期行为:
|
||
1. 错误状态的卡片显示"🔄 重试"按钮
|
||
2. 点击后按钮变为加载状态(转圈图标)
|
||
3. 触发 retry 事件,传递 taskId 和 fileName
|
||
4. 1秒后按钮恢复正常,可再次点击
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/exam/UploadTaskItem.vue
|
||
git commit -m "feat: add retry button to UploadTaskItem for failed tasks"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 实现智能轮询策略
|
||
|
||
**Files:**
|
||
- Modify: `src/components/exam/composables/useUploadTasks.js`
|
||
|
||
**目标:** 将固定5秒轮询改为动态间隔策略(活跃3秒/空闲10秒/隐藏30秒)
|
||
|
||
- [ ] **Step 1: 修改轮询配置常量**
|
||
|
||
在第5行附近,将:
|
||
```javascript
|
||
const POLL_INTERVAL = 5000
|
||
```
|
||
|
||
替换为:
|
||
|
||
```javascript
|
||
// 智能轮询配置(单位:毫秒)
|
||
const POLL_CONFIG = {
|
||
active: 3000, // 有活跃任务时:3秒快速反馈
|
||
idle: 10000, // 全部空闲时:10秒低频查询
|
||
hidden: 30000, // 页面不可见时:30秒节省资源
|
||
maxInterval: 30000 // 最大间隔上限
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 添加智能轮询逻辑**
|
||
|
||
在 `startPolling()` 函数之前(约第292行)插入以下代码:
|
||
|
||
```javascript
|
||
let currentInterval = POLL_CONFIG.idle
|
||
let visibilityHandler = null
|
||
|
||
/**
|
||
* 根据当前任务状态更新轮询策略
|
||
*/
|
||
function updatePollingStrategy() {
|
||
const hasActiveTasks = shouldPoll()
|
||
const isHidden = document.hidden
|
||
|
||
let newInterval
|
||
|
||
if (tasks.value.length === 0) {
|
||
// 无任何任务 → 停止轮询
|
||
newInterval = 0
|
||
} else if (isHidden) {
|
||
// 页面不可见 → 低频轮询
|
||
newInterval = POLL_CONFIG.hidden
|
||
} else if (hasActiveTasks) {
|
||
// 有活跃任务 → 高频轮询
|
||
newInterval = POLL_CONFIG.active
|
||
} else {
|
||
// 全部空闲 → 中频轮询
|
||
newInterval = POLL_CONFIG.idle
|
||
}
|
||
|
||
// 仅当间隔变化时才重启定时器(避免不必要的重启)
|
||
if (newInterval !== currentInterval) {
|
||
const oldInterval = currentInterval
|
||
currentInterval = newInterval
|
||
|
||
console.log(`[useUploadTasks] 轮询策略调整: ${oldInterval}ms → ${newInterval}ms`, {
|
||
hasActiveTasks,
|
||
isHidden,
|
||
totalTasks: tasks.value.length
|
||
})
|
||
|
||
stopPolling()
|
||
|
||
if (newInterval > 0) {
|
||
pollTimer = setInterval(pollOnce, newInterval)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置页面可见性监听器
|
||
*/
|
||
function setupVisibilityListener() {
|
||
if (visibilityHandler) return // 避免重复绑定
|
||
|
||
visibilityHandler = () => {
|
||
updatePollingStrategy()
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', visibilityHandler)
|
||
console.log('[useUploadTasks] 页面可见性监听已启用')
|
||
}
|
||
|
||
/**
|
||
* 清除页面可见性监听器
|
||
*/
|
||
function teardownVisibilityListener() {
|
||
if (visibilityHandler) {
|
||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||
visibilityHandler = null
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 修改 startPolling 和 stopPolling 函数**
|
||
|
||
将原有的:
|
||
```javascript
|
||
function startPolling() {
|
||
stopPolling()
|
||
pollTimer = setInterval(pollOnce, POLL_INTERVAL)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
```
|
||
|
||
替换为:
|
||
|
||
```javascript
|
||
function startPolling() {
|
||
stopPolling()
|
||
|
||
// 初始化策略并启动
|
||
updatePollingStrategy()
|
||
setupVisibilityListener()
|
||
|
||
console.log(`[useUploadTasks] 智能轮询已启动 (初始间隔: ${currentInterval}ms)`)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
|
||
teardownVisibilityListener()
|
||
currentInterval = 0
|
||
|
||
console.log('[useUploadTasks] 轮询已停止')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 修改生命周期钩子**
|
||
|
||
在 `onUnmounted` 中确保清理可见性监听器:
|
||
|
||
```javascript
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
// teardownVisibilityListener 已在 stopPolling 中调用
|
||
})
|
||
```
|
||
|
||
并在组件挂载时自动启动(如果已有任务的话)。可以在 `return` 语句之前添加:
|
||
|
||
```javascript
|
||
// 自动初始化(如果已有任务则立即开始轮询)
|
||
if (tasks.value.length > 0) {
|
||
startPolling()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 测试智能轮询**
|
||
|
||
打开浏览器控制台,观察日志输出:
|
||
|
||
1. **正常情况**:
|
||
```
|
||
[useUploadTasks] 智能轮询已启动 (初始间隔: 10000ms)
|
||
[useUploadTasks] 页面可见性监听已启用
|
||
```
|
||
|
||
2. **模拟有活跃任务**(手动将某个任务状态改为VECTORIZING):
|
||
```
|
||
[useUploadTasks] 轮询策略调整: 10000ms → 3000ms
|
||
```
|
||
|
||
3. **切换到其他标签页(隐藏页面)**:
|
||
```
|
||
[useUploadTasks] 轮询策略调整: 3000ms → 30000ms
|
||
```
|
||
|
||
4. **回到页面**:
|
||
```
|
||
[useUploadTasks] 轮询策略调整: 30000ms → 3000ms (如果有活跃任务)
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/exam/composables/useUploadTasks.js
|
||
git commit -m "perf: implement smart polling strategy (3s/10s/30s dynamic intervals)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 创建 Toast 通知组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/ToastNotification.vue`
|
||
- Create: `src/utils/notification.js`
|
||
|
||
**目标:** 实现全局Toast通知系统,用于展示上传成功/失败、任务状态变更等消息
|
||
|
||
- [ ] **Step 1: 编写 notification.js 工具函数**
|
||
|
||
```javascript
|
||
/**
|
||
* Toast通知工具函数
|
||
* 用于全局显示通知消息
|
||
*/
|
||
|
||
let toastContainer = null
|
||
let toastIdCounter = 0
|
||
const MAX_VISIBLE_TOASTS = 3
|
||
const DEFAULT_DURATION = {
|
||
success: 3000,
|
||
error: 5000,
|
||
info: 4000,
|
||
warning: 4500
|
||
}
|
||
|
||
/**
|
||
* 确保toast容器存在
|
||
*/
|
||
function ensureContainer() {
|
||
if (toastContainer) return
|
||
|
||
toastContainer = document.createElement('div')
|
||
toastContainer.id = 'toast-container'
|
||
toastContainer.setAttribute('role', 'alert')
|
||
toastContainer.setAttribute('aria-live', 'polite')
|
||
|
||
Object.assign(toastContainer.style, {
|
||
position: 'fixed',
|
||
bottom: '20px',
|
||
right: '20px',
|
||
zIndex: '99999',
|
||
display: 'flex',
|
||
flexDirection: 'column-reverse',
|
||
gap: '10px',
|
||
maxWidth: '400px',
|
||
pointerEvents: 'none'
|
||
})
|
||
|
||
document.body.appendChild(toastContainer)
|
||
}
|
||
|
||
/**
|
||
* 创建单个toast元素
|
||
*/
|
||
function createToastElement({ id, type, title, message, duration, closable }) {
|
||
const toast = document.createElement('div')
|
||
toast.id = `toast-${id}`
|
||
toast.className = `toast toast-${type}`
|
||
toast.setAttribute('role', 'status')
|
||
|
||
const icons = {
|
||
success: '✅',
|
||
error: '❌',
|
||
info: 'ℹ️',
|
||
warning: '⚠️'
|
||
}
|
||
|
||
toast.innerHTML = `
|
||
<div class="toast-content">
|
||
<span class="toast-icon">${icons[type] || ''}</span>
|
||
<div class="toast-body">
|
||
${title ? `<div class="toast-title">${title}</div>` : ''}
|
||
${message ? `<div class="toast-message">${message}</div>` : ''}
|
||
</div>
|
||
${closable ? '<button class="toast-close" aria-label="关闭">×</button>' : ''}
|
||
</div>
|
||
<div class="toast-progress" style="animation-duration: ${duration}ms"></div>
|
||
`
|
||
|
||
// 关闭按钮事件
|
||
if (closable) {
|
||
const closeBtn = toast.querySelector('.toast-close')
|
||
closeBtn.addEventListener('click', () => removeToast(id))
|
||
}
|
||
|
||
return toast
|
||
}
|
||
|
||
/**
|
||
* 移除指定toast
|
||
*/
|
||
function removeToast(id) {
|
||
const toast = document.getElementById(`toast-${id}`)
|
||
if (!toast) return
|
||
|
||
toast.classList.add('toast-exit')
|
||
|
||
setTimeout(() => {
|
||
toast.remove()
|
||
// 检查是否需要调整容器位置
|
||
adjustContainerPosition()
|
||
}, 300)
|
||
}
|
||
|
||
/**
|
||
* 调整容器位置(防止过多toast超出屏幕)
|
||
*/
|
||
function adjustContainerPosition() {
|
||
if (!toastContainer) return
|
||
|
||
const toasts = toastContainer.querySelectorAll('.toast')
|
||
const containerRect = toastContainer.getBoundingClientRect()
|
||
const viewportHeight = window.innerHeight
|
||
|
||
if (containerRect.top < 80) {
|
||
toastContainer.style.bottom = 'auto'
|
||
toastContainer.style.top = '20px'
|
||
} else {
|
||
toastContainer.style.bottom = '20px'
|
||
toastContainer.style.top = 'auto'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 显示通知(主入口函数)
|
||
* @param {Object} options
|
||
* @param {string} options.type - 类型: success | error | info | warning
|
||
* @param {string} [options.title] - 标题(可选)
|
||
* @param {string} options.message - 消息内容
|
||
* @param {number} [options.duration] - 显示时长(ms),默认根据type自动设置
|
||
* @param {boolean} [options.closable=true] - 是否可关闭
|
||
* @returns {string} toast ID,可用于手动关闭
|
||
*/
|
||
export function showToast(options) {
|
||
ensureContainer()
|
||
|
||
const {
|
||
type = 'info',
|
||
title = '',
|
||
message = '',
|
||
duration = DEFAULT_DURATION[type] || 4000,
|
||
closable = true
|
||
} = options
|
||
|
||
const id = ++toastIdCounter
|
||
|
||
// 限制同时显示数量
|
||
const existingToasts = toastContainer.querySelectorAll('.toast')
|
||
if (existingToasts.length >= MAX_VISIBLE_TOASTS) {
|
||
// 移除最旧的toast
|
||
const oldestToast = existingToasts[0]
|
||
if (oldestToast) {
|
||
const oldId = parseInt(oldestToast.id.replace('toast-', ''))
|
||
removeToast(oldId)
|
||
}
|
||
}
|
||
|
||
// 创建并添加toast
|
||
const toastElement = createToastElement({
|
||
id,
|
||
type,
|
||
title,
|
||
message,
|
||
duration,
|
||
closable
|
||
})
|
||
|
||
toastContainer.appendChild(toastElement)
|
||
|
||
// 强制重绘以触发进入动画
|
||
toastElement.offsetHeight
|
||
|
||
// 自动移除
|
||
if (duration > 0) {
|
||
setTimeout(() => {
|
||
removeToast(id)
|
||
}, duration)
|
||
}
|
||
|
||
return `toast-${id}`
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:成功通知
|
||
*/
|
||
export function notifySuccess(message, title = '操作成功') {
|
||
return showToast({ type: 'success', title, message })
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:错误通知
|
||
*/
|
||
export function notifyError(message, title = '操作失败') {
|
||
return showToast({ type: 'error', title, message })
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:信息通知
|
||
*/
|
||
export function notifyInfo(message, title = '提示') {
|
||
return showToast({ type: 'info', title, message })
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:警告通知
|
||
*/
|
||
export function notifyWarning(message, title = '注意') {
|
||
return showToast({ type: 'warning', title, message })
|
||
}
|
||
|
||
/**
|
||
* 清除所有通知
|
||
*/
|
||
export function clearAllToasts() {
|
||
if (!toastContainer) return
|
||
|
||
const toasts = toastContainer.querySelectorAll('.toast')
|
||
toasts.forEach(toast => toast.remove())
|
||
}
|
||
|
||
export default {
|
||
showToast,
|
||
notifySuccess,
|
||
notifyError,
|
||
notifyInfo,
|
||
notifyWarning,
|
||
clearAllToasts
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编写 Toast 样式(注入到head)**
|
||
|
||
在 `notification.js` 文件末尾添加样式注入函数:
|
||
|
||
```javascript
|
||
/**
|
||
* 注入Toast样式(仅执行一次)
|
||
*/
|
||
let stylesInjected = false
|
||
|
||
function injectStyles() {
|
||
if (stylesInjected || typeof document === 'undefined') return
|
||
|
||
const styleEl = document.createElement('style')
|
||
styleEl.textContent = `
|
||
#toast-container {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||
}
|
||
|
||
.toast {
|
||
min-width: 280px;
|
||
max-width: 400px;
|
||
padding: 14px 16px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||
backdrop-filter: blur(10px);
|
||
background: rgba(255, 255, 255, 0.95);
|
||
pointer-events: auto;
|
||
animation: toast-enter 0.3s cubic-bezier(0.21, 1.02, 0.73, 1);
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
|
||
@keyframes toast-enter {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.toast-exit {
|
||
animation: toast-exit 0.3s ease-in forwards;
|
||
}
|
||
|
||
@keyframes toast-exit {
|
||
to {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.toast-content {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.toast-icon {
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.toast-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toast-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin-bottom: 4px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.toast-message {
|
||
font-size: 13px;
|
||
color: #4b5563;
|
||
line-height: 1.4;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.toast-close {
|
||
flex-shrink: 0;
|
||
background: none;
|
||
border: none;
|
||
font-size: 18px;
|
||
color: #9ca3af;
|
||
cursor: pointer;
|
||
padding: 0 2px;
|
||
line-height: 1;
|
||
transition: color 0.15s;
|
||
}
|
||
|
||
.toast-close:hover {
|
||
color: #4b5563;
|
||
}
|
||
|
||
.toast-progress {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
height: 3px;
|
||
border-radius: 0 0 10px 10px;
|
||
animation: toast-timer linear forwards;
|
||
}
|
||
|
||
@keyframes toast-timer {
|
||
from { width: 100%; }
|
||
to { width: 0%; }
|
||
}
|
||
|
||
/* 类型特定样式 */
|
||
.toast-success {
|
||
border-left: 4px solid #10b981;
|
||
}
|
||
|
||
.toast-success .toast-progress {
|
||
background: #10b981;
|
||
}
|
||
|
||
.toast-error {
|
||
border-left: 4px solid #ef4444;
|
||
}
|
||
|
||
.toast-error .toast-progress {
|
||
background: #ef4444;
|
||
}
|
||
|
||
.toast-info {
|
||
border-left: 4px solid #3b82f6;
|
||
}
|
||
|
||
.toast-info .toast-progress {
|
||
background: #3b82f6;
|
||
}
|
||
|
||
.toast-warning {
|
||
border-left: 4px solid #f59e0b;
|
||
}
|
||
|
||
.toast-warning .toast-progress {
|
||
background: #f59e0b;
|
||
}
|
||
|
||
/* 移动端适配 */
|
||
@media (max-width: 480px) {
|
||
#toast-container {
|
||
left: 10px;
|
||
right: 10px;
|
||
max-width: calc(100vw - 20px);
|
||
}
|
||
|
||
.toast {
|
||
min-width: auto;
|
||
max-width: 100%;
|
||
}
|
||
}
|
||
`
|
||
|
||
document.head.appendChild(styleEl)
|
||
stylesInjected = true
|
||
}
|
||
|
||
// 首次调用showToast时自动注入样式
|
||
const originalShowToast = showToast
|
||
showToast = function(options) {
|
||
injectStyles()
|
||
return originalShowToast(options)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 测试通知功能**
|
||
|
||
在浏览器控制台中执行:
|
||
|
||
```javascript
|
||
import { notifySuccess, notifyError } from './utils/notification.js'
|
||
|
||
// 测试成功通知
|
||
notifySuccess('文件"制度管理办法.docx"上传成功')
|
||
|
||
// 测试错误通知
|
||
notifyError('向量化失败:服务器内部错误')
|
||
|
||
// 3秒后会自动消失
|
||
```
|
||
|
||
预期效果:
|
||
- 右下角弹出通知卡片
|
||
- 带有进度条倒计时动画
|
||
- 可点击 × 手动关闭
|
||
- 最多同时显示3条
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/utils/notification.js
|
||
git commit -m "feat: add global Toast notification system with auto-dismiss"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 创建骨架屏加载组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/ui/SkeletonCard.vue`
|
||
|
||
**目标:** 实现通用骨架屏加载占位组件,提升首屏加载体验
|
||
|
||
- [ ] **Step 1: 编写 SkeletonCard.vue 组件**
|
||
|
||
```vue
|
||
<template>
|
||
<div class="skeleton-card" :class="[variant, { animated }]">
|
||
<!-- 标题行骨架 -->
|
||
<div v-if="showTitle" class="skeleton-title"></div>
|
||
|
||
<!-- 内容行骨架 -->
|
||
<div
|
||
v-for="i in lines"
|
||
:key="i"
|
||
class="skeleton-line"
|
||
:style="{ width: getLineWidth(i) }"
|
||
></div>
|
||
|
||
<!-- 底部操作栏骨架 -->
|
||
<div v-if="showActions" class="skeleton-actions">
|
||
<div class="skeleton-button short"></div>
|
||
<div class="skeleton-button long"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
lines: {
|
||
type: Number,
|
||
default: 3
|
||
},
|
||
variant: {
|
||
type: String,
|
||
default: 'default', // default | compact | detailed
|
||
validator: (val) => ['default', 'compact', 'detailed'].includes(val)
|
||
},
|
||
showTitle: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
showActions: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
animated: {
|
||
type: Boolean,
|
||
default: true
|
||
}
|
||
})
|
||
|
||
function getLineWidth(index) {
|
||
// 骨架线宽度随机变化(70%-100%),但保持确定性以便SSR
|
||
const widths = ['85%', '95%', '78%', '90%', '82%', '88%', '92%', '75%', '87%', '96%']
|
||
return widths[index % widths.length]
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.skeleton-card {
|
||
padding: 16px;
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.skeleton-title {
|
||
height: 18px;
|
||
width: 60%;
|
||
margin-bottom: 12px;
|
||
border-radius: 4px;
|
||
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||
background-size: 200% 100%;
|
||
}
|
||
|
||
.skeleton-line {
|
||
height: 14px;
|
||
margin-bottom: 8px;
|
||
border-radius: 4px;
|
||
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||
background-size: 200% 100%;
|
||
}
|
||
|
||
.skeleton-line:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.skeleton-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f3f4f6;
|
||
}
|
||
|
||
.skeleton-button {
|
||
height: 30px;
|
||
border-radius: 6px;
|
||
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
|
||
background-size: 200% 100%;
|
||
}
|
||
|
||
.skeleton-button.short {
|
||
width: 70px;
|
||
}
|
||
|
||
.skeleton-button.long {
|
||
width: 110px;
|
||
}
|
||
|
||
/* 动画效果 */
|
||
.skeleton-card.animated .skeleton-title,
|
||
.skeleton-card.animated .skeleton-line,
|
||
.skeleton-card.animated .skeleton-button {
|
||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes skeleton-shimmer {
|
||
0% {
|
||
background-position: 200% 0;
|
||
}
|
||
100% {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
|
||
/* 变体样式 */
|
||
.skeleton-card.compact {
|
||
padding: 12px;
|
||
}
|
||
|
||
.skeleton-card.compact .skeleton-title {
|
||
height: 14px;
|
||
width: 45%;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.skeleton-card.compact .skeleton-line {
|
||
height: 11px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.skeleton-card.detailed {
|
||
padding: 20px;
|
||
}
|
||
|
||
.skeleton-card.detailed .skeleton-title {
|
||
height: 22px;
|
||
width: 70%;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.skeleton-card.detailed .skeleton-line {
|
||
height: 16px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* 深色模式支持(可选) */
|
||
@media (prefers-color-scheme: dark) {
|
||
.skeleton-title,
|
||
.skeleton-line,
|
||
.skeleton-button {
|
||
background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%);
|
||
background-size: 200% 100%;
|
||
}
|
||
|
||
.skeleton-card {
|
||
background: #1f2937;
|
||
border-color: #374151;
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: 测试骨架屏组件**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<!-- 默认变体 -->
|
||
<SkeletonCard :lines="4" :animated="true" />
|
||
|
||
<!-- 紧凑变体 -->
|
||
<SkeletonCard variant="compact" :lines="2" />
|
||
|
||
<!-- 详细变体(带操作按钮) -->
|
||
<SkeletonCard variant="detailed" :lines="5" :show-actions="true" />
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
预期效果:
|
||
- 显示脉冲动画的灰色占位块
|
||
- 模拟真实内容的布局结构
|
||
- 动画流畅自然
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/ui/SkeletonCard.vue
|
||
git commit -m "feat: add SkeletonCard loading placeholder component with variants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 集成所有优化并端到端测试
|
||
|
||
**Files:**
|
||
- Multiple files (integration testing)
|
||
|
||
**目标:** 验证所有增强功能协同工作,确保无回归问题
|
||
|
||
- [ ] **Step 1: 运行构建验证**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
预期结果:
|
||
- ✅ 构建成功(exit code 0)
|
||
- ✅ 无编译错误或警告
|
||
- ✅ 输出文件生成正常
|
||
|
||
- [ ] **Step 2: 功能集成检查清单**
|
||
|
||
逐项验证以下功能点:
|
||
|
||
**文件上传增强:**
|
||
- [ ] 拖拽文件到上传区域 → 触发文件选择
|
||
- [ ] 选择 .docx/.pdf 文件 → 通过验证并开始上传
|
||
- [ ] 选择 .exe 文件 → 显示友好错误提示
|
||
- [ ] 选择 >50MB 文件 → 显示大小超限提示
|
||
- [ ] 上传过程中 → 进度条实时更新百分比
|
||
- [ ] 上传成功 → 显示绿色成功状态 + Toast通知
|
||
- [ ] 上传失败 → 显示红色错误状态 + 错误详情
|
||
|
||
**任务状态优化:**
|
||
- [ ] 任务处于 VECTORIZING 状态 → 图标旋转动画
|
||
- [ ] 任务失败 → 显示重试按钮
|
||
- [ ] 点击重试 → 按钮变为loading → 触发重试逻辑
|
||
- [ ] 页面切换到后台 → 控制台显示轮询间隔调整为30秒
|
||
- [ ] 回到前台且有活跃任务 → 轮询间隔恢复为3秒
|
||
- [ ] 所有任务完成 → 轮询间隔变为10秒
|
||
|
||
**UI/UX改进:**
|
||
- [ ] Toast通知正确显示在右下角
|
||
- [ ] 通知3-5秒后自动消失
|
||
- [ ] 骨架屏在数据加载时正确显示
|
||
- [ ] 移动端布局无严重错位
|
||
- [ ] 所有交互元素触控区域 ≥ 44px
|
||
|
||
- [ ] **Step 3: 性能基准测试**
|
||
|
||
使用浏览器DevTools Performance面板:
|
||
|
||
```javascript
|
||
// 在控制台运行性能测试
|
||
console.time('UploadFlowTest')
|
||
// 执行完整的上传流程
|
||
console.timeEnd('UploadFlowTest')
|
||
// 预期: < 2000ms (不含网络等待时间)
|
||
|
||
console.time('RenderTest')
|
||
// 强制刷新组件多次
|
||
console.timeEnd('RenderTest')
|
||
// 预期: < 100ms per render
|
||
```
|
||
|
||
- [ ] **Step 4: 浏览器兼容性测试**
|
||
|
||
在以下浏览器中验证核心功能:
|
||
|
||
| 浏览器 | 版本 | 拖拽上传 | 进度条 | 动画 | Toast |
|
||
|--------|------|---------|-------|------|-------|
|
||
| Chrome | 90+ | ✅ | ✅ | ✅ | ✅ |
|
||
| Firefox | 88+ | ✅ | ✅ | ✅ | ✅ |
|
||
| Safari | 14+ | ✅ | ✅ | ✅ | ✅ |
|
||
| Edge | 90+ | ✅ | ✅ | ✅ | ✅ |
|
||
|
||
- [ ] **Step 5: 最终Commit**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "feat: complete file upload and task status optimization (Phase A)
|
||
|
||
Enhancements:
|
||
- Add drag-and-drop upload with visual feedback
|
||
- Implement real-time upload progress bar
|
||
- Add client-side file validation (type/size/name)
|
||
- Add one-click retry button for failed tasks
|
||
- Implement smart polling strategy (3s/10s/30s dynamic)
|
||
- Create global Toast notification system
|
||
- Add SkeletonCard loading placeholder
|
||
- Improve mobile responsiveness
|
||
|
||
Components created:
|
||
- ui/ProgressBar.vue
|
||
- ui/DropZone.vue
|
||
- ui/ToastNotification.vue
|
||
- ui/SkeletonCard.vue
|
||
- utils/fileValidator.js
|
||
- utils/notification.js
|
||
|
||
Modified:
|
||
- api/file.js (added progress callback)
|
||
- components/FileSelector.vue (integrated enhancements)
|
||
- components/exam/UploadTaskItem.vue (added retry button)
|
||
- components/exam/composables/useUploadTasks.js (smart polling)"
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 自检清单
|
||
|
||
### Spec Coverage(需求覆盖度检查)
|
||
|
||
| 设计文档章节 | 对应Task | 状态 |
|
||
|------------|----------|------|
|
||
| FR-01: 上传进度条 | Task 2 + Task 5 | ✅ |
|
||
| FR-02: 拖拽上传 | Task 3 + Task 5 | ✅ |
|
||
| FR-03: 前端文件验证 | Task 1 + Task 3 | ✅ |
|
||
| FR-04: 上传前预览 | Task 3 | ✅ |
|
||
| FR-05: 友好错误处理 | Task 1 + Task 5 | ✅ |
|
||
| FR-06: 一键重试按钮 | Task 6 | ✅ |
|
||
| FR-07: 批量删除 | ⏭️ Phase B考虑 | 📝 |
|
||
| FR-08: Toast通知系统 | Task 8 | ✅ |
|
||
| FR-09: 智能轮询策略 | Task 7 | ✅ |
|
||
| FR-10: 加载骨架屏 | Task 9 | ✅ |
|
||
|
||
### Placeholder Scan(占位符扫描)
|
||
|
||
- ❌ 无 TBD/TODO 占位符
|
||
- ❌ 无"实现细节待定"描述
|
||
- ✅ 所有步骤都包含完整代码
|
||
- ✅ 所有文件路径都是绝对路径
|
||
|
||
### Type Consistency(类型一致性检查)
|
||
|
||
- ✅ 函数命名一致(camelCase)
|
||
- ✅ 事件命名一致(kebab-case)
|
||
- ✅ CSS类名一致(kebab-case)
|
||
- ✅ Props定义类型完整
|
||
- ✅ Emit事件参数明确
|
||
|
||
---
|
||
|
||
## 🎯 下一步行动
|
||
|
||
**计划已完成并保存至**: `docs/superpowers/plans/2026-05-30-file-upload-task-optimization-plan.md`
|
||
|
||
**两种执行方式可供选择:**
|
||
|
||
**1. Subagent-Driven (推荐)** - 我将为每个Task派遣独立的子代理执行,每个任务完成后进行审查,快速迭代
|
||
|
||
**2. Inline Execution** - 在当前会话中使用 executing-plans 技能批量执行,带有检查点进行审查
|
||
|
||
**您希望使用哪种方式?** |