Supabase文件上传下载 #

一、文件上传 #

1.1 基础上传 #

typescript
// 上传文件
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/avatar.jpg', file)

// data: { path: 'user-123/avatar.jpg', ... }

1.2 带配置上传 #

typescript
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/avatar.jpg', file, {
    cacheControl: '3600',      // 缓存时间
    contentType: 'image/jpeg', // 内容类型
    upsert: true,              // 覆盖已存在文件
  })

1.3 上传表单示例 #

tsx
import { useState } from 'react'
import { supabase } from '../lib/supabase'

export function FileUpload() {
  const [uploading, setUploading] = useState(false)
  const [progress, setProgress] = useState(0)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setUploading(true)
    setProgress(0)

    const { data: { user } } = await supabase.auth.getUser()
    if (!user) return

    const fileExt = file.name.split('.').pop()
    const fileName = `${user.id}/${Date.now()}.${fileExt}`

    const { data, error } = await supabase.storage
      .from('avatars')
      .upload(fileName, file, {
        upsert: true,
      })

    setUploading(false)

    if (error) {
      console.error('Upload error:', error)
    } else {
      console.log('Uploaded:', data.path)
    }
  }

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={handleUpload}
        disabled={uploading}
      />
      {uploading && <div>Uploading... {progress}%</div>}
    </div>
  )
}

1.4 拖拽上传 #

tsx
import { useState, useCallback } from 'react'
import { supabase } from '../lib/supabase'

export function DropZone() {
  const [dragging, setDragging] = useState(false)

  const handleDrop = useCallback(async (e: React.DragEvent) => {
    e.preventDefault()
    setDragging(false)

    const files = Array.from(e.dataTransfer.files)
    
    for (const file of files) {
      await uploadFile(file)
    }
  }, [])

  async function uploadFile(file: File) {
    const { data: { user } } = await supabase.auth.getUser()
    if (!user) return

    const path = `${user.id}/${Date.now()}-${file.name}`
    
    const { error } = await supabase.storage
      .from('uploads')
      .upload(path, file)

    if (error) {
      console.error('Upload error:', error)
    }
  }

  return (
    <div
      onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
      onDragLeave={() => setDragging(false)}
      onDrop={handleDrop}
      style={{
        border: dragging ? '2px dashed blue' : '2px dashed gray',
        padding: '40px',
        textAlign: 'center',
      }}
    >
      Drop files here
    </div>
  )
}

二、文件下载 #

2.1 获取公开URL #

typescript
// 公开桶直接获取URL
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-123/avatar.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/avatars/user-123/avatar.jpg

2.2 获取签名URL #

typescript
// 私有桶需要签名URL
const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl('user-123/report.pdf', 3600) // 1小时有效

console.log(data.signedUrl)

2.3 下载文件 #

typescript
// 下载文件为Blob
const { data, error } = await supabase.storage
  .from('documents')
  .download('user-123/report.pdf')

if (data) {
  // 创建下载链接
  const url = URL.createObjectURL(data)
  const a = document.createElement('a')
  a.href = url
  a.download = 'report.pdf'
  a.click()
  URL.revokeObjectURL(url)
}

2.4 下载组件示例 #

tsx
import { supabase } from '../lib/supabase'

export function DownloadButton({ path, filename }: { path: string; filename: string }) {
  const handleDownload = async () => {
    const { data, error } = await supabase.storage
      .from('documents')
      .download(path)

    if (data) {
      const url = URL.createObjectURL(data)
      const a = document.createElement('a')
      a.href = url
      a.download = filename
      a.click()
      URL.revokeObjectURL(url)
    }
  }

  return (
    <button onClick={handleDownload}>
      Download
    </button>
  )
}

三、文件列表 #

3.1 列出文件 #

typescript
// 列出文件夹中的文件
const { data, error } = await supabase.storage
  .from('avatars')
  .list('user-123', {
    limit: 100,
    offset: 0,
    sortBy: { column: 'name', order: 'asc' },
  })

data?.forEach(file => {
  console.log(file.name)
  console.log(file.metadata?.size)
  console.log(file.metadata?.mimetype)
})

3.2 递归列出所有文件 #

typescript
async function listAllFiles(bucket: string, folder: string = ''): Promise<string[]> {
  const files: string[] = []
  
  const { data } = await supabase.storage
    .from(bucket)
    .list(folder)
  
  for (const item of data || []) {
    const path = folder ? `${folder}/${item.name}` : item.name
    
    if (item.metadata) {
      // 是文件
      files.push(path)
    } else {
      // 是文件夹,递归
      const subFiles = await listAllFiles(bucket, path)
      files.push(...subFiles)
    }
  }
  
  return files
}

3.3 文件列表组件 #

tsx
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'

export function FileList({ bucket, folder }: { bucket: string; folder: string }) {
  const [files, setFiles] = useState<any[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadFiles()
  }, [bucket, folder])

  async function loadFiles() {
    setLoading(true)
    const { data } = await supabase.storage
      .from(bucket)
      .list(folder)
    setFiles(data || [])
    setLoading(false)
  }

  if (loading) return <div>Loading...</div>

  return (
    <ul>
      {files.map(file => (
        <li key={file.name}>
          {file.name} ({formatBytes(file.metadata?.size)})
        </li>
      ))}
    </ul>
  )
}

