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
+124 -24
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase'
import { loadAMap } from '@/lib/amap'
@@ -8,6 +9,9 @@ interface CloudMarkerData {
latitude: number
longitude: number
imageUrl: string
thumbnailUrl: string | null
locationName: string | null
description: string | null
cloudTypeName: string
rarity: 'common' | 'uncommon' | 'rare'
username: string
@@ -20,12 +24,17 @@ const previewCloud = ref<CloudMarkerData | null>(null)
const satelliteOn = ref(false)
const statusText = ref('加载中...')
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
const MIN_MARKER_OPACITY = 0.3
let AMapLib: typeof AMap | null = null
let mapInst: AMap.Map | null = null
let satLayer: AMap.TileLayer | null = null
let roadLayer: AMap.TileLayer | null = null
let mks: AMap.Marker[] = []
let hoverIW: AMap.InfoWindow | null = null
let allClouds: CloudMarkerData[] = []
let redrawTimer: number | null = null
const rarityColors: Record<string, string> = {
common: '#cbd5e1',
@@ -33,6 +42,12 @@ const rarityColors: Record<string, string> = {
rare: '#c084fc',
}
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' },
} as const
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
@@ -45,25 +60,54 @@ function timeAgo(iso: string): string {
return new Date(iso).toLocaleDateString('zh-CN')
}
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 formatCoordinate(value: number | null) {
return value === null ? '未记录' : value.toFixed(2)
}
function getCloudAgeMs(cloud: CloudMarkerData) {
const capturedTime = new Date(cloud.capturedAt).getTime()
const fallbackTime = new Date(cloud.createdAt).getTime()
const targetTime = Number.isNaN(capturedTime) ? fallbackTime : capturedTime
return Date.now() - targetTime
}
function getMarkerOpacity(cloud: CloudMarkerData) {
const ageMs = getCloudAgeMs(cloud)
if (ageMs >= VISIBLE_WINDOW_MS) return null
if (ageMs <= 0) return 1
const progress = ageMs / VISIBLE_WINDOW_MS
return 1 - progress * (1 - MIN_MARKER_OPACITY)
}
function bubbleHtml(cloud: CloudMarkerData): string {
const border = rarityColors[cloud.rarity] || rarityColors.common
const hours = (Date.now() - new Date(cloud.createdAt).getTime()) / 36e5
let opacity = 1
if (hours > 24) opacity = 0.35
else if (hours > 2) opacity = 0.6
else if (hours > 0.5) opacity = 0.85
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
return `<div style="opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0">
<div style="box-sizing:border-box;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,#ffffff 0%,#e8ecf1 100%);border:3px solid ${border};box-shadow:0 4px 16px rgba(0,0,0,0.3),inset 0 2px 4px rgba(255,255,255,0.6),0 0 0 2px rgba(0,0,0,0.06);overflow:hidden">
<img src="${cloud.imageUrl}" style="display:block;width:100%;height:100%;object-fit:cover" />
<img src="${bubbleImage}" style="display:block;width:100%;height:100%;object-fit:cover" />
</div>
</div>`
}
function hoverCardHtml(cloud: CloudMarkerData): string {
const border = rarityColors[cloud.rarity] || rarityColors.common
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
return `<div style="background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif">
<img src="${cloud.imageUrl}" style="display:block;width:100%;height:110px;object-fit:cover" />
<img src="${cardImage}" style="display:block;width:100%;height:110px;object-fit:cover" />
<div style="padding:8px 10px">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:13px;font-weight:600;color:#1f2937">${cloud.cloudTypeName}</span>
@@ -94,7 +138,7 @@ function hideHoverCard() {
async function loadClouds(): Promise<CloudMarkerData[]> {
const { data, error } = await supabase
.from('clouds')
.select('id,image_url,latitude,longitude,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
.eq('status', 'approved')
.eq('is_hidden', false)
.not('latitude', 'is', null)
@@ -118,6 +162,9 @@ async function loadClouds(): Promise<CloudMarkerData[]> {
latitude: r.latitude as number,
longitude: r.longitude as number,
imageUrl: r.image_url as string,
thumbnailUrl: (r.thumbnail_url as string | null) ?? null,
locationName: (r.location_name as string | null) ?? null,
description: (r.description as string | null) ?? null,
cloudTypeName: (ct?.name as string) || (r.custom_cloud_type as string) || '未知',
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
username: (pf?.username as string) || '匿名',
@@ -128,6 +175,13 @@ async function loadClouds(): Promise<CloudMarkerData[]> {
return result
}
function getVisibleClouds(clouds: CloudMarkerData[]) {
return clouds.filter(cloud => {
const opacity = getMarkerOpacity(cloud)
return opacity !== null
})
}
function clearMarkers() {
hideHoverCard()
for (const m of mks) m.setMap(null)
@@ -137,7 +191,14 @@ function clearMarkers() {
function drawMarkers(clouds: CloudMarkerData[]) {
if (!AMapLib) return
clearMarkers()
for (const c of clouds) {
const visibleClouds = getVisibleClouds(clouds)
statusText.value = `${visibleClouds.length} 朵云(近 2 小时)`
if (previewCloud.value && !visibleClouds.some(item => item.id === previewCloud.value?.id)) {
previewCloud.value = null
}
for (const c of visibleClouds) {
const m = new AMapLib.Marker({
position: [c.longitude, c.latitude],
content: bubbleHtml(c),
@@ -146,7 +207,9 @@ function drawMarkers(clouds: CloudMarkerData[]) {
} as AMap.MarkerOptions)
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
m.on('mouseout', () => hideHoverCard())
m.on('click', () => { previewCloud.value = c })
m.on('click', () => {
previewCloud.value = c
})
m.setMap(mapInst!)
mks.push(m)
}
@@ -154,7 +217,15 @@ function drawMarkers(clouds: CloudMarkerData[]) {
async function refresh() {
statusText.value = '加载中...'
drawMarkers(await loadClouds())
allClouds = await loadClouds()
drawMarkers(allClouds)
}
function startMarkerDecayTimer() {
if (redrawTimer !== null) window.clearInterval(redrawTimer)
redrawTimer = window.setInterval(() => {
drawMarkers(allClouds)
}, 60 * 1000)
}
function toggleSat() {
@@ -191,12 +262,17 @@ onMounted(async () => {
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '10px', top: '80px' } } as Record<string, unknown>))
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
await refresh()
startMarkerDecayTimer()
} catch (e) {
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
}
})
onUnmounted(() => {
if (redrawTimer !== null) {
window.clearInterval(redrawTimer)
redrawTimer = null
}
mapInst?.destroy()
mapInst = null
AMapLib = null
@@ -212,20 +288,44 @@ onUnmounted(() => {
<button @click="toggleSat" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'"><span class="text-lg">{{ satelliteOn ? '🗺️' : '🛰️' }}</span></button>
</div>
<Teleport to="body">
<div v-if="previewCloud" class="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-6" @click="previewCloud = null">
<button @click="previewCloud = null" class="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/20 hover:bg-white/30 text-white text-xl flex items-center justify-center"></button>
<div class="max-w-4xl max-h-full flex flex-col items-center" @click.stop>
<img :src="previewCloud.imageUrl" alt="" class="max-w-full max-h-[75vh] rounded-lg object-contain" />
<div class="mt-4 text-white text-center">
<div class="flex items-center justify-center gap-2 mb-1">
<span class="text-lg font-semibold">{{ previewCloud.cloudTypeName }}</span>
<span class="text-xs px-2 py-0.5 rounded-full border" :style="{ color: rarityColors[previewCloud.rarity] || rarityColors.common, borderColor: rarityColors[previewCloud.rarity] || rarityColors.common }">{{ previewCloud.rarity === 'common' ? '常见' : previewCloud.rarity === 'uncommon' ? '少见' : '罕见' }}</span>
</div>
<div class="text-sm text-white/70">📷 {{ previewCloud.username }} &middot; 🕐 {{ timeAgo(previewCloud.capturedAt) }}</div>
</div>
<ImageDetailModal
v-if="previewCloud"
:open="!!previewCloud"
:image-url="previewCloud.imageUrl"
:image-alt="previewCloud.cloudTypeName"
:title="previewCloud.cloudTypeName"
:subtitle="`上传者:${previewCloud.username}`"
:badge-label="rarityMeta[previewCloud.rarity].label"
:badge-class="rarityMeta[previewCloud.rarity].chip"
@close="previewCloud = null"
>
<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">{{ formatDateTime(previewCloud.createdAt) }}</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">{{ formatDateTime(previewCloud.capturedAt) }}</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">{{ previewCloud.locationName || '未填写位置名称' }}</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(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
</p>
</div>
</div>
</Teleport>
<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">
{{ previewCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
</ImageDetailModal>
</div>
</template>