Penny Lens OSS文件管理模块详解

2025年1月27日
10 分钟阅读
作者:Penny Lens Team

OSS管理模块

☁️ 概述

OSS管理模块是 Penny Lens 应用的文件存储管理模块,负责用户头像、图片、文档等文件的云存储管理。支持文件上传、下载、删除、预览等功能,提供统一的文件管理接口。

🏗️ 模块架构

核心组件

src/
├── services/api/upload.ts # 文件上传API服务
├── utils/upload.ts # 文件上传工具
├── utils/file.ts # 文件处理工具
├── types/upload.ts # 上传类型定义
└── components/FileUploader/ # 文件上传组件
├── FileUploader.vue # 文件上传器
├── ImageUploader.vue # 图片上传器
└── DocumentUploader.vue # 文档上传器

📁 文件类型支持

支持的文件类型

// 文件类型枚举
const FileType = {
  IMAGE: 'image',           // 图片
  DOCUMENT: 'document',    // 文档
  AUDIO: 'audio',          // 音频
  VIDEO: 'video',          // 视频
  OTHER: 'other'          // 其他
} as const
 
type FileType = typeof FileType[keyof typeof FileType]
 
// 文件类型配置
interface FileTypeConfig {
  type: FileType;
  name: string;
  extensions: string[];
  maxSize: number;          // 最大文件大小(字节)
  mimeTypes: string[];
  icon: string;
}
 
// 文件类型配置映射
const FILE_TYPE_CONFIGS: Record<FileType, FileTypeConfig> = {
  [FileType.IMAGE]: {
    type: FileType.IMAGE,
    name: '图片',
    extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
    maxSize: 5 * 1024 * 1024, // 5MB
    mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
    icon: 'i-mdi-image'
  },
  [FileType.DOCUMENT]: {
    type: FileType.DOCUMENT,
    name: '文档',
    extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
    maxSize: 10 * 1024 * 1024, // 10MB
    mimeTypes: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
    icon: 'i-mdi-file-document'
  },
  [FileType.AUDIO]: {
    type: FileType.AUDIO,
    name: '音频',
    extensions: ['mp3', 'wav', 'aac', 'ogg'],
    maxSize: 20 * 1024 * 1024, // 20MB
    mimeTypes: ['audio/mpeg', 'audio/wav', 'audio/aac', 'audio/ogg'],
    icon: 'i-mdi-music'
  },
  [FileType.VIDEO]: {
    type: FileType.VIDEO,
    name: '视频',
    extensions: ['mp4', 'avi', 'mov', 'wmv'],
    maxSize: 50 * 1024 * 1024, // 50MB
    mimeTypes: ['video/mp4', 'video/avi', 'video/quicktime', 'video/x-ms-wmv'],
    icon: 'i-mdi-video'
  },
  [FileType.OTHER]: {
    type: FileType.OTHER,
    name: '其他',
    extensions: [],
    maxSize: 5 * 1024 * 1024, // 5MB
    mimeTypes: [],
    icon: 'i-mdi-file'
  }
}

🔧 核心功能

1. 文件上传

功能描述: 支持单文件和批量文件上传

实现流程:

  1. 选择文件
  2. 验证文件类型和大小
  3. 压缩图片(如需要)
  4. 上传到OSS
  5. 返回文件URL

代码示例:

// 文件上传请求
interface UploadRequest {
  file: File;
  type: FileType;
  category?: string;        // 文件分类
  metadata?: Record<string, any>; // 文件元数据
}
 
// 文件上传响应
interface UploadResponse {
  url: string;              // 文件访问URL
  filename: string;         // 文件名
  size: number;            // 文件大小
  type: FileType;          // 文件类型
  mimeType: string;        // MIME类型
  uploadedAt: number;       // 上传时间
}
 
