AWS S3 云存储
@pt/utils/modules/storage 提供了强大的 AWS S3 / MinIO 云存储上传工具,支持文件和文件夹上传、断点续传、进度跟踪、暂停/恢复等高级功能。
S3Uploader 类
S3Uploader 类提供了对 AWS S3 / MinIO 的完整封装,支持大文件分片上传、断点续传、并发控制等企业级特性。
基本用法
typescript
import { S3Uploader } from '@pt/utils/modules/storage';
// 创建 S3 上传器
const uploader = new S3Uploader({
accessKeyId: 'YOUR_ACCESS_KEY',
secretAccessKey: 'YOUR_SECRET_KEY',
endpoint: 'https://your-s3-endpoint.com',
region: 'us-east-1',
s3ForcePathStyle: true,
concurrentLimit: 3,
chunkSize: 5 * 1024 * 1024,
multipartThreshold: 10 * 1024 * 1024
});
// 设置进度回调
uploader.setProgressCallback((progress) => {
console.log(`${progress.fileName}: ${(progress.progress * 100).toFixed(2)}%`);
});
// 上传文件
const files = await uploader.selectFiles();
const uploadIds = await uploader.uploadFiles(files, 'my-bucket', 'uploads/');配置选项
typescript
interface S3UploaderConfig {
accessKeyId: string; // AWS 访问密钥 ID
secretAccessKey: string; // AWS 密钥
endpoint: string; // S3 端点 URL
region?: string; // AWS 区域,默认 'us-east-1'
sslEnabled?: boolean; // 是否启用 SSL
s3ForcePathStyle?: boolean; // 强制路径样式,MinIO 需要设为 true
concurrentLimit?: number; // 并发上传数量,默认 3
chunkSize?: number; // 分片大小,默认 5MB
multipartThreshold?: number; // 分片上传阈值,默认 10MB
}核心 API
文件选择
selectFiles(): Promise<File[]>
打开文件选择对话框,支持多选。
selectFolders(): Promise<FolderEntry[]>
打开文件夹选择界面,支持拖拽和多文件夹选择。
文件上传
uploadFiles(files: File[], bucketName: string, keyPrefix?: string): Promise<string[]>
批量上传文件,返回上传任务 ID 数组。
uploadFolders(folders: FolderEntry[], bucketName: string, keyPrefix?: string): Promise<string[]>
批量上传文件夹,返回上传任务 ID 数组。
上传控制
pauseUpload(uploadId: string): void
暂停指定的上传任务(保留断点信息)。
resumeUpload(uploadId: string): Promise<void>
从断点恢复上传。
cancelUpload(uploadId: string): Promise<void>
取消上传任务并清理资源。
pauseAllUploads(): void / resumeAllUploads(): void / cancelAllUploads(): Promise<void>
批量控制所有上传任务。
进度查询
getUploadProgress(uploadId: string): ProgressInfo | null
获取指定上传任务的进度信息。
getAllUploads(): ProgressInfo[]
获取所有上传任务的进度信息。
setProgressCallback(callback: (progress: ProgressInfo) => void): void
设置进度回调函数,实时接收上传进度更新。
文件管理
deleteFile(bucketName: string, key: string): Promise<any>
删除 S3 中的文件。
fileExists(bucketName: string, key: string): Promise<boolean>
检查文件是否存在。
getSignedUrl(bucketName: string, key: string, expiresIn?: number): Promise<string>
获取文件的预签名 URL(默认 3600 秒)。
listFiles(bucketName: string, prefix?: string): Promise<any[]>
列出桶中的文件。
完整示例
基础文件上传
typescript
import { S3Uploader } from '@pt/utils/modules/storage';
// 初始化上传器
const uploader = new S3Uploader({
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY,
endpoint: import.meta.env.VITE_S3_ENDPOINT,
region: 'us-east-1',
s3ForcePathStyle: true
});
// 处理文件上传
async function handleFileUpload() {
try {
const files = await uploader.selectFiles();
const uploadIds = await uploader.uploadFiles(files, 'my-bucket', 'uploads/');
console.log('Upload IDs:', uploadIds);
return uploadIds;
} catch (error) {
console.error('Upload failed:', error);
throw error;
}
}带进度跟踪的上传
typescript
import { ref } from 'vue';
import { S3Uploader } from '@pt/utils/modules/storage';
const uploads = ref(new Map());
const uploader = new S3Uploader({
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY,
endpoint: import.meta.env.VITE_S3_ENDPOINT,
region: 'us-east-1',
s3ForcePathStyle: true
});
uploader.setProgressCallback((progress) => {
uploads.value.set(progress.uploadId, progress);
console.log(`${progress.fileName}: ${(progress.progress * 100).toFixed(2)}%`);
});
async function uploadWithProgress() {
const files = await uploader.selectFiles();
return await uploader.uploadFiles(files, 'my-bucket', 'uploads/');
}Vue 组件示例
AWS S3 上传示例
请打开代码查看
<template>
<div class="min-h-screen bg-gray-50 p-6px">
<div class="max-w-6xl mx-auto">
<!-- 页面标题 -->
<div class="mb-8px">
<h1 class="text-3xl font-bold text-gray-900 mb-2px">S3 文件上传器 Demo</h1>
<p class="text-gray-600">支持文件和文件夹批量上传,断点续传,实时进度监控</p>
</div>
<!-- 配置面板 -->
<a-card title="上传配置" class="mb-6">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="访问密钥">
<a-input v-model:value="config.accessKeyId" placeholder="Access Key ID" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="密钥">
<a-input-password v-model:value="config.secretAccessKey" placeholder="Secret Access Key" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务端点">
<a-input v-model:value="config.endpoint" placeholder="https://your-minio-endpoint" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="存储桶名称">
<a-input v-model:value="config.bucketName" placeholder="bucket-name" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="8">
<a-col :span="8">
<a-form-item label="并发限制">
<a-input-number v-model:value="config.concurrentLimit" :min="1" :max="10" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="分片大小(MB)">
<a-input-number v-model:value="config.chunkSizeMB" :min="5" :max="100" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="分片阈值(MB)">
<a-input-number v-model:value="config.multipartThresholdMB" :min="10" :max="1000" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" @click="initializeUploader" :loading="initializing">
初始化上传器
</a-button>
</a-card>
<!-- 上传操作面板 -->
<a-card title="上传操作" class="mb-6px">
<div class="flex flex-wrap gap-4px mb-4px">
<a-button type="primary" @click="selectFiles" :disabled="!uploader">
<template #icon><FileOutlined /></template>
选择文件
</a-button>
<a-button @click="selectFolders" :disabled="!uploader">
<template #icon><FolderOutlined /></template>
选择文件夹
</a-button>
<a-divider type="vertical" />
<a-button @click="pauseAllUploads" :disabled="!hasActiveUploads">
<template #icon><PauseCircleOutlined /></template>
暂停全部
</a-button>
<a-button @click="resumeAllUploads" :disabled="!hasPausedUploads">
<template #icon><PlayCircleOutlined /></template>
恢复全部
</a-button>
<a-button danger @click="cancelAllUploads" :disabled="!hasAnyUploads">
<template #icon><StopOutlined /></template>
取消全部
</a-button>
<a-button @click="cleanupTasks" :disabled="!hasCompletedUploads">
<template #icon><ClearOutlined /></template>
清理完成
</a-button>
</div>
<!-- 统计信息 -->
<div class="bg-blue-50 p-4px rounded-lg">
<a-row :gutter="16">
<a-col :span="6">
<a-statistic title="总任务数" :value="uploadStats.total" />
</a-col>
<a-col :span="6">
<a-statistic title="上传中" :value="uploadStats.uploading" />
</a-col>
<a-col :span="6">
<a-statistic title="已完成" :value="uploadStats.completed" />
</a-col>
<a-col :span="6">
<a-statistic title="失败" :value="uploadStats.failed" />
</a-col>
</a-row>
</div>
</a-card>
<!-- 上传任务列表 -->
<a-card title="上传任务" v-if="uploadTasks.length > 0">
<div class="space-y-4">
<div
v-for="task in uploadTasks"
:key="task.uploadId"
class="border border-gray-200 rounded-lg p-4px bg-white"
>
<div class="flex items-center justify-between mb-3px">
<div class="flex-1 w-0">
<h4 class="text-lg font-medium text-gray-900 truncate max-w-90%">{{ task.fileName }}</h4>
<div class="flex items-center gap-4px text-sm text-gray-500 mt-1">
<span>{{ formatFileSize(getFileSize(task.fileName)) }}</span>
<span>{{ formatSpeed(task.speed) }}</span>
<span v-if="task.eta > 0">剩余: {{ formatTime(task.eta) }}</span>
<span v-if="task.partsProgress">
分片: {{ task.partsProgress.completed }}/{{ task.partsProgress.total }}
</span>
</div>
</div>
<div class="flex items-center gap-2px">
<a-tag :color="getStatusColor(task.status)">{{ getStatusText(task.status) }}</a-tag>
<a-button-group size="small">
<a-button
v-if="task.status === 'uploading'"
title="暂停上传"
@click="pauseUpload(task.uploadId)"
>
<PauseCircleOutlined />
</a-button>
<a-button
v-if="task.status === 'paused'"
title="恢复上传"
@click="resumeUpload(task.uploadId)"
>
<PlayCircleOutlined />
</a-button>
<a-button
danger
title="取消上传"
:disabled="task.status === UploadStatus.COMPLETED"
@click="cancelUpload(task.uploadId)"
>
<StopOutlined />
</a-button>
</a-button-group>
</div>
</div>
<!-- 进度条 -->
<a-progress
:percent="Math.round(task.progress * 100)"
:status="getProgressStatus(task.status)"
:stroke-color="getProgressColor(task.status)"
class="mb-2"
/>
<!-- 详细信息 -->
<div class="text-xs text-gray-400">
ID: {{ task.uploadId }}
</div>
</div>
</div>
</a-card>
<!-- 空状态 -->
<a-empty
v-else
description="暂无上传任务"
class="mt-8"
>
<a-button type="primary" @click="selectFiles" :disabled="!uploader">
开始上传
</a-button>
</a-empty>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import {
FileOutlined,
FolderOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
StopOutlined,
ClearOutlined
} from '@ant-design/icons-vue'
import { S3Uploader,UploadStatus } from '@pt/utils'
import type { ProgressInfo } from '@pt/utils'
// 配置信息
const config = reactive({
accessKeyId: 'HBE4ZPQA3OLNNSMUNWWC',
secretAccessKey: 'NNdgyDR3LbdpnmXe8APcHlnkTnbFOND2bs9lCmyU',
endpoint: 'https://minio-local.i-tudou.com',
bucketName: 'potato-share-components',
concurrentLimit: 3,
chunkSizeMB: 5,
multipartThresholdMB: 10
})
// 上传器实例
const uploader = ref<S3Uploader | null>(null)
const initializing = ref(false)
// 上传任务列表
const uploadTasks = ref<ProgressInfo[]>([])
// 文件大小映射(用于显示文件大小)
const fileSizeMap = ref<Map<string, number>>(new Map())
// 计算属性
const uploadStats = computed(() => {
const stats = {
total: uploadTasks.value.length,
uploading: 0,
completed: 0,
failed: 0,
paused: 0
}
uploadTasks.value.forEach(task => {
switch (task.status) {
case UploadStatus.UPLOADING:
stats.uploading++
break
case UploadStatus.COMPLETED:
stats.completed++
break
case UploadStatus.FAILED:
stats.failed++
break
case UploadStatus.PAUSED:
stats.paused++
break
}
})
return stats
})
const hasActiveUploads = computed(() =>
uploadTasks.value.some(task => task.status === UploadStatus.UPLOADING)
)
const hasPausedUploads = computed(() =>
uploadTasks.value.some(task => task.status === UploadStatus.PAUSED)
)
const hasAnyUploads = computed(() =>
uploadTasks.value.some(task =>
task.status === UploadStatus.UPLOADING || task.status === UploadStatus.PAUSED
)
)
const hasCompletedUploads = computed(() =>
uploadTasks.value.some(task =>
task.status === UploadStatus.COMPLETED || task.status === UploadStatus.CANCELLED
)
)
// 初始化上传器
const initializeUploader = async () => {
if (!config.accessKeyId || !config.secretAccessKey || !config.endpoint) {
message.error('请填写完整的配置信息')
return
}
initializing.value = true
try {
uploader.value = new S3Uploader({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
endpoint: config.endpoint,
concurrentLimit: config.concurrentLimit,
chunkSize: config.chunkSizeMB * 1024 * 1024,
multipartThreshold: config.multipartThresholdMB * 1024 * 1024
})
// 设置进度回调
uploader.value.setProgressCallback((progress: ProgressInfo) => {
const index = uploadTasks.value.findIndex(task => task.uploadId === progress.uploadId)
if (index !== -1) {
uploadTasks.value[index] = progress
} else {
uploadTasks.value.push(progress)
}
})
message.success('上传器初始化成功')
} catch (error) {
message.error('上传器初始化失败: ' + error)
} finally {
initializing.value = false
}
}
// 选择文件
const selectFiles = async () => {
if (!uploader.value) return
try {
const files = await uploader.value.selectFiles()
if (files.length === 0) return
// 保存文件大小信息
files.forEach(file => {
fileSizeMap.value.set(file.name, file.size)
})
const uploadIds = await uploader.value.uploadFiles(files, config.bucketName, 'temp-files')
message.success(`开始上传 ${files.length} 个文件`)
} catch (error) {
message.error('选择文件失败: ' + error)
}
}
// 选择文件夹
const selectFolders = async () => {
if (!uploader.value) return
try {
const folders = await uploader.value.selectFolders()
if (folders.length === 0) return
// 保存文件大小信息
folders.forEach(folder => {
folder.files.forEach(file => {
fileSizeMap.value.set(file.name, file.size)
})
})
const uploadIds = await uploader.value.uploadFolders(folders, config.bucketName)
const totalFiles = folders.reduce((sum, folder) => sum + folder.files.length, 0)
message.success(`开始上传 ${totalFiles} 个文件`)
} catch (error) {
message.error('选择文件夹失败: ' + error)
}
}
// 暂停上传
const pauseUpload = (uploadId: string) => {
uploader.value?.pauseUpload(uploadId)
}
// 恢复上传
const resumeUpload = (uploadId: string) => {
uploader.value?.resumeUpload(uploadId)
}
// 取消上传
const cancelUpload = (uploadId: string) => {
uploader.value?.cancelUpload(uploadId)
}
// 暂停所有上传
const pauseAllUploads = () => {
uploader.value?.pauseAllUploads()
message.info('已暂停所有上传任务')
}
// 恢复所有上传
const resumeAllUploads = () => {
uploader.value?.resumeAllUploads()
message.info('已恢复所有上传任务')
}
// 取消所有上传
const cancelAllUploads = () => {
uploader.value?.cancelAllUploads()
message.info('已取消所有上传任务')
}
// 清理已完成的任务
const cleanupTasks = () => {
uploader.value?.cleanupCompletedTasks()
uploadTasks.value = uploadTasks.value.filter(task =>
task.status !== UploadStatus.COMPLETED && task.status !== UploadStatus.CANCELLED
)
message.info('已清理完成的任务')
}
// 工具函数
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return '0 B/s'
return formatFileSize(bytesPerSecond) + '/s'
}
const formatTime = (seconds: number): string => {
if (seconds === 0) return '0s'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}h ${m}m ${s}s`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const getFileSize = (fileName: string): number => {
return fileSizeMap.value.get(fileName) || 0
}
const getStatusColor = (status: UploadStatus): string => {
switch (status) {
case UploadStatus.UPLOADING: return 'blue'
case UploadStatus.COMPLETED: return 'green'
case UploadStatus.FAILED: return 'red'
case UploadStatus.PAUSED: return 'orange'
case UploadStatus.CANCELLED: return 'default'
default: return 'default'
}
}
const getStatusText = (status: UploadStatus): string => {
switch (status) {
case UploadStatus.PENDING: return '等待中'
case UploadStatus.UPLOADING: return '上传中'
case UploadStatus.PAUSED: return '已暂停'
case UploadStatus.COMPLETED: return '已完成'
case UploadStatus.FAILED: return '上传失败'
case UploadStatus.CANCELLED: return '已取消'
default: return '未知状态'
}
}
const getProgressStatus = (status: UploadStatus): string => {
switch (status) {
case UploadStatus.UPLOADING: return 'active'
case UploadStatus.COMPLETED: return 'success'
case UploadStatus.FAILED: return 'exception'
default: return 'normal'
}
}
const getProgressColor = (status: UploadStatus): string => {
switch (status) {
case UploadStatus.UPLOADING: return '#1890ff'
case UploadStatus.COMPLETED: return '#52c41a'
case UploadStatus.FAILED: return '#ff4d4f'
case UploadStatus.PAUSED: return '#faad14'
default: return '#d9d9d9'
}
}
// 生命周期
onMounted(() => {
// 可以在这里设置默认配置
// config.endpoint = 'https://your-default-endpoint'
})
onUnmounted(() => {
// 清理资源
uploader.value?.cancelAllUploads()
})
</script>
<style scoped>
.ant-statistic {
text-align: center;
}
.ant-progress {
margin-bottom: 0;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>环境变量配置
建议将 S3 凭证存储在环境变量中:
bash
# .env.local
VITE_AWS_ACCESS_KEY_ID=your_access_key_id
VITE_AWS_SECRET_ACCESS_KEY=your_secret_access_key
VITE_S3_ENDPOINT=https://your-s3-endpoint.com
VITE_AWS_REGION=us-east-1使用环境变量:
typescript
const uploader = new S3Uploader({
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY,
endpoint: import.meta.env.VITE_S3_ENDPOINT,
region: import.meta.env.VITE_AWS_REGION,
s3ForcePathStyle: true
});类型定义
typescript
// 上传状态
enum UploadStatus {
PENDING = 'pending',
UPLOADING = 'uploading',
PAUSED = 'paused',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled'
}
// 进度信息
interface ProgressInfo {
uploadId: string;
fileName: string;
progress: number; // 0-1 之间的进度值
speed: number; // 字节/秒
eta: number; // 预计剩余时间(秒)
status: UploadStatus;
partsProgress?: { // 分片上传进度(可选)
completed: number;
total: number;
};
}
// 文件夹条目
interface FolderEntry {
path: string;
files: FileInfo[];
}最佳实践
1. 文件命名规范
使用时间戳和原始文件名组合,避免文件名冲突:
typescript
function generateKey(file: File, folder: string = 'uploads'): string {
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(7);
const extension = file.name.split('.').pop();
return `${folder}/${timestamp}-${randomStr}.${extension}`;
}2. 文件类型验证
上传前验证文件类型和大小:
typescript
function validateFile(file: File): { valid: boolean; error?: string } {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
const maxSize = 5 * 1024 * 1024; // 5MB
if (!allowedTypes.includes(file.type)) {
return { valid: false, error: '不支持的文件类型' };
}
if (file.size > maxSize) {
return { valid: false, error: '文件大小超过限制' };
}
return { valid: true };
}3. 安全性
- 不要在前端代码中硬编码 AWS 凭证
- 使用环境变量存储敏感信息
- 考虑使用预签名 URL 或后端代理上传
实际使用场景
上传用户头像
这是一个完整的用户头像上传示例,包含图片预览、文件验证、上传进度和错误处理:
vue
<template>
<div class="avatar-upload">
<div class="avatar-preview">
<img v-if="avatarUrl" :src="avatarUrl" alt="头像预览" />
<div v-else class="avatar-placeholder">
<span>点击上传头像</span>
</div>
<div v-if="uploading" class="upload-progress">
<div class="progress-bar" :style="{ width: `${progress}%` }"></div>
<span class="progress-text">{{ progress }}%</span>
</div>
</div>
<button @click="selectAvatar" :disabled="uploading" class="upload-btn">
{{ uploading ? '上传中...' : '选择头像' }}
</button>
<p class="upload-hint">支持 JPG、PNG 格式,文件大小不超过 2MB</p>
<div v-if="errorMessage" class="error-message">{{ errorMessage }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { S3Uploader } from '@pt/utils/modules/storage';
const avatarUrl = ref('');
const uploading = ref(false);
const progress = ref(0);
const errorMessage = ref('');
let uploader: S3Uploader;
onMounted(() => {
// 初始化上传器
uploader = new S3Uploader({
accessKeyId: import.meta.env.VITE_AWS_ACCESS_KEY_ID,
secretAccessKey: import.meta.env.VITE_AWS_SECRET_ACCESS_KEY,
endpoint: import.meta.env.VITE_S3_ENDPOINT,
region: 'us-east-1',
s3ForcePathStyle: true
});
// 设置进度回调
uploader.setProgressCallback((progressInfo) => {
progress.value = Math.round(progressInfo.progress * 100);
});
});
// 验证图片文件
function validateImage(file: File): { valid: boolean; error?: string } {
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (!allowedTypes.includes(file.type)) {
return { valid: false, error: '仅支持 JPG 和 PNG 格式的图片' };
}
// 验证文件大小(2MB)
const maxSize = 2 * 1024 * 1024;
if (file.size > maxSize) {
return { valid: false, error: '图片大小不能超过 2MB' };
}
return { valid: true };
}
// 生成唯一的头像文件名
function generateAvatarKey(userId: string, file: File): string {
const timestamp = Date.now();
const extension = file.name.split('.').pop();
return `avatars/${userId}/${timestamp}.${extension}`;
}
// 选择并上传头像
async function selectAvatar() {
try {
errorMessage.value = '';
// 选择文件
const files = await uploader.selectFiles();
if (files.length === 0) return;
const file = files[0];
// 验证图片
const validation = validateImage(file);
if (!validation.valid) {
errorMessage.value = validation.error!;
return;
}
// 显示本地预览
const reader = new FileReader();
reader.onload = (e) => {
avatarUrl.value = e.target?.result as string;
};
reader.readAsDataURL(file);
// 开始上传
uploading.value = true;
progress.value = 0;
// 假设用户 ID 从某处获取
const userId = 'user_123'; // 实际应用中从用户状态获取
const key = generateAvatarKey(userId, file);
const uploadIds = await uploader.uploadFiles(
[file],
'my-app-bucket',
'' // 不使用额外前缀,因为 key 已经包含完整路径
);
// 等待上传完成
const checkUploadStatus = setInterval(() => {
const uploadProgress = uploader.getUploadProgress(uploadIds[0]);
if (uploadProgress?.status === 'completed') {
clearInterval(checkUploadStatus);
// 上传成功,获取访问 URL
getAvatarUrl(key);
} else if (uploadProgress?.status === 'failed') {
clearInterval(checkUploadStatus);
uploading.value = false;
errorMessage.value = '上传失败,请重试';
}
}, 500);
} catch (error) {
console.error('上传头像失败:', error);
uploading.value = false;
errorMessage.value = '上传失败,请重试';
}
}
// 获取头像访问 URL
async function getAvatarUrl(key: string) {
try {
// 生成预签名 URL(24小时有效)
const signedUrl = await uploader.getSignedUrl(
'my-app-bucket',
key,
24 * 60 * 60
);
avatarUrl.value = signedUrl;
uploading.value = false;
progress.value = 100;
// 这里可以将 URL 保存到用户资料
await saveAvatarToProfile(signedUrl);
} catch (error) {
console.error('获取头像 URL 失败:', error);
uploading.value = false;
errorMessage.value = '获取图片地址失败';
}
}
// 保存头像到用户资料(示例)
async function saveAvatarToProfile(url: string) {
// 调用 API 保存到数据库
console.log('保存头像 URL 到用户资料:', url);
// await api.updateUserProfile({ avatar: url });
}
</script>
<style scoped>
.avatar-upload {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px;
}
.avatar-preview {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
border: 2px solid #e0e0e0;
}
.avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
font-size: 14px;
cursor: pointer;
}
.upload-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: linear-gradient(90deg, #4caf50, #8bc34a);
transition: width 0.3s ease;
}
.progress-text {
position: relative;
color: white;
font-size: 12px;
font-weight: 600;
z-index: 1;
}
.upload-btn {
padding: 10px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.3s ease;
}
.upload-btn:hover:not(:disabled) {
background: #0056b3;
}
.upload-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.upload-hint {
margin: 0;
color: #666;
font-size: 13px;
}
.error-message {
padding: 8px 16px;
background: #ffebee;
color: #c62828;
border-radius: 4px;
font-size: 13px;
}
</style>关键功能说明
文件选择与验证
- 使用
selectFiles()方法打开文件选择对话框 - 验证文件类型(仅允许 JPG/PNG)
- 验证文件大小(最大 2MB)
- 使用
本地预览
- 使用
FileReader在上传前显示图片预览 - 提供即时的视觉反馈
- 使用
进度跟踪
- 通过
setProgressCallback实时更新上传进度 - 显示百分比进度条
- 通过
文件命名策略
- 使用用户 ID 和时间戳生成唯一文件名
- 格式:
avatars/{userId}/{timestamp}.{extension}
获取访问 URL
- 上传完成后使用
getSignedUrl()获取预签名 URL - 设置 24 小时有效期
- 上传完成后使用
错误处理
- 友好的错误提示
- 处理各种上传失败场景
这个示例展示了如何将 S3Uploader 集成到实际的 Vue 应用中,实现完整的用户头像上传功能。