Files
opencloud/src/composables/useUpload.ts
T
2026-07-18 20:49:09 +08:00

235 lines
7.1 KiB
TypeScript

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<string, string>
}
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<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 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<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(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<string, string> = {}
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<UploadResult> {
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<number, UnlockedBadge>()
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<CloudCreateResponse>('/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,
}
}