From 6d8acce295a9e403ffcd08b80cb9699a0bf058f0 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 21 May 2026 19:56:42 +0800 Subject: [PATCH] feat: improve image browsing experience --- src/components/cloud/ImageDetailModal.vue | 117 +++++++ src/composables/useUpload.ts | 100 +++++- src/stores/encyclopedia.ts | 4 +- src/views/encyclopedia/EncyclopediaView.vue | 2 +- src/views/gallery/GalleryView.vue | 322 +++++++++++++++++++- src/views/map/MapView.vue | 148 +++++++-- 6 files changed, 661 insertions(+), 32 deletions(-) create mode 100644 src/components/cloud/ImageDetailModal.vue diff --git a/src/components/cloud/ImageDetailModal.vue b/src/components/cloud/ImageDetailModal.vue new file mode 100644 index 0000000..1118ea0 --- /dev/null +++ b/src/components/cloud/ImageDetailModal.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts index aac49ac..cf7ffbd 100644 --- a/src/composables/useUpload.ts +++ b/src/composables/useUpload.ts @@ -31,6 +31,8 @@ export interface UploadResult { } let nextId = 0 +const THUMBNAIL_MAX_EDGE = 640 +const THUMBNAIL_QUALITY = 0.72 function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { @@ -41,6 +43,84 @@ function readFileAsArrayBuffer(file: File): Promise { }) } +function loadImageElement(file: File): Promise { + 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(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 @@ -253,18 +333,32 @@ export function useUpload() { overallProgress.value = Math.round(i / items.value.length * 100) - const ext = item.file.name.split('.').pop() || 'jpg' 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 }) + .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 @@ -276,7 +370,7 @@ export function useUpload() { 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: null, + thumbnail_url: thumbnailUrl, latitude, longitude, location_name: item.locationName || null, diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts index 9eaa86f..979b181 100644 --- a/src/stores/encyclopedia.ts +++ b/src/stores/encyclopedia.ts @@ -7,6 +7,7 @@ import type { CloudType, UserCollection } from '@/types/database' export interface CollectionPreviewCloud { id: string image_url: string + thumbnail_url: string | null captured_at: string | null created_at: string location_name: string | null @@ -31,6 +32,7 @@ function toCollectionCloudMap(rows: Array> | null) { { id: row.id as string, image_url: row.image_url as string, + thumbnail_url: (row.thumbnail_url as string | null) ?? null, 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, @@ -114,7 +116,7 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => { if (firstCloudIds.length) { const { data: firstCloudData, error: firstCloudError } = await supabase .from('clouds') - .select('id,image_url,captured_at,created_at,location_name') + .select('id,image_url,thumbnail_url,captured_at,created_at,location_name') .in('id', firstCloudIds) if (firstCloudError) throw firstCloudError diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue index f0c7364..1a208e3 100644 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ b/src/views/encyclopedia/EncyclopediaView.vue @@ -129,7 +129,7 @@ onMounted(async () => { > +import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' +import { supabase } from '@/lib/supabase' +import { useCloudsStore } from '@/stores/clouds' +import type { CloudType } from '@/types/database' + +interface GalleryCloud { + id: string + image_url: string + thumbnail_url: string | null + location_name: string | null + description: string | null + latitude: number | null + longitude: number | null + captured_at: string | null + created_at: string + cloudTypeName: string + cloudTypeRarity: CloudType['rarity'] + username: string +} + +const PAGE_SIZE = 20 + +const cloudsStore = useCloudsStore() + +const loading = ref(true) +const loadingMore = ref(false) +const loadError = ref('') +const galleryItems = ref([]) +const selectedTypeId = ref('all') +const selectedCloud = ref(null) +const sentinel = ref(null) +const totalLoaded = ref(0) +const hasMore = ref(true) + +let observer: IntersectionObserver | null = null + +const rarityMeta = { + common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' }, + uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' }, + rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' }, +} satisfies Record + +function formatDateTime(iso: string | null) { + if (!iso) return '未知时间' + return new Date(iso).toLocaleString('zh-CN', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatUploadTime(cloud: GalleryCloud) { + return formatDateTime(cloud.created_at) +} + +function formatCapturedTime(cloud: GalleryCloud) { + return formatDateTime(cloud.captured_at) +} + +function formatCoordinate(value: number | null) { + return value === null ? '未记录' : value.toFixed(2) +} + +function toGalleryCloud(row: Record) { + const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : [] + const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : [] + const cloudType = cloudTypes[0] as Record | undefined + const profile = profiles[0] as Record | undefined + + return { + id: row.id as string, + image_url: row.image_url as string, + thumbnail_url: (row.thumbnail_url as string | null) ?? null, + location_name: (row.location_name as string | null) ?? null, + description: (row.description as string | null) ?? null, + latitude: (row.latitude as number | null) ?? null, + longitude: (row.longitude as number | null) ?? null, + captured_at: (row.captured_at as string | null) ?? null, + created_at: row.created_at as string, + cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知', + cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common', + username: (profile?.username as string) || '匿名', + } satisfies GalleryCloud +} + +async function fetchPage(offset: number) { + let query = supabase + .from('clouds') + .select('id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)') + .eq('status', 'approved') + .eq('is_hidden', false) + .order('created_at', { ascending: false }) + .range(offset, offset + PAGE_SIZE - 1) + + if (selectedTypeId.value !== 'all') { + query = query.eq('cloud_type_id', selectedTypeId.value) + } + + const { data, error } = await query + if (error) throw error + + return ((data || []) as Array>).map(toGalleryCloud) +} + +async function loadInitial() { + loading.value = true + loadError.value = '' + galleryItems.value = [] + totalLoaded.value = 0 + hasMore.value = true + + try { + const firstPage = await fetchPage(0) + galleryItems.value = firstPage + totalLoaded.value = firstPage.length + hasMore.value = firstPage.length === PAGE_SIZE + } catch (error) { + loadError.value = error instanceof Error ? error.message : '画廊加载失败' + } finally { + loading.value = false + await nextTick() + setupObserver() + } +} + +async function loadMore() { + if (loading.value || loadingMore.value || !hasMore.value) return + + loadingMore.value = true + try { + const nextPage = await fetchPage(totalLoaded.value) + galleryItems.value = [...galleryItems.value, ...nextPage] + totalLoaded.value += nextPage.length + hasMore.value = nextPage.length === PAGE_SIZE + } catch (error) { + loadError.value = error instanceof Error ? error.message : '加载更多失败' + } finally { + loadingMore.value = false + } +} + +function setupObserver() { + observer?.disconnect() + if (!sentinel.value) return + + observer = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) { + loadMore() + } + }, { + rootMargin: '320px 0px', + }) + + observer.observe(sentinel.value) +} + +function openDetail(cloud: GalleryCloud) { + selectedCloud.value = cloud +} + +function closeDetail() { + selectedCloud.value = null +} + +const filterTabs = computed(() => [ + { id: 'all' as const, label: '全部' }, + ...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })), +]) + +onMounted(async () => { + await cloudsStore.fetchCloudTypes() + await loadInitial() +}) + +watch(selectedTypeId, async () => { + selectedCloud.value = null + await loadInitial() +}) + +watch(sentinel, () => { + if (!loading.value) setupObserver() +}) + +onUnmounted(() => { + observer?.disconnect() +}) diff --git a/src/views/map/MapView.vue b/src/views/map/MapView.vue index ff067fe..85199f7 100644 --- a/src/views/map/MapView.vue +++ b/src/views/map/MapView.vue @@ -1,5 +1,6 @@