88. 文件上传与下载

概述

文件上传与下载是现代Web应用中常见的功能需求,如头像上传、文档下载、图片分享等。本集将深入探讨Vue 3项目中的文件上传与下载实现,包括单文件上传、多文件上传、大文件分片上传、断点续传、文件下载等核心功能。我们将学习如何使用HTML5 File API、Axios处理文件请求,以及如何结合Vue 3的响应式系统实现流畅的文件处理体验。

核心知识点

1. 文件上传基础

1.1 HTML5 File API

HTML5提供了强大的File API,允许网页访问用户设备上的文件系统:

  • File:表示单个文件,包含文件名、大小、类型等信息
  • FileList:文件列表,通常来自<input type="file">元素
  • FileReader:用于读取文件内容
  • Blob:表示二进制数据块
  • FormData:用于构造表单数据,支持文件上传

1.2 单文件上传

<!-- src/components/SingleFileUpload.vue -->
<template>
  <div class="file-upload">
    <h2>单文件上传</h2>
    <div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
      <input 
        ref="fileInput" 
        type="file" 
        accept=".jpg,.png,.gif" 
        @change="handleFileChange"
        style="display: none"
      />
      <div v-if="!selectedFile" class="upload-placeholder">
        <span class="icon">📁</span>
        <p>点击或拖拽文件到此处上传</p>
        <p class="hint">支持JPG、PNG、GIF格式,大小不超过5MB</p>
      </div>
      <div v-else class="file-preview">
        <img v-if="isImageFile(selectedFile)" :src="previewUrl" alt="预览" class="image-preview" />
        <div v-else class="file-info">
          <span class="file-icon">📄</span>
          <div>
            <div class="file-name">{{ selectedFile.name }}</div>
            <div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
          </div>
        </div>
        <button @click.stop="removeFile" class="remove-btn">×</button>
      </div>
    </div>
    <div class="upload-progress" v-if="uploading">
      <div class="progress-bar">
        <div class="progress" :style="{ width: progress + '%' }"></div>
      </div>
      <div class="progress-text">{{ progress }}%</div>
    </div>
    <div class="upload-actions">
      <button 
        @click="uploadFile" 
        :disabled="!selectedFile || uploading"
        class="upload-btn"
      >
        {{ uploading ? '上传中...' : '上传文件' }}
      </button>
      <button 
        @click="removeFile" 
        :disabled="!selectedFile || uploading"
        class="cancel-btn"
      >
        取消
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'

const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const previewUrl = ref('')
const uploading = ref(false)
const progress = ref(0)

// 触发文件选择
const triggerFileInput = () => {
  fileInput.value?.click()
}

// 处理文件选择
const handleFileChange = (event: Event) => {
  const input = event.target as HTMLInputElement
  if (input.files && input.files.length > 0) {
    const file = input.files[0]
    handleFile(file)
  }
}

// 处理拖拽文件
const handleDrop = (event: DragEvent) => {
  const files = event.dataTransfer?.files
  if (files && files.length > 0) {
    const file = files[0]
    handleFile(file)
  }
}

// 处理文件
const handleFile = (file: File) => {
  // 检查文件大小
  if (file.size > 5 * 1024 * 1024) {
    alert('文件大小不能超过5MB')
    return
  }
  
  selectedFile.value = file
  
  // 生成预览
  if (isImageFile(file)) {
    const reader = new FileReader()
    reader.onload = (e) => {
      previewUrl.value = e.target?.result as string
    }
    reader.readAsDataURL(file)
  }
}

// 检查是否为图片文件
const isImageFile = (file: File): boolean => {
  return file.type.startsWith('image/')
}

// 格式化文件大小
const formatFileSize = (size: number): string => {
  if (size < 1024) {
    return `${size} B`
  } else if (size < 1024 * 1024) {
    return `${(size / 1024).toFixed(1)} KB`
  } else {
    return `${(size / (1024 * 1024)).toFixed(1)} MB`
  }
}

// 移除文件
const removeFile = () => {
  selectedFile.value = null
  previewUrl.value = ''
  progress.value = 0
  if (fileInput.value) {
    fileInput.value.value = ''
  }
}

