332 lines
12 KiB
Vue
332 lines
12 KiB
Vue
<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'
|
|
|
|
interface CloudMarkerData {
|
|
id: string
|
|
latitude: number
|
|
longitude: number
|
|
imageUrl: string
|
|
thumbnailUrl: string | null
|
|
locationName: string | null
|
|
description: string | null
|
|
cloudTypeName: string
|
|
rarity: 'common' | 'uncommon' | 'rare'
|
|
username: string
|
|
capturedAt: string
|
|
createdAt: string
|
|
}
|
|
|
|
const mapEl = ref<HTMLDivElement>()
|
|
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',
|
|
uncommon: '#60a5fa',
|
|
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)
|
|
if (m < 1) return '刚刚'
|
|
if (m < 60) return `${m} 分钟前`
|
|
const h = Math.floor(m / 60)
|
|
if (h < 24) return `${h} 小时前`
|
|
const d = Math.floor(h / 24)
|
|
if (d < 30) return `${d} 天前`
|
|
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 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="${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="${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>
|
|
<span style="font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22">${label}</span>
|
|
</div>
|
|
<div style="font-size:11px;color:#9ca3af;margin-top:3px">📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}</div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function showHoverCard(cloud: CloudMarkerData, pos: [number, number]) {
|
|
if (!AMapLib || !mapInst) return
|
|
hoverIW?.close()
|
|
const iw = new AMapLib.InfoWindow({
|
|
content: hoverCardHtml(cloud),
|
|
offset: new AMapLib.Pixel(-100, -60),
|
|
isCustom: true,
|
|
} as AMap.InfoWindowOptions)
|
|
iw.open(mapInst, pos)
|
|
hoverIW = iw
|
|
}
|
|
|
|
function hideHoverCard() {
|
|
hoverIW?.close()
|
|
hoverIW = null
|
|
}
|
|
|
|
async function loadClouds(): Promise<CloudMarkerData[]> {
|
|
const { data, error } = await supabase
|
|
.from('clouds')
|
|
.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)
|
|
.not('longitude', 'is', null)
|
|
.order('created_at', { ascending: false })
|
|
.limit(500)
|
|
|
|
if (error) {
|
|
statusText.value = `查询失败: ${error.message}`
|
|
return []
|
|
}
|
|
|
|
const rows = data as Array<Record<string, unknown>> || []
|
|
statusText.value = `${rows.length} 朵云`
|
|
const result: CloudMarkerData[] = []
|
|
for (const r of rows) {
|
|
const ct = r.cloud_types as Record<string, unknown> | null
|
|
const pf = r.profiles as Record<string, unknown> | null
|
|
result.push({
|
|
id: r.id as string,
|
|
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) || '匿名',
|
|
capturedAt: (r.captured_at as string) || (r.created_at as string),
|
|
createdAt: r.created_at as string,
|
|
})
|
|
}
|
|
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)
|
|
mks = []
|
|
}
|
|
|
|
function drawMarkers(clouds: CloudMarkerData[]) {
|
|
if (!AMapLib) return
|
|
clearMarkers()
|
|
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),
|
|
offset: new AMapLib.Pixel(-23, -23),
|
|
zIndex: 200,
|
|
} as AMap.MarkerOptions)
|
|
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
|
|
m.on('mouseout', () => hideHoverCard())
|
|
m.on('click', () => {
|
|
previewCloud.value = c
|
|
})
|
|
m.setMap(mapInst!)
|
|
mks.push(m)
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
statusText.value = '加载中...'
|
|
allClouds = await loadClouds()
|
|
drawMarkers(allClouds)
|
|
}
|
|
|
|
function startMarkerDecayTimer() {
|
|
if (redrawTimer !== null) window.clearInterval(redrawTimer)
|
|
redrawTimer = window.setInterval(() => {
|
|
drawMarkers(allClouds)
|
|
}, 60 * 1000)
|
|
}
|
|
|
|
function toggleSat() {
|
|
if (!AMapLib || !mapInst) return
|
|
if (satelliteOn.value) {
|
|
if (satLayer) mapInst.remove(satLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
|
if (roadLayer) mapInst.remove(roadLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
|
satelliteOn.value = false
|
|
} else {
|
|
satLayer = satLayer || new AMapLib.TileLayer.Satellite()
|
|
roadLayer = roadLayer || new AMapLib.TileLayer.RoadNet()
|
|
mapInst.add(satLayer as unknown as Parameters<typeof mapInst.add>[0])
|
|
mapInst.add(roadLayer as unknown as Parameters<typeof mapInst.add>[0])
|
|
satelliteOn.value = true
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
AMapLib = await loadAMap()
|
|
mapInst = new AMapLib.Map(mapEl.value!, {
|
|
viewMode: '3D',
|
|
pitch: 0,
|
|
rotation: 0,
|
|
zoom: 5,
|
|
center: [104.07, 30.67],
|
|
mapStyle: 'amap://styles/normal',
|
|
features: ['bg', 'road', 'building', 'point'],
|
|
resizeEnable: true,
|
|
} as AMap.MapOptions)
|
|
|
|
mapInst.addControl(new AMapLib.Scale())
|
|
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
|
|
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
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative h-[calc(100vh-4rem)]">
|
|
<div ref="mapEl" class="w-full h-full"></div>
|
|
|
|
<div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10">
|
|
<button @click="refresh" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" title="刷新"><span class="text-lg">🔄</span></button>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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>
|