feat: cloud upload - multi-image, category select, capture time, progress bar
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
cloudCategoryId: number | 'other' | null
|
||||
customCloudType: string
|
||||
locationName: string
|
||||
description: string
|
||||
isHidden: boolean
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
capturedAt: string
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
const view = new DataView(buffer)
|
||||
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||
|
||||
let offset = 2
|
||||
while (offset < view.byteLength - 2) {
|
||||
const marker = view.getUint16(offset, false)
|
||||
offset += 2
|
||||
if ((marker & 0xff00) !== 0xff00) break
|
||||
const length = view.getUint16(offset, false)
|
||||
offset += 2
|
||||
|
||||
if (marker === 0xffe1) {
|
||||
const exifStart = offset
|
||||
const header = String.fromCharCode(
|
||||
view.getUint8(exifStart),
|
||||
view.getUint8(exifStart + 1),
|
||||
view.getUint8(exifStart + 2),
|
||||
view.getUint8(exifStart + 3),
|
||||
)
|
||||
if (header !== 'Exif') break
|
||||
|
||||
const tiffStart = exifStart + 6
|
||||
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
||||
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
|
||||
const ifdStart = tiffStart + ifd0Offset
|
||||
const numEntries = view.getUint16(ifdStart, isLE)
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryStart = ifdStart + 2 + i * 12
|
||||
if (entryStart + 12 > buffer.byteLength) break
|
||||
const tag = view.getUint16(entryStart, isLE)
|
||||
if (tag === 0x9003) {
|
||||
const dataOffset = view.getUint32(entryStart + 8, isLE)
|
||||
const dateStr = Array.from({ length: 20 }, (_, j) =>
|
||||
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
|
||||
).join('')
|
||||
const iso = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
|
||||
const d = new Date(iso)
|
||||
if (!isNaN(d.getTime())) return d.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += length - 2
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function useUpload() {
|
||||
const items = ref<UploadItem[]>([])
|
||||
const uploading = ref(false)
|
||||
const overallProgress = ref(0)
|
||||
const currentItemIndex = ref(0)
|
||||
const totalItems = ref(0)
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'))
|
||||
for (const file of imageFiles) {
|
||||
let capturedAt = new Date().toISOString()
|
||||
try {
|
||||
const buffer = await readFileAsArrayBuffer(file)
|
||||
const exifDate = extractExifDate(buffer)
|
||||
if (exifDate) capturedAt = exifDate
|
||||
} catch {
|
||||
// fallback to now
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
id: String(++nextId),
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
cloudCategoryId: null,
|
||||
customCloudType: '',
|
||||
locationName: '',
|
||||
description: '',
|
||||
isHidden: false,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
capturedAt,
|
||||
errors: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
const idx = items.value.findIndex(i => i.id === id)
|
||||
if (idx !== -1) {
|
||||
URL.revokeObjectURL(items.value[idx].preview)
|
||||
items.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function validateItem(item: UploadItem): boolean {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!item.cloudCategoryId) {
|
||||
errors.cloudCategory = '请选择类别'
|
||||
}
|
||||
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||
errors.cloudCategory = '请输入自定义云型名称'
|
||||
}
|
||||
if (!item.capturedAt) {
|
||||
errors.capturedAt = '请选择拍摄时间'
|
||||
}
|
||||
item.errors = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
let allValid = true
|
||||
for (const item of items.value) {
|
||||
if (!validateItem(item)) {
|
||||
allValid = false
|
||||
}
|
||||
}
|
||||
return allValid
|
||||
}
|
||||
|
||||
function blurCoordinate(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
async function uploadAll(): Promise<boolean> {
|
||||
if (!validateAll()) return false
|
||||
|
||||
uploading.value = true
|
||||
totalItems.value = items.value.length
|
||||
currentItemIndex.value = 0
|
||||
overallProgress.value = 0
|
||||
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id
|
||||
if (!userId) {
|
||||
uploading.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < items.value.length; i++) {
|
||||
const item = items.value[i]
|
||||
currentItemIndex.value = i + 1
|
||||
|
||||
overallProgress.value = Math.round(i / items.value.length * 100)
|
||||
|
||||
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const imagePath = `${basePath}.${ext}`
|
||||
|
||||
const { error: imgError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(imagePath, item.file, { upsert: false })
|
||||
if (imgError) throw imgError
|
||||
|
||||
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||
|
||||
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||
|
||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||
|
||||
const { error: dbError } = await supabase
|
||||
.from('clouds')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
||||
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
||||
image_url: imageUrl,
|
||||
thumbnail_url: null,
|
||||
latitude,
|
||||
longitude,
|
||||
location_name: item.locationName || null,
|
||||
description: item.description.trim() || null,
|
||||
captured_at: item.capturedAt,
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
if (dbError) throw dbError
|
||||
|
||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value = []
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateItem, validateAll, uploadAll }
|
||||
}
|
||||
Reference in New Issue
Block a user