// 上传文件
const uploadFile = async () => {
  if (!selectedFile.value) return
  
  try {
    uploading.value = true
    progress.value = 0
    
    const formData = new FormData()
    formData.append('file', selectedFile.value)
    
    await axios.post('/api/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.total) {
          progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
        }
      }
    })
    
    alert('文件上传成功')
    removeFile()
  } catch (error) {
    console.error('文件上传失败:', error)
    alert('文件上传失败')
  } finally {
    uploading.value = false
  }
}
</script>

<style scoped>
.file-upload {
  width: 400px;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 8px;
  background-color: #f9f9f9;
}

.upload-area {
  border: 2px dashed #ccc;
  border-radius: 8px;
  padding: 30px;
  text-align: center;
  cursor: pointer;
  background-color: white;
  transition: all 0.3s ease;
  position: relative;
}

.upload-area:hover {
  border-color: #409eff;
}

.upload-placeholder {
  color: #666;
}

.upload-placeholder .icon {
  font-size: 48px;
  margin-bottom: 10px;
  display: block;
}

.hint {
  font-size: 12px;
  color: #999;
  margin-top: 5px;
}

.file-preview {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px;
  background-color: #f0f9ff;
  border: 1px solid #e0f2fe;
  border-radius: 4px;
}

.image-preview {
  width: 60px;
  height: 60px;
  object-fit: cover;
  border-radius: 4px;
}

.file-info {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  margin-left: 10px;
}

.file-icon {
  font-size: 24px;
}

.file-name {
  font-weight: bold;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 200px;
}

.file-size {
  font-size: 12px;
  color: #666;
}

.remove-btn {
  background-color: #fef2f2;
  color: #f56c6c;
  border: none;
  border-radius: 50%;
  width: 24px;
  height: 24px;
  cursor: pointer;
  font-size: 16px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.upload-progress {
  margin: 15px 0;
}

.progress-bar {
  height: 8px;
  background-color: #e5e7eb;
  border-radius: 4px;
  overflow: hidden;
}

.progress {
  height: 100%;
  background-color: #409eff;
  transition: width 0.3s ease;
}

.progress-text {
  text-align: right;
  font-size: 12px;
  color: #666;
  margin-top: 5px;
}

.upload-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
  margin-top: 15px;
}

