Skip to content

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>

关键功能说明

  1. 文件选择与验证

    • 使用 selectFiles() 方法打开文件选择对话框
    • 验证文件类型(仅允许 JPG/PNG)
    • 验证文件大小(最大 2MB)
  2. 本地预览

    • 使用 FileReader 在上传前显示图片预览
    • 提供即时的视觉反馈
  3. 进度跟踪

    • 通过 setProgressCallback 实时更新上传进度
    • 显示百分比进度条
  4. 文件命名策略

    • 使用用户 ID 和时间戳生成唯一文件名
    • 格式:avatars/{userId}/{timestamp}.{extension}
  5. 获取访问 URL

    • 上传完成后使用 getSignedUrl() 获取预签名 URL
    • 设置 24 小时有效期
  6. 错误处理

    • 友好的错误提示
    • 处理各种上传失败场景

这个示例展示了如何将 S3Uploader 集成到实际的 Vue 应用中,实现完整的用户头像上传功能。

Released under the MIT License.