后端迁移到fastapi (#3)

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-18 20:49:09 +08:00
parent 217b81c506
commit 51743645e2
71 changed files with 845 additions and 3580 deletions
+80 -285
View File
@@ -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,
}
}