.upload-btn {
  padding: 8px 16px;
  background-color: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.upload-btn:disabled {
  background-color: #a0cfff;
  cursor: not-allowed;
}

.cancel-btn {
  padding: 8px 16px;
  background-color: #f5f7fa;
  color: #606266;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  cursor: pointer;
}

.cancel-btn:disabled {
  background-color: #f5f7fa;
  color: #c0c4cc;
  border-color: #ebeef5;
  cursor: not-allowed;
}
</style>

1.3 多文件上传

<!-- src/components/MultipleFileUpload.vue -->
<template>
  <div class="file-upload">
    <h2>多文件上传</h2>
    <div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
      <input 
        ref="fileInput" 
        type="file" 
        multiple 
        accept=".jpg,.png,.pdf,.doc,.docx"
        @change="handleFileChange"
        style="display: none"
      />
      <div v-if="selectedFiles.length === 0" class="upload-placeholder">
        <span class="icon">📁</span>
        <p>点击或拖拽文件到此处上传</p>
        <p class="hint">支持JPG、PNG、PDF、DOC格式,单文件不超过10MB</p>
      </div>
      <div v-else class="file-list">
        <div 
          v-for="(file, index) in selectedFiles" 
          :key="index"
          class="file-item"
        >
          <img v-if="isImageFile(file)" :src="getPreviewUrl(file)" alt="预览" class="file-preview" />
          <div v-else class="file-icon">{{ getFileIcon(file) }}</div>
          <div class="file-details">
            <div class="file-name">{{ file.name }}</div>
            <div class="file-size">{{ formatFileSize(file.size) }}</div>
          </div>
          <button @click="removeFile(index)" class="remove-btn">×</button>
        </div>
      </div>
    </div>
    <div class="upload-stats" v-if="selectedFiles.length > 0">
      已选择 {{ selectedFiles.length }} 个文件,总大小 {{ formatFileSize(totalFileSize) }}
    </div>
    <div class="upload-progress" v-if="uploading">
      <div class="progress-bar">
        <div class="progress" :style="{ width: progress + '%' }"></div>
      </div>
      <div class="progress-text">{{ progress }}%</div>
    </div>
    <div class="upload-actions">
      <button 
        @click="uploadFiles" 
        :disabled="selectedFiles.length === 0 || uploading"
        class="upload-btn"
      >
        {{ uploading ? '上传中...' : `上传 ${selectedFiles.length} 个文件` }}
      </button>
      <button 
        @click="clearFiles" 
        :disabled="selectedFiles.length === 0 || uploading"
        class="clear-btn"
      >
        清空
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'

const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const previews = ref<Map<File, string>>(new Map())
const uploading = ref(false)
const progress = ref(0)

// 计算总文件大小
const totalFileSize = computed(() => {
  return selectedFiles.value.reduce((total, file) => total + file.size, 0)
})

// 触发文件选择
const triggerFileInput = () => {
  fileInput.value?.click()
}

// 处理文件选择
const handleFileChange = (event: Event) => {
  const input = event.target as HTMLInputElement
  if (input.files && input.files.length > 0) {
    const files = Array.from(input.files)
    files.forEach(file => handleFile(file))
  }
}

// 处理拖拽文件
const handleDrop = (event: DragEvent) => {
  const files = Array.from(event.dataTransfer?.files || [])
  files.forEach(file => handleFile(file))
}

// 处理单个文件
const handleFile = (file: File) => {
  // 检查文件大小
  if (file.size > 10 * 1024 * 1024) {
    alert(`${file.name} 文件大小超过10MB,无法上传`)
    return
  }
  
  // 检查是否已存在
  if (!selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
    selectedFiles.value.push(file)
    
    // 生成预览
    if (isImageFile(file)) {
      const reader = new FileReader()
      reader.onload = (e) => {
        previews.value.set(file, e.target?.result as string)
      }
      reader.readAsDataURL(file)
    }
  }
}

// 检查是否为图片文件
const isImageFile = (file: File): boolean => {
  return file.type.startsWith('image/')
}

// 获取文件图标
const getFileIcon = (file: File): string => {
  const ext = file.name.split('.').pop()?.toLowerCase()
  switch (ext) {
    case 'pdf': return '📄'
    case 'doc':
    case 'docx': return '📝'
    case 'xls':
    case 'xlsx': return '📊'
    case 'zip':
    case 'rar': return '🗜️'
    default: return '📑'
  }
}

// 获取预览URL
const getPreviewUrl = (file: File): string => {
  return previews.value.get(file) || ''
}

// 格式化文件大小
const formatFileSize = (size: number): string => {
  if (size < 1024) {
    return `${size} B`
  } else if (size < 1024 * 1024) {
    return `${(size / 1024).toFixed(1)} KB`
  } else {
    return `${(size / (1024 * 1024)).toFixed(1)} MB`
  }
}

// 移除文件
const removeFile = (index: number) => {
  const file = selectedFiles.value[index]
  previews.value.delete(file)
  selectedFiles.value.splice(index, 1)
  if (fileInput.value) {
    fileInput.value.value = ''
  }
}

// 清空所有文件
const clearFiles = () => {
  selectedFiles.value = []
  previews.value.clear()
  if (fileInput.value) {
    fileInput.value.value = ''
  }
}

// 上传文件
const uploadFiles = async () => {
  if (selectedFiles.value.length === 0) return
  
  try {
    uploading.value = true
    progress.value = 0
    
    const formData = new FormData()
    selectedFiles.value.forEach(file => {
      formData.append('files[]', file)
    })
    
    await axios.post('/api/upload/multiple', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent) => {
        if (progressEvent.total) {
          progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
        }
      }
    })
    
    alert(`${selectedFiles.length} 个文件上传成功`)
    clearFiles()
  } catch (error) {
    console.error('文件上传失败:', error)
    alert('文件上传失败')
  } finally {
    uploading.value = false
    progress.value = 0
  }
}
</script>

<style scoped>
/* 样式与单文件上传类似,调整file-list和file-item样式 */
.file-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.file-item {
  display: flex;
  align-items: center;
  padding: 10px;
  background-color: #f0f9ff;
  border: 1px solid #e0f2fe;
  border-radius: 4px;
}

