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. 文件上传
功能描述: 支持单文件和批量文件上传
实现流程:
- 选择文件
- 验证文件类型和大小
- 压缩图片(如需要)
- 上传到OSS
- 返回文件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管理模块 - 让文件存储更安全、更高效 ☁️
