diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts index 46a3d6b..aac49ac 100644 --- a/src/composables/useUpload.ts +++ b/src/composables/useUpload.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { supabase } from '@/lib/supabase' +import type { CloudType } from '@/types/database' export interface UploadItem { id: string @@ -16,6 +17,19 @@ export interface UploadItem { 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 { @@ -75,6 +89,50 @@ 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>).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) +} + export function useUpload() { const items = ref([]) const uploading = ref(false) @@ -163,8 +221,10 @@ export function useUpload() { return Math.round(value * 100) / 100 } - async function uploadAll(): Promise { - if (!validateAll()) return false + async function uploadAll(): Promise { + if (!validateAll()) { + return { ok: false, unlockedBadges: [] } + } uploading.value = true totalItems.value = items.value.length @@ -174,10 +234,19 @@ export function useUpload() { const userId = (await supabase.auth.getUser()).data.user?.id if (!userId) { uploading.value = false - return false + return { ok: false, unlockedBadges: [] } } try { + let unlockedTypeIds = new Set() + try { + unlockedTypeIds = await fetchUnlockedTypeIds(userId) + } catch { + unlockedTypeIds = new Set() + } + + 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 @@ -200,7 +269,7 @@ export function useUpload() { const latitude = item.latitude ? blurCoordinate(item.latitude) : null const longitude = item.longitude ? blurCoordinate(item.longitude) : null - const { error: dbError } = await supabase + const { data: insertedCloud, error: dbError } = await supabase .from('clouds') .insert({ user_id: userId, @@ -216,8 +285,41 @@ export function useUpload() { status: 'approved', 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. + } + } + overallProgress.value = Math.round((i + 1) / items.value.length * 100) } @@ -225,9 +327,12 @@ export function useUpload() { URL.revokeObjectURL(item.preview) } items.value = [] - return true + return { + ok: true, + unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [], + } } catch { - return false + return { ok: false, unlockedBadges: [] } } finally { uploading.value = false } diff --git a/src/lib/cloudBadges.ts b/src/lib/cloudBadges.ts new file mode 100644 index 0000000..b8a406a --- /dev/null +++ b/src/lib/cloudBadges.ts @@ -0,0 +1,130 @@ +export type BadgeRarity = 'common' | 'uncommon' | 'rare' + +export interface CloudBadgeCardInput { + cloudName: string + cloudNameEn: string + unlockedAt: string + username: string + rarity: BadgeRarity +} + +const rarityTheme = { + common: { + from: '#dbeafe', + to: '#f8fafc', + accent: '#0284c7', + shadow: 'rgba(2, 132, 199, 0.18)', + }, + uncommon: { + from: '#fef3c7', + to: '#fff7ed', + accent: '#d97706', + shadow: 'rgba(217, 119, 6, 0.22)', + }, + rare: { + from: '#fee2e2', + to: '#fff7ed', + accent: '#dc2626', + shadow: 'rgba(220, 38, 38, 0.22)', + }, +} satisfies Record + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) +} + +function makeDownloadName(name: string) { + return `opencloud-badge-${name}.png` +} + +export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) { + const canvas = document.createElement('canvas') + canvas.width = 1200 + canvas.height = 630 + + const ctx = canvas.getContext('2d') + if (!ctx) return + + const theme = rarityTheme[input.rarity] + const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) + gradient.addColorStop(0, theme.from) + gradient.addColorStop(1, theme.to) + + ctx.fillStyle = gradient + ctx.fillRect(0, 0, canvas.width, canvas.height) + + ctx.fillStyle = 'rgba(255,255,255,0.72)' + ctx.beginPath() + ctx.arc(1010, 120, 140, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = 'rgba(255,255,255,0.45)' + ctx.beginPath() + ctx.arc(160, 520, 180, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = '#0f172a' + ctx.font = '700 44px sans-serif' + ctx.fillText('OpenCloud 云朵图鉴', 72, 88) + + ctx.fillStyle = 'rgba(15, 23, 42, 0.62)' + ctx.font = '400 24px sans-serif' + ctx.fillText('新徽章已解锁', 72, 126) + + ctx.shadowColor = theme.shadow + ctx.shadowBlur = 28 + ctx.fillStyle = '#ffffff' + ctx.beginPath() + ctx.roundRect(72, 170, 1056, 380, 32) + ctx.fill() + ctx.shadowBlur = 0 + + ctx.fillStyle = theme.accent + ctx.beginPath() + ctx.arc(210, 360, 92, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = '#ffffff' + ctx.font = '700 82px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(input.cloudName.slice(0, 1), 210, 360) + + ctx.textAlign = 'left' + ctx.textBaseline = 'alphabetic' + ctx.fillStyle = '#0f172a' + ctx.font = '700 64px sans-serif' + ctx.fillText(input.cloudName, 340, 300) + + ctx.fillStyle = 'rgba(15, 23, 42, 0.62)' + ctx.font = '400 30px sans-serif' + ctx.fillText(input.cloudNameEn, 340, 346) + + ctx.fillStyle = theme.accent + ctx.beginPath() + ctx.roundRect(340, 382, 168, 48, 24) + ctx.fill() + + ctx.fillStyle = '#ffffff' + ctx.font = '600 24px sans-serif' + ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414) + + ctx.fillStyle = '#334155' + ctx.font = '400 28px sans-serif' + ctx.fillText(`解锁者:${input.username}`, 340, 476) + ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518) + + const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) + if (!blob) return + + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = makeDownloadName(input.cloudNameEn.toLowerCase()) + link.click() + URL.revokeObjectURL(url) +} diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts new file mode 100644 index 0000000..9eaa86f --- /dev/null +++ b/src/stores/encyclopedia.ts @@ -0,0 +1,161 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { supabase } from '@/lib/supabase' +import { useAuthStore } from '@/stores/auth' +import type { CloudType, UserCollection } from '@/types/database' + +export interface CollectionPreviewCloud { + id: string + image_url: string + captured_at: string | null + created_at: string + location_name: string | null +} + +export interface CollectionEntry extends UserCollection { + firstCloud: CollectionPreviewCloud | null +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message + } + return '图鉴收藏加载失败' +} + +function toCollectionCloudMap(rows: Array> | null) { + return new Map( + (rows || []).map(row => [ + row.id as string, + { + id: row.id as string, + image_url: row.image_url as string, + captured_at: (row.captured_at as string | null) ?? null, + created_at: row.created_at as string, + location_name: (row.location_name as string | null) ?? null, + } satisfies CollectionPreviewCloud, + ]), + ) +} + +export const useEncyclopediaStore = defineStore('encyclopedia', () => { + const authStore = useAuthStore() + + const cloudTypes = ref([]) + const myCollection = ref([]) + const loadingCloudTypes = ref(false) + const loadingCollection = ref(false) + const cloudTypesLoaded = ref(false) + const collectionLoadedForUserId = ref(null) + const collectionError = ref('') + + const collectionMap = computed(() => { + return new Map(myCollection.value.map(item => [item.cloud_type_id, item])) + }) + + const unlockedCount = computed(() => myCollection.value.length) + const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`) + const unlockPercent = computed(() => { + if (!cloudTypes.value.length) return 0 + return Math.round(unlockedCount.value / cloudTypes.value.length * 100) + }) + + async function fetchCloudTypes(force = false) { + if (cloudTypesLoaded.value && !force) return + + loadingCloudTypes.value = true + try { + const { data, error } = await supabase + .from('cloud_types') + .select('*') + .order('id') + + if (error) throw error + + cloudTypes.value = (data || []) as CloudType[] + cloudTypesLoaded.value = true + } finally { + loadingCloudTypes.value = false + } + } + + async function fetchMyCollection(force = false) { + const userId = authStore.user?.id ?? null + + if (!userId) { + myCollection.value = [] + collectionLoadedForUserId.value = null + collectionError.value = '' + return + } + + if (!force && collectionLoadedForUserId.value === userId) return + + loadingCollection.value = true + collectionError.value = '' + + try { + const { data, error } = await supabase + .from('user_collections') + .select('*') + .eq('user_id', userId) + .order('unlocked_at', { ascending: true }) + + if (error) throw error + + const collectionRows = (data || []) as UserCollection[] + const firstCloudIds = collectionRows + .map(item => item.first_cloud_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + + let firstCloudMap = new Map() + + if (firstCloudIds.length) { + const { data: firstCloudData, error: firstCloudError } = await supabase + .from('clouds') + .select('id,image_url,captured_at,created_at,location_name') + .in('id', firstCloudIds) + + if (firstCloudError) throw firstCloudError + + firstCloudMap = toCollectionCloudMap(firstCloudData as Array> | null) + } + + myCollection.value = collectionRows.map(item => ({ + ...item, + firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null, + })) + collectionLoadedForUserId.value = userId + } catch (error) { + myCollection.value = [] + collectionLoadedForUserId.value = userId + collectionError.value = getErrorMessage(error) + } finally { + loadingCollection.value = false + } + } + + function isUnlocked(cloudTypeId: number) { + return collectionMap.value.has(cloudTypeId) + } + + function getCollectionEntry(cloudTypeId: number) { + return collectionMap.value.get(cloudTypeId) ?? null + } + + return { + cloudTypes, + myCollection, + loadingCloudTypes, + loadingCollection, + collectionError, + unlockedCount, + unlockProgress, + unlockPercent, + fetchCloudTypes, + fetchMyCollection, + isUnlocked, + getCollectionEntry, + } +}) diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue index 829e5cb..c6e326e 100644 --- a/src/views/encyclopedia/CloudTypeView.vue +++ b/src/views/encyclopedia/CloudTypeView.vue @@ -1,9 +1,255 @@ diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue index 9f913b1..f0c7364 100644 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ b/src/views/encyclopedia/EncyclopediaView.vue @@ -1,9 +1,190 @@ diff --git a/src/views/upload/UploadView.vue b/src/views/upload/UploadView.vue index 5013063..950fba6 100644 --- a/src/views/upload/UploadView.vue +++ b/src/views/upload/UploadView.vue @@ -2,15 +2,19 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { useRouter } from 'vue-router' import { useCloudsStore } from '@/stores/clouds' +import { useAuthStore } from '@/stores/auth' +import { downloadCloudBadgeCard } from '@/lib/cloudBadges' import { useUpload } from '@/composables/useUpload' const router = useRouter() +const authStore = useAuthStore() const cloudsStore = useCloudsStore() const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload() const activeId = ref(null) const dragOver = ref(false) const successMsg = ref(false) +const unlockedBadges = ref>['unlockedBadges']>([]) const errorMsg = ref('') const fileInput = ref(null) @@ -68,15 +72,45 @@ async function handleSubmit() { return } - const ok = await uploadAll() - if (ok) { + const result = await uploadAll() + if (result.ok) { + unlockedBadges.value = result.unlockedBadges successMsg.value = true - setTimeout(() => router.push('/profile'), 2000) } else { errorMsg.value = '上传失败,请稍后重试' } } +function resetAfterSuccess() { + successMsg.value = false + unlockedBadges.value = [] + errorMsg.value = '' +} + +async function saveBadge(badge: NonNullable) { + await downloadCloudBadgeCard({ + cloudName: badge.cloudName, + cloudNameEn: badge.cloudNameEn, + unlockedAt: badge.unlockedAt, + username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户', + rarity: badge.rarity, + }) +} + +function rarityLabel(rarity: NonNullable['rarity']) { + if (rarity === 'common') return '常见' + if (rarity === 'uncommon') return '少见' + return '罕见' +} + +function formatUnlockedAt(iso: string) { + return new Date(iso).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) +} + function onLatInput(e: Event) { const val = parseFloat((e.target as HTMLInputElement).value) if (activeItem.value) { @@ -133,10 +167,77 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
-
- -

上传成功!

-

正在跳转到个人主页...

+
+
+ +

上传成功

+

+ + +

+
+ +
+
+
+ {{ badge.cloudName.slice(0, 1) }} +
+
+
+

{{ badge.cloudName }}

+ + {{ rarityLabel(badge.rarity) }} + +
+

{{ badge.cloudNameEn }}

+

解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}

+
+ +
+ + +
+
+
+ +
+ + + +