feat: improve image browsing experience

This commit is contained in:
2026-05-21 19:56:42 +08:00
parent 7504cce7f1
commit 6d8acce295
6 changed files with 661 additions and 32 deletions
+319 -3
View File
@@ -1,9 +1,325 @@
<script setup lang="ts">
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<GalleryCloud[]>([])
const selectedTypeId = ref<number | 'all'>('all')
const selectedCloud = ref<GalleryCloud | null>(null)
const sentinel = ref<HTMLDivElement | null>(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<CloudType['rarity'], { label: string; chip: string }>
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<string, unknown>) {
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<string, unknown> | undefined
const profile = profiles[0] as Record<string, unknown> | 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<Record<string, unknown>>).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()
})
</script>
<template>
<div class="max-w-5xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold text-gray-900 mb-8">🖼 画廊</h1>
<p class="text-gray-500">画廊功能开发中...</p>
<div>
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
<div class="max-w-7xl mx-auto px-4 py-10">
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Community Gallery</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">云图画廊</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
按上传时间倒序浏览社区云图卡片采用 Instagram 风格的整齐宫格排布悬停即可快速查看基本信息点开可看大图和详细记录
</p>
</div>
</section>
<div class="max-w-7xl mx-auto px-4 py-8">
<section>
<div class="flex gap-3 overflow-x-auto pb-2">
<button
v-for="tab in filterTabs"
:key="tab.id"
type="button"
@click="selectedTypeId = tab.id"
class="shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-colors"
:class="selectedTypeId === tab.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'"
>
{{ tab.label }}
</button>
</div>
</section>
<div v-if="loadError" class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ loadError }}
</div>
<section v-if="loading" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<div v-for="n in 8" :key="n" class="aspect-square animate-pulse rounded-[26px] bg-slate-200"></div>
</section>
<section v-else-if="galleryItems.length" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
<button
v-for="cloud in galleryItems"
:key="cloud.id"
type="button"
@click="openDetail(cloud)"
class="group relative aspect-square overflow-hidden rounded-[26px] bg-slate-200 text-left shadow-sm"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
:alt="cloud.cloudTypeName"
class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]"
/>
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/8 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<div class="rounded-2xl bg-black/28 px-3 py-3 backdrop-blur-[2px]">
<div class="flex items-center justify-between gap-3">
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
<span class="shrink-0 rounded-full border border-white/20 bg-white/10 px-2 py-0.5 text-[11px]">
{{ rarityMeta[cloud.cloudTypeRarity].label }}
</span>
</div>
<p class="mt-2 truncate text-xs text-white/78">📷 {{ cloud.username }}</p>
<p class="mt-1 truncate text-xs text-white/78">🕐 {{ formatUploadTime(cloud) }}</p>
<p class="mt-1 truncate text-xs text-white/65">{{ cloud.location_name || '未填写位置' }}</p>
</div>
</div>
</button>
</section>
<section v-else class="mt-6 rounded-[28px] border border-dashed border-slate-300 bg-white px-6 py-12 text-center">
<p class="text-xl font-semibold text-slate-900">还没有符合条件的云图</p>
<p class="mt-2 text-sm text-slate-500">换个云型筛选试试或者等社区上传更多作品</p>
</section>
<div ref="sentinel" class="h-10"></div>
<div v-if="loadingMore" class="flex justify-center py-4">
<div class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-500 shadow-sm">
正在加载更多云图...
</div>
</div>
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
<button
type="button"
@click="loadMore"
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:text-slate-900"
>
手动加载更多
</button>
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
:image-url="selectedCloud.image_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`上传者:${selectedCloud.username}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
@close="closeDetail"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
</p>
</div>
</div>
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
</ImageDetailModal>
</div>
</div>
</template>