.file-preview {
  width: 40px;
  height: 40px;
  object-fit: cover;
  border-radius: 4px;
}

.file-details {
  flex: 1;
  margin: 0 10px;
}

/* 其他样式参考单文件上传组件 */
</style>

2. 高级文件上传功能

2.1 大文件分片上传

大文件分片上传是将大文件分割成多个小块,分别上传,最后在服务器端合并的技术。

<!-- src/components/ChunkUpload.vue -->
<template>
  <div class="chunk-upload">
    <h2>大文件分片上传</h2>
    <div class="upload-area" @click="triggerFileInput" @dragover.prevent @drop.prevent="handleDrop">
      <input 
        ref="fileInput" 
        type="file" 
        @change="handleFileChange"
        style="display: none"
      />
      <div v-if="!selectedFile" class="upload-placeholder">
        <span class="icon">📁</span>
        <p>点击或拖拽大文件到此处上传</p>
        <p class="hint">支持各种格式,自动分片上传,断点续传</p>
      </div>
      <div v-else class="file-info">
        <div class="file-name">{{ selectedFile.name }}</div>
        <div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
      </div>
    </div>
    <div class="upload-progress" v-if="uploading">
      <div class="progress-bar">
        <div class="progress" :style="{ width: progress + '%' }"></div>
      </div>
      <div class="progress-text">
        {{ progress }}% ({{ uploadedChunks }}/{{ totalChunks }} 块)
      </div>
    </div>
    <div class="upload-actions">
      <button 
        @click="uploadFile" 
        :disabled="!selectedFile || uploading"
        class="upload-btn"
      >
        {{ uploading ? '上传中...' : '开始上传' }}
      </button>
      <button 
        @click="pauseUpload" 
        :disabled="!uploading"
        class="pause-btn"
      >
        暂停
      </button>
      <button 
        @click="resumeUpload" 
        :disabled="!selectedFile || uploading || uploadedChunks === 0"
        class="resume-btn"
      >
        继续
      </button>
      <button 
        @click="cancelUpload" 
        :disabled="!selectedFile"
        class="cancel-btn"
      >
        取消
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'

const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const uploading = ref(false)
const paused = ref(false)
const progress = ref(0)
const chunkSize = 5 * 1024 * 1024 // 5MB per chunk
const totalChunks = ref(0)
const uploadedChunks = ref(0)
let currentChunk = 0
let fileHash = ''

// 生成文件哈希
const generateFileHash = async (file: File): Promise<string> => {
  // 简化实现,实际项目中应使用更可靠的哈希算法
  return `${file.name}-${file.size}-${file.lastModified}`
}

// 触发文件选择
const triggerFileInput = () => {
  fileInput.value?.click()
}

// 处理文件选择
const handleFileChange = async (event: Event) => {
  const input = event.target as HTMLInputElement
  if (input.files && input.files.length > 0) {
    selectedFile.value = input.files[0]
    fileHash = await generateFileHash(selectedFile.value)
    totalChunks.value = Math.ceil(selectedFile.value.size / chunkSize)
    uploadedChunks.value = 0
    currentChunk = 0
    progress.value = 0
  }
}

// 处理拖拽文件
const handleDrop = async (event: DragEvent) => {
  const files = event.dataTransfer?.files
  if (files && files.length > 0) {
    selectedFile.value = files[0]
    fileHash = await generateFileHash(selectedFile.value)
    totalChunks.value = Math.ceil(selectedFile.value.size / chunkSize)
    uploadedChunks.value = 0
    currentChunk = 0
    progress.value = 0
  }
}

// 格式化文件大小
const formatFileSize = (size: number): string => {
  if (size < 1024 * 1024) {
    return `${(size / 1024).toFixed(1)} KB`
  } else if (size < 1024 * 1024 * 1024) {
    return `${(size / (1024 * 1024)).toFixed(1)} MB`
  } else {
    return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
  }
}

// 获取文件分片
const getChunk = (file: File, chunkIndex: number): Blob => {
  const start = chunkIndex * chunkSize
  const end = Math.min(start + chunkSize, file.size)
  return file.slice(start, end)
}

