后端迁移到fastapi (#3)
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+80
-285
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
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 {
|
||||
@@ -32,8 +34,6 @@ export interface UploadResult {
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
const THUMBNAIL_MAX_EDGE = 640
|
||||
const THUMBNAIL_QUALITY = 0.72
|
||||
|
||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -44,84 +44,6 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
})
|
||||
}
|
||||
|
||||
function loadImageElement(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
resolve(image)
|
||||
}
|
||||
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
|
||||
image.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
function getResizedDimensions(width: number, height: number, maxEdge: number) {
|
||||
const largestEdge = Math.max(width, height)
|
||||
if (largestEdge <= maxEdge) {
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const scale = maxEdge / largestEdge
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
}
|
||||
}
|
||||
|
||||
function fileBaseName(name: string) {
|
||||
const idx = name.lastIndexOf('.')
|
||||
return idx === -1 ? name : name.slice(0, idx)
|
||||
}
|
||||
|
||||
async function renderJpegFile(
|
||||
image: HTMLImageElement,
|
||||
sourceFile: File,
|
||||
maxEdge: number,
|
||||
quality: number,
|
||||
suffix = '',
|
||||
) {
|
||||
const { width, height } = getResizedDimensions(image.naturalWidth || image.width, image.naturalHeight || image.height, maxEdge)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
throw new Error('图片压缩失败')
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
const blob = await new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('图片压缩失败')
|
||||
}
|
||||
|
||||
return new File([blob], `${fileBaseName(sourceFile.name)}${suffix}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
async function createUploadAssets(file: File) {
|
||||
const image = await loadImageElement(file)
|
||||
|
||||
return {
|
||||
thumbnailFile: await renderJpegFile(image, file, THUMBNAIL_MAX_EDGE, THUMBNAIL_QUALITY, '-thumb'),
|
||||
}
|
||||
}
|
||||
|
||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
const view = new DataView(buffer)
|
||||
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||
@@ -146,23 +68,20 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
|
||||
const tiffStart = exifStart + 6
|
||||
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
||||
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
|
||||
const ifdStart = tiffStart + ifd0Offset
|
||||
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
|
||||
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()
|
||||
}
|
||||
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
|
||||
@@ -170,51 +89,12 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchUnlockedTypeIds(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('cloud_type_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return new Set(
|
||||
(data || [])
|
||||
.map(row => row.cloud_type_id)
|
||||
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
|
||||
const ids = unlockedRows.map(item => item.cloudTypeId)
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('id,name,name_en,rarity')
|
||||
.in('id', ids)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const typeMap = new Map(
|
||||
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
|
||||
)
|
||||
|
||||
return unlockedRows
|
||||
.map(item => {
|
||||
const cloudType = typeMap.get(item.cloudTypeId)
|
||||
if (!cloudType) return null
|
||||
|
||||
return {
|
||||
cloudTypeId: cloudType.id,
|
||||
cloudName: cloudType.name,
|
||||
cloudNameEn: cloudType.name_en,
|
||||
rarity: cloudType.rarity,
|
||||
unlockedAt: item.unlockedAt,
|
||||
} satisfies UnlockedBadge
|
||||
})
|
||||
.filter((item): item is UnlockedBadge => item !== 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)
|
||||
@@ -223,15 +103,14 @@ export function useUpload() {
|
||||
const totalItems = ref(0)
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'))
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'))
|
||||
for (const file of imageFiles) {
|
||||
let capturedAt = new Date().toISOString()
|
||||
try {
|
||||
const buffer = await readFileAsArrayBuffer(file)
|
||||
const exifDate = extractExifDate(buffer)
|
||||
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
||||
if (exifDate) capturedAt = exifDate
|
||||
} catch {
|
||||
// fallback to now
|
||||
// 无 EXIF 时使用当前时间。
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
@@ -252,182 +131,86 @@ export function useUpload() {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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() {
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value.forEach(item => URL.revokeObjectURL(item.preview))
|
||||
items.value = []
|
||||
}
|
||||
|
||||
function validateItem(item: UploadItem): boolean {
|
||||
function validateItem(item: UploadItem) {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!item.cloudCategoryId) {
|
||||
errors.cloudCategory = '请选择类别'
|
||||
}
|
||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
||||
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||
errors.cloudCategory = '请输入自定义云型名称'
|
||||
}
|
||||
if (!item.capturedAt) {
|
||||
errors.capturedAt = '请选择拍摄时间'
|
||||
}
|
||||
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 = '经纬度必须同时填写'
|
||||
}
|
||||
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
|
||||
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
|
||||
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
|
||||
function validateAll() {
|
||||
return items.value.map(validateItem).every(Boolean)
|
||||
}
|
||||
|
||||
async function uploadAll(): Promise<UploadResult> {
|
||||
if (!validateAll()) {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
||||
|
||||
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 { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
const badges = new Map<number, UnlockedBadge>()
|
||||
|
||||
try {
|
||||
let unlockedTypeIds = new Set<number>()
|
||||
try {
|
||||
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
|
||||
} catch {
|
||||
unlockedTypeIds = new Set<number>()
|
||||
}
|
||||
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 newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
|
||||
|
||||
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 basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||
const imagePath = `${basePath}.${ext}`
|
||||
const thumbnailPath = `${basePath}-thumb.jpg`
|
||||
const { thumbnailFile } = await createUploadAssets(item.file)
|
||||
|
||||
const { error: imgError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(imagePath, item.file, {
|
||||
upsert: false,
|
||||
contentType: item.file.type,
|
||||
})
|
||||
if (imgError) throw imgError
|
||||
|
||||
const { error: thumbError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(thumbnailPath, thumbnailFile, {
|
||||
upsert: false,
|
||||
contentType: thumbnailFile.type,
|
||||
})
|
||||
if (thumbError) throw thumbError
|
||||
|
||||
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||
|
||||
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
|
||||
|
||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||
|
||||
const { data: insertedCloud, 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: thumbnailUrl,
|
||||
latitude,
|
||||
longitude,
|
||||
location_name: item.locationName || null,
|
||||
description: item.description.trim() || null,
|
||||
captured_at: item.capturedAt,
|
||||
status: 'pending',
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
.select('id,cloud_type_id')
|
||||
.single()
|
||||
if (dbError) throw dbError
|
||||
|
||||
if (
|
||||
insertedCloud &&
|
||||
typeof insertedCloud.cloud_type_id === 'number' &&
|
||||
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
|
||||
) {
|
||||
try {
|
||||
const { data: collectionRow, error: collectionError } = await supabase
|
||||
.from('user_collections')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: insertedCloud.cloud_type_id,
|
||||
first_cloud_id: insertedCloud.id,
|
||||
})
|
||||
.select('cloud_type_id,unlocked_at')
|
||||
.single()
|
||||
|
||||
if (collectionError) {
|
||||
const duplicate = collectionError.code === '23505'
|
||||
if (!duplicate) throw collectionError
|
||||
} else if (collectionRow) {
|
||||
unlockedTypeIds.add(collectionRow.cloud_type_id)
|
||||
newlyUnlockedRows.push({
|
||||
cloudTypeId: collectionRow.cloud_type_id,
|
||||
unlockedAt: collectionRow.unlocked_at,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore collection sync failures so uploads can still complete.
|
||||
}
|
||||
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))
|
||||
|
||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||
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)
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value = []
|
||||
profileStore.invalidateUser(userId)
|
||||
return {
|
||||
ok: true,
|
||||
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
|
||||
}
|
||||
clearAll()
|
||||
profileStore.invalidateUser(authStore.user.id)
|
||||
return { ok: true, unlockedBadges: [...badges.values()] }
|
||||
} catch {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
} finally {
|
||||
@@ -435,5 +218,17 @@ export function useUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll }
|
||||
return {
|
||||
items,
|
||||
uploading,
|
||||
overallProgress,
|
||||
currentItemIndex,
|
||||
totalItems,
|
||||
addFiles,
|
||||
removeItem,
|
||||
clearAll,
|
||||
validateItem,
|
||||
validateAll,
|
||||
uploadAll,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user