Files
opencloud/src/composables/useUpload.ts
T

222 lines
6.6 KiB
TypeScript

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 }
}