235 lines
7.1 KiB
TypeScript
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,
|
|
}
|
|
}
|