import { ref } from 'vue' import { apiRequest } from '@/lib/api' import { useAuthStore } from '@/stores/auth' import { useProfileStore } from '@/stores/profile' import type { CloudCreateResponse } from '@/types/api' import type { CloudType } from '@/types/database' 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 } export interface UnlockedBadge { cloudTypeId: number cloudName: string cloudNameEn: string rarity: CloudType['rarity'] unlockedAt: string } export interface UploadResult { ok: boolean unlockedBadges: UnlockedBadge[] } let nextId = 0 function readFileAsArrayBuffer(file: File): Promise { 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 ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE) 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 if (view.getUint16(entryStart, isLE) !== 0x9003) continue const dataOffset = view.getUint32(entryStart + 8, isLE) const dateStr = Array.from({ length: 20 }, (_, j) => String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), ).join('') const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')) if (!isNaN(date.getTime())) return date.toISOString() } } offset += length - 2 } return null } function appendOptional(form: FormData, key: string, value: string | number | null) { if (value !== null && value !== '') form.append(key, String(value)) } export function useUpload() { const authStore = useAuthStore() const profileStore = useProfileStore() const items = ref([]) 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(file => file.type.startsWith('image/')) for (const file of imageFiles) { let capturedAt = new Date().toISOString() try { const exifDate = extractExifDate(await readFileAsArrayBuffer(file)) if (exifDate) capturedAt = exifDate } catch { // 无 EXIF 时使用当前时间。 } 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 index = items.value.findIndex(item => item.id === id) if (index === -1) return URL.revokeObjectURL(items.value[index].preview) items.value.splice(index, 1) } function clearAll() { items.value.forEach(item => URL.revokeObjectURL(item.preview)) items.value = [] } function validateItem(item: UploadItem) { const errors: Record = {} if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别' if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) { errors.cloudCategory = '请输入自定义云型名称' } if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间' const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude) const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude) if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写' if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写' item.errors = errors return Object.keys(errors).length === 0 } function validateAll() { return items.value.map(validateItem).every(Boolean) } async function uploadAll(): Promise { if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] } uploading.value = true totalItems.value = items.value.length currentItemIndex.value = 0 overallProgress.value = 0 const badges = new Map() try { for (let index = 0; index < items.value.length; index++) { const item = items.value[index] currentItemIndex.value = index + 1 overallProgress.value = Math.round(index / items.value.length * 100) const form = new FormData() form.append('image', item.file) if (item.cloudCategoryId === 'other') { form.append('custom_cloud_type', item.customCloudType.trim()) } else { form.append('cloud_type_id', String(item.cloudCategoryId)) } appendOptional(form, 'latitude', item.latitude) appendOptional(form, 'longitude', item.longitude) appendOptional(form, 'location_name', item.locationName.trim()) appendOptional(form, 'description', item.description.trim()) appendOptional(form, 'captured_at', item.capturedAt) form.append('is_hidden', String(item.isHidden)) const result = await apiRequest('/clouds', { method: 'POST', body: form, }) if (result.unlocked_badge) { const badge = result.unlocked_badge badges.set(badge.cloud_type_id, { cloudTypeId: badge.cloud_type_id, cloudName: badge.cloud_name, cloudNameEn: badge.cloud_name_en, rarity: badge.rarity, unlockedAt: badge.unlocked_at, }) } overallProgress.value = Math.round((index + 1) / items.value.length * 100) } clearAll() profileStore.invalidateUser(authStore.user.id) return { ok: true, unlockedBadges: [...badges.values()] } } catch { return { ok: false, unlockedBadges: [] } } finally { uploading.value = false } } return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll, } }