// 文件上传服务
export const uploadService = {
  // 上传单个文件
  async uploadFile(request: UploadRequest): Promise<UploadResponse> {
    // 验证文件
    const validation = this.validateFile(request.file, request.type)
    if (!validation.isValid) {
      throw new Error(validation.errors.join(', '))
    }
    
    // 处理文件
    const processedFile = await this.processFile(request.file, request.type)
    
    // 上传到OSS
    const response = await uploadApi.uploadFile({
      file: processedFile,
      type: request.type,
      category: request.category,
      metadata: request.metadata
    })
    
    return response.data
  },
  
  // 批量上传文件
  async uploadFiles(requests: UploadRequest[]): Promise<UploadResponse[]> {
    const results: UploadResponse[] = []
    const errors: string[] = []
    
    for (const request of requests) {
      try {
        const result = await this.uploadFile(request)
        results.push(result)
      } catch (error) {
        errors.push(`${request.file.name}: ${error.message}`)
      }
    }
    
    if (errors.length > 0) {
      console.warn('部分文件上传失败:', errors)
    }
    
    return results
  },
  
  // 验证文件
  validateFile(file: File, type: FileType): ValidationResult {
    const config = FILE_TYPE_CONFIGS[type]
    const errors: string[] = []
    
    // 检查文件大小
    if (file.size > config.maxSize) {
      errors.push(`文件大小不能超过${this.formatFileSize(config.maxSize)}`)
    }
    
    // 检查文件类型
    if (config.mimeTypes.length > 0 && !config.mimeTypes.includes(file.type)) {
      errors.push(`不支持的文件类型: ${file.type}`)
    }
    
    // 检查文件扩展名
    const extension = this.getFileExtension(file.name)
    if (config.extensions.length > 0 && !config.extensions.includes(extension)) {
      errors.push(`不支持的文件扩展名: ${extension}`)
    }
    
    return {
      isValid: errors.length === 0,
      errors
    }
  },
  
  // 处理文件
  async processFile(file: File, type: FileType): Promise<File> {
    if (type === FileType.IMAGE) {
      return await this.compressImage(file)
    }
    return file
  },
  
  // 压缩图片
  async compressImage(file: File, quality: number = 0.8): Promise<File> {
    return new Promise((resolve, reject) => {
      const canvas = document.createElement('canvas')
      const ctx = canvas.getContext('2d')
      const img = new Image()
      
      img.onload = () => {
        // 计算压缩后的尺寸
        const maxWidth = 1920
        const maxHeight = 1080
        let { width, height } = img
        
        if (width > maxWidth || height > maxHeight) {
          const ratio = Math.min(maxWidth / width, maxHeight / height)
          width *= ratio
          height *= ratio
        }
        
        canvas.width = width
        canvas.height = height
        
        // 绘制压缩后的图片
        ctx?.drawImage(img, 0, 0, width, height)
        
        canvas.toBlob(
          (blob) => {
            if (blob) {
              const compressedFile = new File([blob], file.name, {
                type: file.type,
                lastModified: Date.now()
              })
              resolve(compressedFile)
            } else {
              reject(new Error('图片压缩失败'))
            }
          },
          file.type,
          quality
        )
      }
      
      img.onerror = () => reject(new Error('图片加载失败'))
      img.src = URL.createObjectURL(file)
    })
  },
  
  // 获取文件扩展名
  getFileExtension(filename: string): string {
    return filename.split('.').pop()?.toLowerCase() || ''
  },
  
  // 格式化文件大小
  formatFileSize(bytes: number): string {
    if (bytes === 0) return '0 Bytes'
    
    const k = 1024
    const sizes = ['Bytes', 'KB', 'MB', 'GB']
    const i = Math.floor(Math.log(bytes) / Math.log(k))
    
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  }
}

2. 文件预览

功能描述: 支持多种文件类型的在线预览

代码示例:

// 文件预览服务
export const previewService = {
  // 获取文件预览URL
  getPreviewUrl(fileUrl: string, type: FileType): string {
    switch (type) {
      case FileType.IMAGE:
        return fileUrl
      case FileType.DOCUMENT:
        return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`
      case FileType.VIDEO:
        return fileUrl
      case FileType.AUDIO:
        return fileUrl
      default:
        return fileUrl
    }
  },
  
  // 检查是否支持预览
  isPreviewSupported(type: FileType): boolean {
    return [FileType.IMAGE, FileType.DOCUMENT, FileType.VIDEO, FileType.AUDIO].includes(type)
  },
  
  // 获取文件类型
  getFileType(filename: string): FileType {
    const extension = this.getFileExtension(filename)
    
    for (const [type, config] of Object.entries(FILE_TYPE_CONFIGS)) {
      if (config.extensions.includes(extension)) {
        return type as FileType
      }
    }
    
    return FileType.OTHER
  },
  
  // 获取文件图标
  getFileIcon(type: FileType): string {
    return FILE_TYPE_CONFIGS[type].icon
  }
}

3. 文件管理

功能描述: 提供文件的增删改查功能

代码示例:

// 文件管理服务
export const fileManagerService = {
  // 获取文件列表
  async getFileList(params: FileListRequest): Promise<FileListResponse> {
    const response = await uploadApi.getFileList(params)
    return response.data
  },
  
  // 删除文件
  async deleteFile(fileId: string): Promise<void> {
    const response = await uploadApi.deleteFile(fileId)
    if (response.code !== 200) {
      throw new Error(response.message)
    }
  },
  
  // 更新文件信息
  async updateFileInfo(fileId: string, data: Partial<FileInfo>): Promise<FileInfo> {
    const response = await uploadApi.updateFileInfo(fileId, data)
    return response.data
  },
  
  // 获取文件信息
  async getFileInfo(fileId: string): Promise<FileInfo> {
    const response = await uploadApi.getFileInfo(fileId)
    return response.data
  }
}
 
// 文件信息接口
interface FileInfo {
  id: string;
  filename: string;
  url: string;
  type: FileType;
  size: number;
  mimeType: string;
  category?: string;
  metadata?: Record<string, any>;
  uploadedAt: number;
  updatedAt: number;
}

🎨 文件上传组件

通用文件上传器

功能描述: 提供统一的文件上传界面

代码示例:

<template>
  <view class="file-uploader">
    <!-- 上传区域 -->
    <view 
      class="upload-area"
      :class="{ 
        'upload-area--dragover': isDragOver,
        'upload-area--disabled': disabled 
      }"
      @click="openFileDialog"
      @dragover="handleDragOver"
      @dragleave="handleDragLeave"
      @drop="handleDrop"
    >
      <view class="upload-content">
        <Iconify :icon="uploadIcon" size="48" />
        <text class="upload-text">{{ uploadText }}</text>
        <text class="upload-hint">{{ uploadHint }}</text>
      </view>
    </view>
    
    <!-- 文件列表 -->
    <view class="file-list" v-if="fileList.length > 0">
      <view 
        v-for="(file, index) in fileList" 
        :key="file.id || index"
        class="file-item"
      >
        <view class="file-info">
          <Iconify :icon="getFileIcon(file)" size="20" />
          <view class="file-details">
            <text class="file-name">{{ file.name }}</text>
            <text class="file-size">{{ formatFileSize(file.size) }}</text>
          </view>
        </view>
        
        <view class="file-actions">
          <view class="progress" v-if="file.uploading">
            <view class="progress-bar" :style="{ width: file.progress + '%' }"></view>
          </view>
          <view class="action-buttons" v-else>
            <button class="btn-icon" @click="previewFile(file)">
              <Iconify icon="i-mdi-eye" size="16" />
            </button>
            <button class="btn-icon" @click="removeFile(index)">
              <Iconify icon="i-mdi-delete" size="16" />
            </button>
          </view>
        </view>
      </view>
    </view>
    
    <!-- 隐藏的文件输入 -->
    <input
      ref="fileInput"
      type="file"
      :multiple="multiple"
      :accept="accept"
      @change="handleFileSelect"
      style="display: none"
    />
  </view>
</template>
 
<script setup lang="ts">
interface Props {
  type: FileType;
  multiple?: boolean;
  maxFiles?: number;
  disabled?: boolean;
  category?: string;
}
 
interface Emits {
  upload: [files: UploadResponse[]];
  error: [error: string];
  progress: [progress: number];
}
 
const props = withDefaults(defineProps<Props>(), {
  multiple: false,
  maxFiles: 10,
  disabled: false
})
 
const emit = defineEmits<Emits>()
 
// 响应式数据
const fileList = ref<FileItem[]>([])
const isDragOver = ref(false)
const uploading = ref(false)
 
// 计算属性
const uploadIcon = computed(() => {
  const config = FILE_TYPE_CONFIGS[props.type]
  return config.icon
})
 
const uploadText = computed(() => {
  if (uploading.value) return '上传中...'
  if (props.disabled) return '上传已禁用'
  return `点击或拖拽上传${FILE_TYPE_CONFIGS[props.type].name}`
})
 
const uploadHint = computed(() => {
  const config = FILE_TYPE_CONFIGS[props.type]
  return `支持 ${config.extensions.join(', ')} 格式,最大 ${formatFileSize(config.maxSize)}`
})
 
const accept = computed(() => {
  const config = FILE_TYPE_CONFIGS[props.type]
  return config.mimeTypes.join(',')
})
 
// 文件项接口
interface FileItem {
  id?: string;
  name: string;
  size: number;
  file?: File;
  uploading?: boolean;
  progress?: number;
  url?: string;
  error?: string;
}
 
// 打开文件选择对话框
function openFileDialog() {
  if (props.disabled) return
  fileInput.value?.click()
}
 
// 处理文件选择
async function handleFileSelect(event: Event) {
  const target = event.target as HTMLInputElement
  const files = Array.from(target.files || [])
  
  if (files.length === 0) return
  
  await processFiles(files)
  
  // 清空input
  target.value = ''
}
 
// 处理拖拽
function handleDragOver(event: DragEvent) {
  event.preventDefault()
  isDragOver.value = true
}
 
function handleDragLeave(event: DragEvent) {
  event.preventDefault()
  isDragOver.value = false
}
 
async function handleDrop(event: DragEvent) {
  event.preventDefault()
  isDragOver.value = false
  
  if (props.disabled) return
  
  const files = Array.from(event.dataTransfer?.files || [])
  if (files.length > 0) {
    await processFiles(files)
  }
}
 
// 处理文件
async function processFiles(files: File[]) {
  // 检查文件数量限制
  if (fileList.value.length + files.length > props.maxFiles) {
    emit('error', `最多只能上传${props.maxFiles}个文件`)
    return
  }
  
  // 添加文件到列表
  const newFiles: FileItem[] = files.map(file => ({
    name: file.name,
    size: file.size,
    file,
    uploading: true,
    progress: 0
  }))
  
  fileList.value.push(...newFiles)
  
  // 开始上传
  uploading.value = true
  const uploadResults: UploadResponse[] = []
  
  try {
    for (let i = 0; i < newFiles.length; i++) {
      const fileItem = newFiles[i]
      const fileIndex = fileList.value.findIndex(item => item === fileItem)
      
      try {
        const result = await uploadService.uploadFile({
          file: fileItem.file!,
          type: props.type,
          category: props.category
        })
        
        // 更新文件项
        fileList.value[fileIndex] = {
          ...fileItem,
          id: result.filename,
          url: result.url,
          uploading: false,
          progress: 100
        }
        
        uploadResults.push(result)
      } catch (error) {
        // 标记上传失败
        fileList.value[fileIndex] = {
          ...fileItem,
          uploading: false,
          error: error.message
        }
      }
    }
    
    if (uploadResults.length > 0) {
      emit('upload', uploadResults)
    }
  } finally {
    uploading.value = false
  }
}
 
// 移除文件
function removeFile(index: number) {
  fileList.value.splice(index, 1)
}
 
// 预览文件
function previewFile(file: FileItem) {
  if (file.url) {
    uni.previewImage({
      urls: [file.url],
      current: file.url
    })
  }
}
</script>

图片上传器

功能描述: 专门用于图片上传的组件

代码示例:

<template>
  <view class="image-uploader">
    <!-- 图片网格 -->
    <view class="image-grid">
      <view 
        v-for="(image, index) in imageList" 
        :key="image.id || index"
        class="image-item"
      >
        <image 
          :src="image.url || image.preview" 
          class="image"
          mode="aspectFill"
          @click="previewImage(index)"
        />
        
        <view class="image-overlay" v-if="image.uploading">
          <view class="progress">
            <view class="progress-text">{{ image.progress }}%</view>
          </view>
        </view>
        
        <view class="image-actions">
          <button class="btn-icon" @click="removeImage(index)">
            <Iconify icon="i-mdi-delete" size="16" />
          </button>
        </view>
      </view>
      
      <!-- 添加按钮 -->
      <view 
        class="add-button"
        @click="openImagePicker"
        v-if="imageList.length < maxImages"
      >
        <Iconify icon="i-mdi-plus" size="24" />
        <text>添加图片</text>
      </view>
    </view>
  </view>
</template>
 
<script setup lang="ts">
interface Props {
  maxImages?: number;
  category?: string;
}
 
interface Emits {
  upload: [images: UploadResponse[]];
  error: [error: string];
}
 
const props = withDefaults(defineProps<Props>(), {
  maxImages: 9
})
 
const emit = defineEmits<Emits>()
 
const imageList = ref<ImageItem[]>([])
 
interface ImageItem {
  id?: string;
  url?: string;
  preview?: string;
  uploading?: boolean;
  progress?: number;
  error?: string;
}
 
// 打开图片选择器
function openImagePicker() {
  uni.chooseImage({
    count: props.maxImages - imageList.value.length,
    sizeType: ['compressed'],
    sourceType: ['album', 'camera'],
    success: (res) => {
      processImages(res.tempFilePaths)
    }
  })
}
 
// 处理图片
async function processImages(tempFilePaths: string[]) {
  for (const tempFilePath of tempFilePaths) {
    const imageItem: ImageItem = {
      preview: tempFilePath,
      uploading: true,
      progress: 0
    }
    
    imageList.value.push(imageItem)
    
    try {
      // 上传图片
      const result = await uploadService.uploadFile({
        file: tempFilePath as any, // 小程序环境下的文件对象
        type: FileType.IMAGE,
        category: props.category
      })
      
      // 更新图片项
      const index = imageList.value.findIndex(item => item === imageItem)
      imageList.value[index] = {
        ...imageItem,
        id: result.filename,
        url: result.url,
        uploading: false,
        progress: 100
      }
    } catch (error) {
      // 标记上传失败
      const index = imageList.value.findIndex(item => item === imageItem)
      imageList.value[index] = {
        ...imageItem,
        uploading: false,
        error: error.message
      }
    }
  }
}
 
// 预览图片
function previewImage(index: number) {
  const urls = imageList.value
    .filter(img => img.url)
    .map(img => img.url!)
  
  uni.previewImage({
    urls,
    current: urls[index]
  })
}
 
// 移除图片
function removeImage(index: number) {
  imageList.value.splice(index, 1)
}
</script>

🔄 状态管理

Pinia Store

功能描述: 使用Pinia管理文件上传状态

代码示例:

// 文件上传状态管理
export const useUploadStore = defineStore('upload', () => {
  // 状态
  const uploadQueue = ref<UploadTask[]>([])
  const uploadHistory = ref<UploadResponse[]>([])
  const isUploading = ref(false)
  
  // 计算属性
  const totalProgress = computed(() => {
    if (uploadQueue.value.length === 0) return 0
    
    const totalProgress = uploadQueue.value.reduce((sum, task) => sum + task.progress, 0)
    return totalProgress / uploadQueue.value.length
  })
  
  const completedTasks = computed(() => 
    uploadQueue.value.filter(task => task.status === 'completed')
  )
  
  const failedTasks = computed(() => 
    uploadQueue.value.filter(task => task.status === 'failed')
  )
  
  // 操作
  const addUploadTask = (task: UploadTask): void => {
    uploadQueue.value.push(task)
  }
  
  const removeUploadTask = (taskId: string): void => {
    uploadQueue.value = uploadQueue.value.filter(task => task.id !== taskId)
  }
  
  const updateTaskProgress = (taskId: string, progress: number): void => {
    const task = uploadQueue.value.find(task => task.id === taskId)
    if (task) {
      task.progress = progress
    }
  }
  
  const updateTaskStatus = (taskId: string, status: UploadTaskStatus): void => {
    const task = uploadQueue.value.find(task => task.id === taskId)
    if (task) {
      task.status = status
    }
  }
  
  const clearCompletedTasks = (): void => {
    uploadQueue.value = uploadQueue.value.filter(task => task.status !== 'completed')
  }
  
  const addToHistory = (response: UploadResponse): void => {
    uploadHistory.value.unshift(response)
    
    // 限制历史记录数量
    if (uploadHistory.value.length > 100) {
      uploadHistory.value = uploadHistory.value.slice(0, 100)
    }
  }
  
  return {
    uploadQueue,
    uploadHistory,
    isUploading,
    totalProgress,
    completedTasks,
    failedTasks,
    addUploadTask,
    removeUploadTask,
    updateTaskProgress,
    updateTaskStatus,
    clearCompletedTasks,
    addToHistory
  }
})
 
// 上传任务接口
interface UploadTask {
  id: string;
  file: File;
  type: FileType;
  category?: string;
  progress: number;
  status: 'pending' | 'uploading' | 'completed' | 'failed';
  error?: string;
  result?: UploadResponse;
}

📚 最佳实践

1. 文件安全

  • 验证文件类型和大小
  • 扫描恶意文件
  • 设置访问权限
  • 定期清理过期文件

2. 性能优化

  • 图片压缩和缩略图
  • 分片上传大文件
  • CDN加速访问
  • 缓存策略优化

3. 用户体验

  • 拖拽上传支持
  • 实时上传进度
  • 预览功能
  • 错误提示友好

4. 可维护性

  • 统一的文件管理接口
  • 完整的类型定义
  • 模块化组件设计
  • 充分的测试覆盖

Penny Lens OSS管理模块 - 让文件存储更安全、更高效 ☁️