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:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="关闭大图"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user