222 lines
6.6 KiB
TypeScript
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 }
|
|
}
|