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
+6
View File
@@ -18,6 +18,12 @@
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/supabase-postgres-best-practices/SKILL.md", "skillPath": "skills/supabase-postgres-best-practices/SKILL.md",
"computedHash": "292c93e5a86e2429204bc37abe26b3c9023c4760eb02418462887f2082f118ce" "computedHash": "292c93e5a86e2429204bc37abe26b3c9023c4760eb02418462887f2082f118ce"
},
"web-design-reviewer": {
"source": "github/awesome-copilot",
"sourceType": "github",
"skillPath": "skills/web-design-reviewer/SKILL.md",
"computedHash": "10c6fad8b4f01dbeae969009d81d6c306c8a96d56077cad9bfe2a73a38f5dcbe"
} }
} }
} }
+1 -1
View File
@@ -187,7 +187,7 @@ onBeforeUnmount(() => {
<button <button
type="button" type="button"
@click="close" @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="关闭大图" aria-label="关闭大图"
title="关闭大图" title="关闭大图"
> >
+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' }, 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 }> } 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 totalTypes = computed(() => encyclopediaStore.cloudTypes.length || 10)
const unlockedCount = computed(() => (authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0)) const unlockedCount = computed(() => (authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0))
const progressText = computed(() => `${unlockedCount.value}/${totalTypes.value}`) const progressText = computed(() => `${unlockedCount.value}/${totalTypes.value}`)
const currentProgressColor = computed(() => progressColor(authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0))
function isUnlocked(cloudTypeId: number) { function isUnlocked(cloudTypeId: number) {
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId) return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
@@ -80,9 +96,12 @@ onMounted(async () => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm text-slate-500">当前进度</p> <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>
<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 }}% {{ authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0 }}%
</div> </div>
</div> </div>
@@ -92,8 +111,9 @@ onMounted(async () => {
type="line" type="line"
:show-indicator="false" :show-indicator="false"
:percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0" :percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0"
color="{stops:['#0ea5e9','#f59e0b']}" :color="{ stops: ['#0ea5e9', currentProgressColor] }"
:height="12" :height="12"
rail-color="#e2e8f0"
/> />
<p class="mt-4 text-sm text-slate-500"> <p class="mt-4 text-sm text-slate-500">
+100 -80
View File
@@ -32,7 +32,7 @@ interface GalleryCloud {
username: string username: string
} }
const PAGE_SIZE = 20 const PAGE_SIZE = 50
const authStore = useAuthStore() const authStore = useAuthStore()
const cloudsStore = useCloudsStore() const cloudsStore = useCloudsStore()
@@ -40,21 +40,19 @@ const profileStore = useProfileStore()
const message = useMessage() const message = useMessage()
const loading = ref(true) const loading = ref(true)
const loadingMore = ref(false)
const loadError = ref('') const loadError = ref('')
const galleryItems = ref<GalleryCloud[]>([]) const galleryItems = ref<GalleryCloud[]>([])
const selectedTypeId = ref<number | 'all'>('all') const selectedTypeId = ref<number | 'all'>('all')
const searchQuery = ref('') const searchQuery = ref('')
const selectedCloud = ref<GalleryCloud | null>(null) const selectedCloud = ref<GalleryCloud | null>(null)
const sentinel = ref<HTMLDivElement | null>(null) const currentPage = ref(1)
const totalLoaded = ref(0) const totalCount = ref(0)
const hasMore = ref(true) const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
const manageError = ref('') const manageError = ref('')
const editModalOpen = ref(false) const editModalOpen = ref(false)
const editSaving = ref(false) const editSaving = ref(false)
const editInitialValue = ref<CloudEditFormValue | null>(null) const editInitialValue = ref<CloudEditFormValue | null>(null)
let observer: IntersectionObserver | null = null
let searchTimer: number | null = null let searchTimer: number | null = null
const rarityMeta = { const rarityMeta = {
@@ -144,95 +142,92 @@ function toGalleryCloud(row: Record<string, unknown>) {
} satisfies GalleryCloud } satisfies GalleryCloud
} }
async function fetchPage(offset: number) { async function resolveSearchFilters() {
const search = normalizedSearch.value const search = normalizedSearch.value
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : '' const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : '' 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) : [] const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
if (isUserSearch.value && !usernameTerm) return [] return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
if (search && !isUserSearch.value && !cloudTypeTerm) return [] }
if (usernameTerm && userIds.length === 0) return []
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 let query = supabase
.from('clouds') .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('status', 'approved')
.eq('is_hidden', false) .eq('is_hidden', false)
.order('created_at', { ascending: false })
.range(offset, offset + PAGE_SIZE - 1)
if (selectedTypeId.value !== 'all') { if (selectedTypeId.value !== 'all') {
query = query.eq('cloud_type_id', selectedTypeId.value) query = query.eq('cloud_type_id', selectedTypeId.value)
} }
if (usernameTerm) { return query
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 async function loadPage(page: number) {
if (error) throw error
return ((data || []) as Array<Record<string, unknown>>).map(toGalleryCloud)
}
async function loadInitial() {
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''
galleryItems.value = []
totalLoaded.value = 0
hasMore.value = true
try { try {
const firstPage = await fetchPage(0) const filters = await resolveSearchFilters()
galleryItems.value = firstPage if (filters === null) {
totalLoaded.value = firstPage.length galleryItems.value = []
hasMore.value = firstPage.length === PAGE_SIZE 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) { } catch (error) {
loadError.value = error instanceof Error ? error.message : '画廊加载失败' loadError.value = error instanceof Error ? error.message : '画廊加载失败'
} finally { } finally {
loading.value = false loading.value = false
await nextTick()
setupObserver()
} }
} }
async function loadMore() { async function goToPage(page: number) {
if (loading.value || loadingMore.value || !hasMore.value) return if (page < 1 || page > totalPages.value || page === currentPage.value) return
await loadPage(page)
loadingMore.value = true window.scrollTo({ top: 0, behavior: 'smooth' })
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) { function openDetail(cloud: GalleryCloud) {
@@ -412,12 +407,12 @@ const filterTabs = computed(() => [
onMounted(async () => { onMounted(async () => {
await cloudsStore.fetchCloudTypes() await cloudsStore.fetchCloudTypes()
await loadInitial() await loadPage(1)
}) })
watch(selectedTypeId, async () => { watch(selectedTypeId, async () => {
selectedCloud.value = null selectedCloud.value = null
await loadInitial() await loadPage(1)
}) })
watch(searchQuery, () => { watch(searchQuery, () => {
@@ -426,20 +421,15 @@ watch(searchQuery, () => {
} }
searchTimer = window.setTimeout(async () => { searchTimer = window.setTimeout(async () => {
selectedCloud.value = null selectedCloud.value = null
await loadInitial() await loadPage(1)
searchTimer = null searchTimer = null
}, 250) }, 250)
}) })
watch(sentinel, () => {
if (!loading.value) setupObserver()
})
onUnmounted(() => { onUnmounted(() => {
if (searchTimer !== null) { if (searchTimer !== null) {
window.clearTimeout(searchTimer) window.clearTimeout(searchTimer)
} }
observer?.disconnect()
}) })
</script> </script>
@@ -581,14 +571,44 @@ onUnmounted(() => {
</NEmpty> </NEmpty>
</section> </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"> <template v-for="page in totalPages" :key="page">
<NButton secondary disabled>正在加载更多云图...</NButton> <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>
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4"> <div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
<NButton secondary strong @click="loadMore">手动加载更多</NButton> {{ totalCount }} 张照片 {{ currentPage }} / {{ totalPages }}
</div> </div>
<ImageDetailModal <ImageDetailModal
+4 -1
View File
@@ -371,11 +371,14 @@ function handleSliderPointerUp() {
sliderDragging.value = false sliderDragging.value = false
} }
function toggleTimelineControls() { async function toggleTimelineControls() {
timelineControlsOpen.value = !timelineControlsOpen.value timelineControlsOpen.value = !timelineControlsOpen.value
if (!timelineControlsOpen.value) { if (!timelineControlsOpen.value) {
archivePanelOpen.value = false archivePanelOpen.value = false
sliderDragging.value = false sliderDragging.value = false
if (mapMode.value === 'archive') {
await returnRealtime()
}
} }
} }