function formatBytes(bytes: number): string {
  if (!bytes) 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]
}

四、文件删除 #

4.1 删除单个文件 #

typescript
const { data, error } = await supabase.storage
  .from('avatars')
  .remove(['user-123/avatar.jpg'])

4.2 批量删除 #

typescript
const { data, error } = await supabase.storage
  .from('avatars')
  .remove([
    'user-123/avatar1.jpg',
    'user-123/avatar2.jpg',
    'user-123/avatar3.jpg',
  ])

4.3 删除文件夹 #

typescript
async function deleteFolder(bucket: string, folder: string) {
  // 1. 列出所有文件
  const files = await listAllFiles(bucket, folder)
  
  // 2. 批量删除
  if (files.length > 0) {
    await supabase.storage
      .from(bucket)
      .remove(files)
  }
}

五、文件移动和复制 #

5.1 移动文件 #

typescript
const { data, error } = await supabase.storage
  .from('avatars')
  .move('user-123/old.jpg', 'user-123/new.jpg')

5.2 复制文件 #

typescript
const { data, error } = await supabase.storage
  .from('documents')
  .copy('user-123/original.pdf', 'user-123/copy.pdf')

六、大文件上传 #

6.1 分片上传 #

typescript
async function uploadLargeFile(
  bucket: string,
  path: string,
  file: File,
  chunkSize: number = 5 * 1024 * 1024 // 5MB
) {
  const totalChunks = Math.ceil(file.size / chunkSize)
  const uploadId = crypto.randomUUID()

  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize
    const end = Math.min(start + chunkSize, file.size)
    const chunk = file.slice(start, end)
    
    const chunkPath = `${path}.part.${i}`
    
    await supabase.storage
      .from(bucket)
      .upload(chunkPath, chunk)
    
    console.log(`Uploaded chunk ${i + 1}/${totalChunks}`)
  }

  // 合并分片(需要服务端处理)
  // ...
}

6.2 上传进度 #

typescript
// 使用XMLHttpRequest获取进度
function uploadWithProgress(
  bucket: string,
  path: string,
  file: File,
  onProgress: (percent: number) => void
): Promise<any> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const percent = Math.round((e.loaded / e.total) * 100)
        onProgress(percent)
      }
    })
    
    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText))
      } else {
        reject(new Error(xhr.statusText))
      }
    })
    
    xhr.addEventListener('error', () => reject(new Error('Upload failed')))
    
    xhr.open('POST', `${supabaseUrl}/storage/v1/object/${bucket}/${path}`)
    xhr.setRequestHeader('Authorization', `Bearer ${supabaseKey}`)
    xhr.setRequestHeader('Content-Type', file.type)
    xhr.send(file)
  })
}

七、错误处理 #

7.1 常见错误 #

typescript
const errorMessages: Record<string, string> = {
  'Bucket not found': '存储桶不存在',
  'File not found': '文件不存在',
  'File already exists': '文件已存在',
  'storage quota exceeded': '存储配额已满',
  'File size exceeded': '文件大小超限',
  'Invalid mime type': '文件类型不允许',
  'Unauthorized': '未授权访问',
}

function handleStorageError(error: any): string {
  return errorMessages[error.message] || error.message
}

7.2 重试机制 #

typescript
async function uploadWithRetry(
  bucket: string,
  path: string,
  file: File,
  maxRetries: number = 3
) {
  let lastError: any
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      const result = await supabase.storage
        .from(bucket)
        .upload(path, file)
      
      if (!result.error) {
        return result
      }
      
      lastError = result.error
    } catch (err) {
      lastError = err
    }
    
    // 等待后重试
    await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
  }
  
  throw lastError
}

八、最佳实践 #

8.1 文件命名 #

typescript
function generateUniquePath(userId: string, filename: string): string {
  const ext = filename.split('.').pop()
  const timestamp = Date.now()
  const random = Math.random().toString(36).substring(7)
  return `${userId}/${timestamp}-${random}.${ext}`
}

8.2 文件验证 #

typescript
function validateFile(
  file: File,
  options: {
    maxSize?: number
    allowedTypes?: string[]
  }
): { valid: boolean; error?: string } {
  if (options.maxSize && file.size > options.maxSize) {
    return { valid: false, error: 'File too large' }
  }
  
  if (options.allowedTypes) {
    const allowed = options.allowedTypes.some(type => {
      if (type.endsWith('/*')) {
        return file.type.startsWith(type.slice(0, -1))
      }
      return file.type === type
    })
    
    if (!allowed) {
      return { valid: false, error: 'File type not allowed' }
    }
  }
  
  return { valid: true }
}

九、总结 #

文件操作要点:

操作 方法
上传 upload(path, file)
下载 download(path)
列表 list(folder)
删除 remove([paths])
移动 move(from, to)
复制 copy(from, to)
公开URL getPublicUrl(path)
签名URL createSignedUrl(path, expiresIn)

下一步,让我们学习图片处理!

最后更新:2026-03-28