// 上传单个分片
const uploadChunk = async (chunkIndex: number): Promise<boolean> => {
  if (!selectedFile.value) return false
  
  try {
    const chunk = getChunk(selectedFile.value, chunkIndex)
    const formData = new FormData()
    
    formData.append('file', chunk)
    formData.append('filename', selectedFile.value.name)
    formData.append('filehash', fileHash)
    formData.append('chunkindex', chunkIndex.toString())
    formData.append('totalchunks', totalChunks.value.toString())
    formData.append('chunksize', chunkSize.toString())
    
    await axios.post('/api/upload/chunk', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
    
    return true
  } catch (error) {
    console.error(`上传分片 ${chunkIndex} 失败:`, error)
    return false
  }
}

// 合并分片
const mergeChunks = async (): Promise<boolean> => {
  if (!selectedFile.value) return false
  
  try {
    await axios.post('/api/upload/merge', {
      filename: selectedFile.value.name,
      filehash: fileHash,
      totalchunks: totalChunks.value
    })
    return true
  } catch (error) {
    console.error('合并分片失败:', error)
    return false
  }
}

// 上传文件
const uploadFile = async () => {
  if (!selectedFile.value) return
  
  try {
    uploading.value = true
    paused.value = false
    
    // 检查是否已经上传过部分分片
    const response = await axios.get('/api/upload/check', {
      params: {
        filehash: fileHash,
        filename: selectedFile.value.name
      }
    })
    
    if (response.data.uploaded) {
      // 文件已完全上传
      alert('文件已上传完成')
      resetUpload()
      return
    }
    
    // 获取已上传的分片索引
    const uploadedChunkIndices = response.data.uploadedChunks || []
    uploadedChunks.value = uploadedChunkIndices.length
    currentChunk = uploadedChunkIndices.length
    
    // 开始上传剩余分片
    while (currentChunk < totalChunks.value && !paused.value) {
      const success = await uploadChunk(currentChunk)
      if (success) {
        uploadedChunks.value++
        currentChunk++
        progress.value = Math.round((uploadedChunks.value / totalChunks.value) * 100)
      } else {
        // 分片上传失败,重试一次
        await new Promise(resolve => setTimeout(resolve, 1000))
      }
    }
    
    if (!paused.value && uploadedChunks.value === totalChunks.value) {
      // 所有分片上传完成,合并文件
      const mergeSuccess = await mergeChunks()
      if (mergeSuccess) {
        alert('文件上传成功')
        resetUpload()
      } else {
        alert('文件合并失败')
      }
    }
  } catch (error) {
    console.error('文件上传失败:', error)
    alert('文件上传失败')
  } finally {
    if (!paused.value) {
      uploading.value = false
    }
  }
}

// 暂停上传
const pauseUpload = () => {
  paused.value = true
  uploading.value = false
}

// 继续上传
const resumeUpload = () => {
  uploadFile()
}

// 取消上传
const cancelUpload = () => {
  resetUpload()
}

// 重置上传状态
const resetUpload = () => {
  selectedFile.value = null
  uploading.value = false
  paused.value = false
  progress.value = 0
  uploadedChunks.value = 0
  totalChunks.value = 0
  currentChunk = 0
  fileHash = ''
  if (fileInput.value) {
    fileInput.value.value = ''
  }
}
</script>

<style scoped>
/* 样式参考单文件上传组件,添加暂停、继续按钮样式 */
.pause-btn {
  background-color: #f59e0b;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

.resume-btn {
  background-color: #10b981;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
}

/* 其他样式参考单文件上传组件 */
</style>

3. 文件下载功能

3.1 基本文件下载

<!-- src/components/FileDownload.vue -->
<template>
  <div class="file-download">
    <h2>文件下载</h2>
    <div class="file-list">
      <div 
        v-for="file in files" 
        :key="file.id"
        class="file-item"
      >
        <div class="file-icon">{{ getFileIcon(file) }}</div>
        <div class="file-info">
          <div class="file-name">{{ file.name }}</div>
          <div class="file-meta">
            <span>{{ formatFileSize(file.size) }}</span>
            <span>{{ formatDate(file.uploadedAt) }}</span>
          </div>
        </div>
        <div class="download-actions">
          <button 
            @click="downloadFile(file)"
            class="download-btn"
            :disabled="downloadingFiles.includes(file.id)"
          >
            <span v-if="downloadingFiles.includes(file.id)">⏳</span>
            <span v-else>📥</span>
            {{ downloadingFiles.includes(file.id) ? '下载中...' : '下载' }}
          </button>
          <button 
            @click="previewFile(file)"
            class="preview-btn"
            :disabled="!canPreview(file)"
          >
            预览
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'

interface FileItem {
  id: string
  name: string
  size: number
  type: string
  url: string
  uploadedAt: string
}

const files = ref<FileItem[]>([
  {
    id: '1',
    name: '文档.pdf',
    size: 2048576,
    type: 'application/pdf',
    url: '/api/files/1',
    uploadedAt: '2023-01-15T10:30:00Z'
  },
  {
    id: '2',
    name: '图片.jpg',
    size: 5242880,
    type: 'image/jpeg',
    url: '/api/files/2',
    uploadedAt: '2023-01-16T14:45:00Z'
  },
  {
    id: '3',
    name: '表格.xlsx',
    size: 10485760,
    type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    url: '/api/files/3',
    uploadedAt: '2023-01-17T09:15:00Z'
  }
])

const downloadingFiles = ref<string[]>([])

// 获取文件图标
const getFileIcon = (file: FileItem): string => {
  if (file.type.startsWith('image/')) return '🖼️'
  if (file.type === 'application/pdf') return '📄'
  if (file.type.includes('word')) return '📝'
  if (file.type.includes('excel') || file.type.includes('spreadsheet')) return '📊'
  if (file.type.includes('zip') || file.type.includes('rar')) return '🗜️'
  return '📑'
}

// 格式化文件大小
const formatFileSize = (size: number): string => {
  if (size < 1024) return `${size} B`
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
  if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`
  return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}

// 格式化日期
const formatDate = (dateString: string): string => {
  const date = new Date(dateString)
  return date.toLocaleString('zh-CN')
}

// 检查是否可以预览
const canPreview = (file: FileItem): boolean => {
  return file.type.startsWith('image/') || file.type === 'application/pdf'
}

// 预览文件
const previewFile = (file: FileItem) => {
  if (file.type.startsWith('image/')) {
    // 图片预览
    window.open(file.url, '_blank')
  } else if (file.type === 'application/pdf') {
    // PDF预览
    window.open(file.url, '_blank')
  }
}

// 下载文件
const downloadFile = async (file: FileItem) => {
  try {
    downloadingFiles.value.push(file.id)
    
    // 使用axios下载文件
    const response = await axios.get(file.url, {
      responseType: 'blob' // 重要:设置响应类型为blob
    })
    
    // 创建下载链接
    const url = window.URL.createObjectURL(new Blob([response.data]))
    const link = document.createElement('a')
    link.href = url
    link.setAttribute('download', file.name)
    document.body.appendChild(link)
    link.click()
    
    // 清理
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)
  } catch (error) {
    console.error('文件下载失败:', error)
    alert('文件下载失败')
  } finally {
    downloadingFiles.value = downloadingFiles.value.filter(id => id !== file.id)
  }
}
</script>

<style scoped>
.file-download {
  max-width: 800px;
  padding: 20px;
}

.file-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 20px;
}

.file-item {
  display: flex;
  align-items: center;
  padding: 15px;
  background-color: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.file-icon {
  font-size: 24px;
  margin-right: 15px;
}

.file-info {
  flex: 1;
}

.file-name {
  font-weight: bold;
  margin-bottom: 5px;
}

.file-meta {
  display: flex;
  gap: 15px;
  font-size: 12px;
  color: #666;
}

.download-actions {
  display: flex;
  gap: 10px;
}

.download-btn,
.preview-btn {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.download-btn {
  background-color: #409eff;
  color: white;
}

.preview-btn {
  background-color: #f5f7fa;
  color: #606266;
  border: 1px solid #dcdfe6;
}

.preview-btn:disabled {
  background-color: #f5f7fa;
  color: #c0c4cc;
  border-color: #ebeef5;
  cursor: not-allowed;
}
</style>

3.2 带进度条的文件下载

<!-- src/components/DownloadWithProgress.vue -->
<template>
  <div class="download-progress">
    <h2>带进度条的文件下载</h2>
    <div class="file-item">
      <div class="file-info">
        <div class="file-name">大型文件.zip</div>
        <div class="file-size">500 MB</div>
      </div>
      <button 
        @click="startDownload" 
        :disabled="downloading"
        class="download-btn"
      >
        {{ downloading ? '下载中...' : '开始下载' }}
      </button>
    </div>
    <div class="progress-container" v-if="downloading">
      <div class="progress-bar">
        <div class="progress" :style="{ width: progress + '%' }"></div>
      </div>
      <div class="progress-info">
        <span>{{ progress }}%</span>
        <span>{{ formatFileSize(downloadedSize) }} / 500 MB</span>
        <span>{{ downloadSpeed }} KB/s</span>
      </div>
      <div class="download-actions">
        <button @click="pauseDownload" class="pause-btn">暂停</button>
        <button @click="cancelDownload" class="cancel-btn">取消</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'

const downloading = ref(false)
const progress = ref(0)
const downloadedSize = ref(0)
const downloadSpeed = ref(0)
let startTime = 0
let lastTime = 0
let lastSize = 0
let controller: AbortController | null = null

// 开始下载
const startDownload = async () => {
  try {
    downloading.value = true
    progress.value = 0
    downloadedSize.value = 0
    downloadSpeed.value = 0
    startTime = Date.now()
    lastTime = startTime
    lastSize = 0
    
    // 创建AbortController用于取消下载
    controller = new AbortController()
    
    const response = await axios.get('/api/files/large', {
      responseType: 'blob',
      signal: controller.signal,
      onDownloadProgress: (progressEvent) => {
        if (progressEvent.total) {
          downloadedSize.value = progressEvent.loaded
          progress.value = Math.round((progressEvent.loaded * 100) / progressEvent.total)
          
          // 计算下载速度
          const now = Date.now()
          const timeDiff = now - lastTime
          const sizeDiff = progressEvent.loaded - lastSize
          
          if (timeDiff > 1000) { // 每秒计算一次
            downloadSpeed.value = Math.round((sizeDiff / 1024) / (timeDiff / 1000))
            lastTime = now
            lastSize = progressEvent.loaded
          }
        }
      }
    })
    
    // 下载完成,保存文件
    const url = window.URL.createObjectURL(new Blob([response.data]))
    const link = document.createElement('a')
    link.href = url
    link.setAttribute('download', '大型文件.zip')
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)
    
    alert('文件下载完成')
  } catch (error: any) {
    if (error.name !== 'CanceledError') {
      console.error('文件下载失败:', error)
      alert('文件下载失败')
    }
  } finally {
    downloading.value = false
    controller = null
  }
}

// 暂停下载
const pauseDownload = () => {
  // 注意:axios的取消下载会导致请求失败,实际项目中需要实现断点续传
  if (controller) {
    controller.abort()
    downloading.value = false
  }
}

// 取消下载
const cancelDownload = () => {
  if (controller) {
    controller.abort()
    downloading.value = false
    progress.value = 0
    downloadedSize.value = 0
    downloadSpeed.value = 0
  }
}

// 格式化文件大小
const formatFileSize = (size: number): string => {
  if (size < 1024) return `${size} B`
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
  if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`
  return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
</script>

<style scoped>
.download-progress {
  max-width: 600px;
  padding: 20px;
  background-color: #f9f9f9;
  border-radius: 8px;
  border: 1px solid #ccc;
}

.file-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.file-info {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

.file-name {
  font-weight: bold;
}

.file-size {
  color: #666;
  font-size: 14px;
}

.progress-container {
  background-color: white;
  padding: 20px;
  border-radius: 8px;
  border: 1px solid #e0e0e0;
}

.progress-bar {
  height: 10px;
  background-color: #e5e7eb;
  border-radius: 5px;
  overflow: hidden;
  margin-bottom: 10px;
}

.progress {
  height: 100%;
  background-color: #409eff;
  transition: width 0.3s ease;
}

.progress-info {
  display: flex;
  justify-content: space-between;
  font-size: 14px;
  margin-bottom: 15px;
  color: #666;
}

.download-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
}

.download-btn,
.pause-btn,
.cancel-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.download-btn {
  background-color: #409eff;
  color: white;
}

.download-btn:disabled {
  background-color: #a0cfff;
  cursor: not-allowed;
}

.pause-btn {
  background-color: #f59e0b;
  color: white;
}

.cancel-btn {
  background-color: #f56c6c;
  color: white;
}
</style>

3. 文件上传下载的最佳实践

3.1 前端最佳实践

  • 文件类型验证:使用accept属性限制文件类型,在客户端进行验证
  • 文件大小限制:在客户端检查文件大小,避免过大文件上传
  • 进度反馈:提供清晰的上传/下载进度反馈
  • 拖拽支持:实现拖拽上传功能,提升用户体验
  • 断点续传:大文件支持断点续传,提高上传成功率
  • 并发控制:限制并发上传的文件数量或分片数量
  • 错误处理:妥善处理上传/下载过程中的错误
  • 取消/暂停功能:允许用户取消或暂停上传/下载
  • 预览功能:对支持预览的文件提供预览功能
  • 响应式设计:适配不同屏幕尺寸

3.2 后端最佳实践

  • 文件存储:使用对象存储服务(如S3、OSS)存储文件,避免直接存储在服务器
  • 文件命名:生成唯一文件名,避免文件名冲突
  • 分块处理:支持大文件分片上传和合并
  • 断点续传:记录已上传的分片信息,支持续传
  • 文件元数据:存储文件的元数据,如大小、类型、上传时间等
  • 访问控制:实现文件的访问权限控制
  • CDN加速:使用CDN加速文件下载
  • 限流策略:对上传/下载请求进行限流,防止服务器过载
  • 病毒扫描:对上传的文件进行病毒扫描
  • 日志记录:记录文件上传/下载日志,便于审计和排查问题

常见问题与解决方案

1. 问题:文件上传大小限制

解决方案

  • 前端:在客户端检查文件大小,给出友好提示
  • 后端:调整服务器配置,如Nginx的client_max_body_size,Node.js的body-parser限制
  • 采用分片上传:将大文件分割成小块上传

2. 问题:跨域文件上传

解决方案

  • 后端设置正确的CORS头,允许跨域请求
  • 使用代理服务器转发请求
  • 在同域下部署文件上传服务

3. 问题:文件上传进度不准确

解决方案

  • 确保服务器正确返回Content-Length
  • 使用onUploadProgressonDownloadProgress事件监听进度
  • 对于分片上传,需要在服务器端实现进度计算

4. 问题:文件下载时浏览器直接打开而不是下载

解决方案

  • 后端设置正确的Content-Disposition头:attachment; filename=&quot;filename.ext&quot;
  • 前端使用blob类型接收响应,创建下载链接

5. 问题:断点续传实现复杂

解决方案

  • 使用成熟的文件上传库,如resumable.jsuppy
  • 后端需要实现分片管理、合并逻辑
  • 使用文件哈希标识文件,避免重复上传

进一步学习资源

  1. HTML5 File API文档
  2. Axios文件上传下载文档
  3. 大文件分片上传实现
  4. 断点续传原理与实现
  5. Uppy - 现代文件上传库
  6. Resumable.js - 断点续传库
  7. AWS S3文件上传
  8. 阿里云OSS文件上传

课后练习

  1. 基础练习

    • 实现单文件上传功能,支持图片预览
    • 实现文件下载功能,支持多种文件类型
    • 添加文件上传进度显示
  2. 进阶练习

    • 实现多文件上传功能
    • 实现大文件分片上传
    • 添加断点续传功能
    • 实现带进度条的文件下载
  3. 挑战练习

    • 实现文件上传队列管理
    • 添加文件上传前的病毒扫描
    • 实现文件共享功能
    • 实现文件版本控制
    • 集成云存储服务(如AWS S3、阿里云OSS)

通过本集的学习,你应该能够掌握Vue 3项目中文件上传与下载的核心实现,包括单文件上传、多文件上传、大文件分片上传、断点续传以及文件下载等功能。合理的文件处理机制能够显著提升用户体验,同时也需要考虑服务器端的性能和安全性。在实际项目中,建议根据业务需求选择合适的文件处理方案,必要时可以使用成熟的第三方库来简化开发。

« 上一篇 WebSocket实时通信 - Vue 3实时应用开发 下一篇 » 接口Mock与开发环境 - Vue 3前后端分离开发实践