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:
@@ -187,7 +187,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border border-white/30 bg-white/15 p-0 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-white/25"
|
||||
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border-0 bg-transparent p-0 text-white/70 shadow-none transition-colors hover:text-white [&_svg]:stroke-[2.5]"
|
||||
aria-label="关闭大图"
|
||||
title="关闭大图"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user