# 文件上传与任务状态管理优化实施计划
> **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
{{ filename }}
{{ statsText }}
{{ hintText }}
```
- [ ] **Step 2: 测试组件渲染**
在任意父组件中使用:
```vue
```
预期效果:
- 显示蓝色渐变进度条,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
拖拽文件到此处,或点击选择
支持 {{ acceptedTypesDisplay }},最大 {{ maxSizeDisplay }}
{{ file.name }}
{{ formatSize(file.size) }}
还有 {{ selectedFiles.length - maxPreviewCount }} 个文件...
```
- [ ] **Step 2: 测试拖拽功能**
在页面中使用:
```vue
```
测试场景:
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: 导入新组件和工具函数**
在 `
```
预期行为:
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 = `
${icons[type] || ''}
${title ? `
${title}
` : ''}
${message ? `
${message}
` : ''}
${closable ? '
' : ''}
`
// 关闭按钮事件
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
```
- [ ] **Step 2: 测试骨架屏组件**
```vue
```
预期效果:
- 显示脉冲动画的灰色占位块
- 模拟真实内容的布局结构
- 动画流畅自然
- [ ] **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 技能批量执行,带有检查点进行审查
**您希望使用哪种方式?**