# 文件上传与任务状态管理优化实施计划 > **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 ``` - [ ] **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 ``` - [ ] **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 技能批量执行,带有检查点进行审查 **您希望使用哪种方式?**