feat: add gallery pagination, dynamic progress colors, and UX fixes

- Replace infinite scroll with page-based navigation in gallery
- Add dynamic progress color (blue→amber→red) in encyclopedia
- Fix timeline not returning to realtime when closing in archive mode
- Simplify image detail modal close button styling
- Add web-design-reviewer skill
This commit is contained in:
2026-05-24 17:08:39 +08:00
parent 09841c06a6
commit 0e063c3abb
5 changed files with 134 additions and 85 deletions
+23 -3
View File
@@ -20,9 +20,25 @@ const rarityMeta = {
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 to-white' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
function lerpHex(a: string, b: string, t: number) {
const ah = parseInt(a.slice(1), 16)
const bh = parseInt(b.slice(1), 16)
const r = Math.round(((ah >> 16) & 0xff) + (((bh >> 16) & 0xff) - ((ah >> 16) & 0xff)) * t)
const g = Math.round(((ah >> 8) & 0xff) + (((bh >> 8) & 0xff) - ((ah >> 8) & 0xff)) * t)
const bv = Math.round((ah & 0xff) + ((bh & 0xff) - (ah & 0xff)) * t)
return `#${((r << 16) | (g << 8) | bv).toString(16).padStart(6, '0')}`
}
function progressColor(percent: number) {
const t = Math.min(1, Math.max(0, percent / 100))
if (t <= 0.5) return lerpHex('#0ea5e9', '#f59e0b', t * 2)
return lerpHex('#f59e0b', '#ef4444', (t - 0.5) * 2)
}
const totalTypes = computed(() => encyclopediaStore.cloudTypes.length || 10)
const unlockedCount = computed(() => (authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0))
const progressText = computed(() => `${unlockedCount.value}/${totalTypes.value}`)
const currentProgressColor = computed(() => progressColor(authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0))
function isUnlocked(cloudTypeId: number) {
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
@@ -80,9 +96,12 @@ onMounted(async () => {
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500">当前进度</p>
<p class="mt-1 text-3xl font-bold text-slate-900">{{ progressText }}</p>
<p class="mt-1 text-3xl font-bold" :style="{ color: currentProgressColor }">{{ progressText }}</p>
</div>
<div class="flex h-16 w-16 items-center justify-center border border-slate-900 bg-slate-900 text-xl font-semibold text-white">
<div
class="flex h-16 w-16 items-center justify-center border text-xl font-semibold text-white transition-colors duration-500"
:style="{ backgroundColor: currentProgressColor, borderColor: currentProgressColor }"
>
{{ authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0 }}%
</div>
</div>
@@ -92,8 +111,9 @@ onMounted(async () => {
type="line"
:show-indicator="false"
:percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0"
color="{stops:['#0ea5e9','#f59e0b']}"
:color="{ stops: ['#0ea5e9', currentProgressColor] }"
:height="12"
rail-color="#e2e8f0"
/>
<p class="mt-4 text-sm text-slate-500">
+100 -80
View File
@@ -32,7 +32,7 @@ interface GalleryCloud {
username: string
}
const PAGE_SIZE = 20
const PAGE_SIZE = 50
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
@@ -40,21 +40,19 @@ const profileStore = useProfileStore()
const message = useMessage()
const loading = ref(true)
const loadingMore = ref(false)
const loadError = ref('')
const galleryItems = ref<GalleryCloud[]>([])
const selectedTypeId = ref<number | 'all'>('all')
const searchQuery = ref('')
const selectedCloud = ref<GalleryCloud | null>(null)
const sentinel = ref<HTMLDivElement | null>(null)
const totalLoaded = ref(0)
const hasMore = ref(true)
const currentPage = ref(1)
const totalCount = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
const manageError = ref('')
const editModalOpen = ref(false)
const editSaving = ref(false)
const editInitialValue = ref<CloudEditFormValue | null>(null)
let observer: IntersectionObserver | null = null
let searchTimer: number | null = null
const rarityMeta = {
@@ -144,95 +142,92 @@ function toGalleryCloud(row: Record<string, unknown>) {
} satisfies GalleryCloud
}
async function fetchPage(offset: number) {
async function resolveSearchFilters() {
const search = normalizedSearch.value
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
const userIds = usernameTerm ? await fetchUserIdsBySearch(usernameTerm) : []
if (isUserSearch.value && !usernameTerm) return null
if (search && !isUserSearch.value && !cloudTypeTerm) return null
let userIds: string[] = []
if (usernameTerm) {
userIds = await fetchUserIdsBySearch(usernameTerm)
if (userIds.length === 0) return null
}
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
if (isUserSearch.value && !usernameTerm) return []
if (search && !isUserSearch.value && !cloudTypeTerm) return []
if (usernameTerm && userIds.length === 0) return []
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
}
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
let query = supabase
.from('clouds')
.select('id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.select(selectStr, options as Parameters<typeof supabase.from<'clouds'>['select']>[1])
.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)
}
if (usernameTerm) {
query = query.in('user_id', userIds)
} else if (cloudTypeTerm) {
if (matchedCloudTypeIds.length) {
query = query.or(`cloud_type_id.in.(${matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${cloudTypeTerm}*`)
} else {
query = query.ilike('custom_cloud_type', `%${cloudTypeTerm}%`)
}
}
const { data, error } = await query
if (error) throw error
return ((data || []) as Array<Record<string, unknown>>).map(toGalleryCloud)
return query
}
async function loadInitial() {
async function loadPage(page: number) {
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
const filters = await resolveSearchFilters()
if (filters === null) {
galleryItems.value = []
totalCount.value = 0
currentPage.value = 1
loading.value = false
return
}
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
let dataQuery = buildFilteredQuery(FULL_SELECT)
.order('created_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
if (filters.usernameTerm) {
countQuery = countQuery.in('user_id', filters.userIds)
dataQuery = dataQuery.in('user_id', filters.userIds)
} else if (filters.cloudTypeTerm) {
if (filters.matchedCloudTypeIds.length) {
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
countQuery = countQuery.or(orFilter)
dataQuery = dataQuery.or(orFilter)
} else {
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
}
}
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
if (error) throw error
totalCount.value = count ?? 0
galleryItems.value = ((data || []) as Array<Record<string, unknown>>).map(toGalleryCloud)
currentPage.value = page
} 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)
async function goToPage(page: number) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
await loadPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function openDetail(cloud: GalleryCloud) {
@@ -412,12 +407,12 @@ const filterTabs = computed(() => [
onMounted(async () => {
await cloudsStore.fetchCloudTypes()
await loadInitial()
await loadPage(1)
})
watch(selectedTypeId, async () => {
selectedCloud.value = null
await loadInitial()
await loadPage(1)
})
watch(searchQuery, () => {
@@ -426,20 +421,15 @@ watch(searchQuery, () => {
}
searchTimer = window.setTimeout(async () => {
selectedCloud.value = null
await loadInitial()
await loadPage(1)
searchTimer = null
}, 250)
})
watch(sentinel, () => {
if (!loading.value) setupObserver()
})
onUnmounted(() => {
if (searchTimer !== null) {
window.clearTimeout(searchTimer)
}
observer?.disconnect()
})
</script>
@@ -581,14 +571,44 @@ onUnmounted(() => {
</NEmpty>
</section>
<div ref="sentinel" class="h-10"></div>
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
<NButton
secondary
strong
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
上一页
</NButton>
<div v-if="loadingMore" class="flex justify-center py-4">
<NButton secondary disabled>正在加载更多云图...</NButton>
<template v-for="page in totalPages" :key="page">
<NButton
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
secondary
strong
:type="page === currentPage ? 'primary' : 'default'"
@click="goToPage(page)"
>
{{ page }}
</NButton>
<span
v-else-if="page === 2 || page === totalPages - 1"
class="px-1 text-sm text-slate-400"
>...</span>
</template>
<NButton
secondary
strong
:disabled="currentPage >= totalPages"
@click="goToPage(currentPage + 1)"
>
下一页
</NButton>
</div>
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
<NButton secondary strong @click="loadMore">手动加载更多</NButton>
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
{{ totalCount }} 张照片 {{ currentPage }} / {{ totalPages }}
</div>
<ImageDetailModal
+4 -1
View File
@@ -371,11 +371,14 @@ function handleSliderPointerUp() {
sliderDragging.value = false
}
function toggleTimelineControls() {
async function toggleTimelineControls() {
timelineControlsOpen.value = !timelineControlsOpen.value
if (!timelineControlsOpen.value) {
archivePanelOpen.value = false
sliderDragging.value = false
if (mapMode.value === 'archive') {
await returnRealtime()
}
}
}