feat: improve image browsing experience
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
imageUrl: string
|
||||||
|
imageAlt: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
badgeLabel?: string
|
||||||
|
badgeClass?: string
|
||||||
|
panelWidth?: string
|
||||||
|
}>(), {
|
||||||
|
subtitle: '',
|
||||||
|
badgeLabel: '',
|
||||||
|
badgeClass: '',
|
||||||
|
panelWidth: '20rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
'--detail-panel-width': props.panelWidth,
|
||||||
|
}) as Record<string, string>)
|
||||||
|
|
||||||
|
const imageClass = computed(() => {
|
||||||
|
return collapsed.value
|
||||||
|
? 'block h-full w-auto max-w-full object-contain lg:max-w-[92vw]'
|
||||||
|
: 'block h-full w-auto max-w-full object-contain lg:max-w-[calc(92vw-var(--detail-panel-width))]'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.open, open => {
|
||||||
|
if (open) {
|
||||||
|
collapsed.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative h-[80vh] w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl lg:flex"
|
||||||
|
:style="rootStyle"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex min-h-0 flex-1 items-center justify-center overflow-hidden lg:flex-none">
|
||||||
|
<img :src="imageUrl" :alt="imageAlt" :class="imageClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative h-[38vh] transition-[width] duration-300 lg:h-full lg:shrink-0"
|
||||||
|
:class="collapsed ? 'lg:w-0' : 'lg:w-[var(--detail-panel-width)]'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="togglePanel"
|
||||||
|
class="absolute left-0 top-1/2 z-10 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
|
||||||
|
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||||
|
:title="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0"
|
||||||
|
:class="collapsed ? 'lg:border-l-0' : 'lg:border-l'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">{{ title }}</h2>
|
||||||
|
<span
|
||||||
|
v-if="badgeLabel"
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="badgeClass"
|
||||||
|
>
|
||||||
|
{{ badgeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="close"
|
||||||
|
class="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -31,6 +31,8 @@ export interface UploadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nextId = 0
|
let nextId = 0
|
||||||
|
const THUMBNAIL_MAX_EDGE = 640
|
||||||
|
const THUMBNAIL_QUALITY = 0.72
|
||||||
|
|
||||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -41,6 +43,84 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadImageElement(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
const image = new Image()
|
||||||
|
|
||||||
|
image.onload = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
resolve(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
image.onerror = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
reject(new Error('图片读取失败'))
|
||||||
|
}
|
||||||
|
|
||||||
|
image.src = objectUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResizedDimensions(width: number, height: number, maxEdge: number) {
|
||||||
|
const largestEdge = Math.max(width, height)
|
||||||
|
if (largestEdge <= maxEdge) {
|
||||||
|
return { width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = maxEdge / largestEdge
|
||||||
|
return {
|
||||||
|
width: Math.round(width * scale),
|
||||||
|
height: Math.round(height * scale),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileBaseName(name: string) {
|
||||||
|
const idx = name.lastIndexOf('.')
|
||||||
|
return idx === -1 ? name : name.slice(0, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderJpegFile(
|
||||||
|
image: HTMLImageElement,
|
||||||
|
sourceFile: File,
|
||||||
|
maxEdge: number,
|
||||||
|
quality: number,
|
||||||
|
suffix = '',
|
||||||
|
) {
|
||||||
|
const { width, height } = getResizedDimensions(image.naturalWidth || image.width, image.naturalHeight || image.height, maxEdge)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('图片压缩失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
context.drawImage(image, 0, 0, width, height)
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>(resolve => {
|
||||||
|
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error('图片压缩失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new File([blob], `${fileBaseName(sourceFile.name)}${suffix}.jpg`, {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUploadAssets(file: File) {
|
||||||
|
const image = await loadImageElement(file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnailFile: await renderJpegFile(image, file, THUMBNAIL_MAX_EDGE, THUMBNAIL_QUALITY, '-thumb'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||||
const view = new DataView(buffer)
|
const view = new DataView(buffer)
|
||||||
if (view.getUint16(0, false) !== 0xffd8) return null
|
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||||
@@ -253,18 +333,32 @@ export function useUpload() {
|
|||||||
|
|
||||||
overallProgress.value = Math.round(i / items.value.length * 100)
|
overallProgress.value = Math.round(i / items.value.length * 100)
|
||||||
|
|
||||||
const ext = item.file.name.split('.').pop() || 'jpg'
|
|
||||||
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||||
const imagePath = `${basePath}.${ext}`
|
const imagePath = `${basePath}.${ext}`
|
||||||
|
const thumbnailPath = `${basePath}-thumb.jpg`
|
||||||
|
const { thumbnailFile } = await createUploadAssets(item.file)
|
||||||
|
|
||||||
const { error: imgError } = await supabase.storage
|
const { error: imgError } = await supabase.storage
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.upload(imagePath, item.file, { upsert: false })
|
.upload(imagePath, item.file, {
|
||||||
|
upsert: false,
|
||||||
|
contentType: item.file.type,
|
||||||
|
})
|
||||||
if (imgError) throw imgError
|
if (imgError) throw imgError
|
||||||
|
|
||||||
|
const { error: thumbError } = await supabase.storage
|
||||||
|
.from('clouds')
|
||||||
|
.upload(thumbnailPath, thumbnailFile, {
|
||||||
|
upsert: false,
|
||||||
|
contentType: thumbnailFile.type,
|
||||||
|
})
|
||||||
|
if (thumbError) throw thumbError
|
||||||
|
|
||||||
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||||
|
|
||||||
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||||
|
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
|
||||||
|
|
||||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||||
@@ -276,7 +370,7 @@ export function useUpload() {
|
|||||||
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
||||||
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
||||||
image_url: imageUrl,
|
image_url: imageUrl,
|
||||||
thumbnail_url: null,
|
thumbnail_url: thumbnailUrl,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
location_name: item.locationName || null,
|
location_name: item.locationName || null,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { CloudType, UserCollection } from '@/types/database'
|
|||||||
export interface CollectionPreviewCloud {
|
export interface CollectionPreviewCloud {
|
||||||
id: string
|
id: string
|
||||||
image_url: string
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
captured_at: string | null
|
captured_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
location_name: string | null
|
location_name: string | null
|
||||||
@@ -31,6 +32,7 @@ function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
|
|||||||
{
|
{
|
||||||
id: row.id as string,
|
id: row.id as string,
|
||||||
image_url: row.image_url as string,
|
image_url: row.image_url as string,
|
||||||
|
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||||
captured_at: (row.captured_at as string | null) ?? null,
|
captured_at: (row.captured_at as string | null) ?? null,
|
||||||
created_at: row.created_at as string,
|
created_at: row.created_at as string,
|
||||||
location_name: (row.location_name as string | null) ?? null,
|
location_name: (row.location_name as string | null) ?? null,
|
||||||
@@ -114,7 +116,7 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|||||||
if (firstCloudIds.length) {
|
if (firstCloudIds.length) {
|
||||||
const { data: firstCloudData, error: firstCloudError } = await supabase
|
const { data: firstCloudData, error: firstCloudError } = await supabase
|
||||||
.from('clouds')
|
.from('clouds')
|
||||||
.select('id,image_url,captured_at,created_at,location_name')
|
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
|
||||||
.in('id', firstCloudIds)
|
.in('id', firstCloudIds)
|
||||||
|
|
||||||
if (firstCloudError) throw firstCloudError
|
if (firstCloudError) throw firstCloudError
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
||||||
:src="getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
||||||
:alt="cloudType.name"
|
:alt="cloudType.name"
|
||||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
|
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
|
||||||
|
|||||||
@@ -1,9 +1,325 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
interface GalleryCloud {
|
||||||
|
id: string
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
cloudTypeName: string
|
||||||
|
cloudTypeRarity: CloudType['rarity']
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const galleryItems = ref<GalleryCloud[]>([])
|
||||||
|
const selectedTypeId = ref<number | 'all'>('all')
|
||||||
|
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||||
|
const sentinel = ref<HTMLDivElement | null>(null)
|
||||||
|
const totalLoaded = ref(0)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
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' },
|
||||||
|
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||||
|
|
||||||
|
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 formatUploadTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapturedTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.captured_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGalleryCloud(row: Record<string, unknown>) {
|
||||||
|
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||||
|
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||||
|
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
|
||||||
|
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
image_url: row.image_url as string,
|
||||||
|
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||||
|
location_name: (row.location_name as string | null) ?? null,
|
||||||
|
description: (row.description as string | null) ?? null,
|
||||||
|
latitude: (row.latitude as number | null) ?? null,
|
||||||
|
longitude: (row.longitude as number | null) ?? null,
|
||||||
|
captured_at: (row.captured_at as string | null) ?? null,
|
||||||
|
created_at: row.created_at as string,
|
||||||
|
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||||
|
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||||
|
username: (profile?.username as string) || '匿名',
|
||||||
|
} satisfies GalleryCloud
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPage(offset: number) {
|
||||||
|
let query = supabase
|
||||||
|
.from('clouds')
|
||||||
|
.select('id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
return ((data || []) as Array<Record<string, unknown>>).map(toGalleryCloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitial() {
|
||||||
|
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
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(cloud: GalleryCloud) {
|
||||||
|
selectedCloud.value = cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedCloud.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterTabs = computed(() => [
|
||||||
|
{ id: 'all' as const, label: '全部' },
|
||||||
|
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await cloudsStore.fetchCloudTypes()
|
||||||
|
await loadInitial()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTypeId, async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadInitial()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sentinel, () => {
|
||||||
|
if (!loading.value) setupObserver()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">🖼️ 画廊</h1>
|
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
||||||
<p class="text-gray-500">画廊功能开发中...</p>
|
<div class="max-w-7xl mx-auto px-4 py-10">
|
||||||
|
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Community Gallery</p>
|
||||||
|
<h1 class="mt-3 text-4xl font-bold text-slate-900">云图画廊</h1>
|
||||||
|
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
按上传时间倒序浏览社区云图。卡片采用 Instagram 风格的整齐宫格排布,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
<button
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
type="button"
|
||||||
|
@click="selectedTypeId = tab.id"
|
||||||
|
class="shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
:class="selectedTypeId === tab.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="loadError" class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="loading" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<div v-for="n in 8" :key="n" class="aspect-square animate-pulse rounded-[26px] bg-slate-200"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="galleryItems.length" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
<button
|
||||||
|
v-for="cloud in galleryItems"
|
||||||
|
:key="cloud.id"
|
||||||
|
type="button"
|
||||||
|
@click="openDetail(cloud)"
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-[26px] bg-slate-200 text-left shadow-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="cloud.thumbnail_url || cloud.image_url"
|
||||||
|
:alt="cloud.cloudTypeName"
|
||||||
|
class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/8 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||||
|
<div class="rounded-2xl bg-black/28 px-3 py-3 backdrop-blur-[2px]">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
||||||
|
<span class="shrink-0 rounded-full border border-white/20 bg-white/10 px-2 py-0.5 text-[11px]">
|
||||||
|
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 truncate text-xs text-white/78">📷 {{ cloud.username }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-white/78">🕐 {{ formatUploadTime(cloud) }}</p>
|
||||||
|
<p class="mt-1 truncate text-xs text-white/65">{{ cloud.location_name || '未填写位置' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="mt-6 rounded-[28px] border border-dashed border-slate-300 bg-white px-6 py-12 text-center">
|
||||||
|
<p class="text-xl font-semibold text-slate-900">还没有符合条件的云图</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div ref="sentinel" class="h-10"></div>
|
||||||
|
|
||||||
|
<div v-if="loadingMore" class="flex justify-center py-4">
|
||||||
|
<div class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-500 shadow-sm">
|
||||||
|
正在加载更多云图...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="loadMore"
|
||||||
|
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
手动加载更多
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedCloud"
|
||||||
|
:open="!!selectedCloud"
|
||||||
|
:image-url="selectedCloud.image_url"
|
||||||
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
|
:title="selectedCloud.cloudTypeName"
|
||||||
|
:subtitle="`上传者:${selectedCloud.username}`"
|
||||||
|
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||||
|
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||||
|
@close="closeDetail"
|
||||||
|
>
|
||||||
|
<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">{{ formatUploadTime(selectedCloud) }}</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">{{ formatCapturedTime(selectedCloud) }}</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">{{ selectedCloud.location_name || '未填写位置名称' }}</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(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.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">
|
||||||
|
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ImageDetailModal>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+124
-24
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ interface CloudMarkerData {
|
|||||||
latitude: number
|
latitude: number
|
||||||
longitude: number
|
longitude: number
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
locationName: string | null
|
||||||
|
description: string | null
|
||||||
cloudTypeName: string
|
cloudTypeName: string
|
||||||
rarity: 'common' | 'uncommon' | 'rare'
|
rarity: 'common' | 'uncommon' | 'rare'
|
||||||
username: string
|
username: string
|
||||||
@@ -20,12 +24,17 @@ const previewCloud = ref<CloudMarkerData | null>(null)
|
|||||||
const satelliteOn = ref(false)
|
const satelliteOn = ref(false)
|
||||||
const statusText = ref('加载中...')
|
const statusText = ref('加载中...')
|
||||||
|
|
||||||
|
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||||
|
const MIN_MARKER_OPACITY = 0.3
|
||||||
|
|
||||||
let AMapLib: typeof AMap | null = null
|
let AMapLib: typeof AMap | null = null
|
||||||
let mapInst: AMap.Map | null = null
|
let mapInst: AMap.Map | null = null
|
||||||
let satLayer: AMap.TileLayer | null = null
|
let satLayer: AMap.TileLayer | null = null
|
||||||
let roadLayer: AMap.TileLayer | null = null
|
let roadLayer: AMap.TileLayer | null = null
|
||||||
let mks: AMap.Marker[] = []
|
let mks: AMap.Marker[] = []
|
||||||
let hoverIW: AMap.InfoWindow | null = null
|
let hoverIW: AMap.InfoWindow | null = null
|
||||||
|
let allClouds: CloudMarkerData[] = []
|
||||||
|
let redrawTimer: number | null = null
|
||||||
|
|
||||||
const rarityColors: Record<string, string> = {
|
const rarityColors: Record<string, string> = {
|
||||||
common: '#cbd5e1',
|
common: '#cbd5e1',
|
||||||
@@ -33,6 +42,12 @@ const rarityColors: Record<string, string> = {
|
|||||||
rare: '#c084fc',
|
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 {
|
function timeAgo(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
const m = Math.floor(diff / 60000)
|
const m = Math.floor(diff / 60000)
|
||||||
@@ -45,25 +60,54 @@ function timeAgo(iso: string): string {
|
|||||||
return new Date(iso).toLocaleDateString('zh-CN')
|
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 {
|
function bubbleHtml(cloud: CloudMarkerData): string {
|
||||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
const hours = (Date.now() - new Date(cloud.createdAt).getTime()) / 36e5
|
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
let opacity = 1
|
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
||||||
if (hours > 24) opacity = 0.35
|
|
||||||
else if (hours > 2) opacity = 0.6
|
|
||||||
else if (hours > 0.5) opacity = 0.85
|
|
||||||
return `<div style="opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0">
|
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">
|
<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>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoverCardHtml(cloud: CloudMarkerData): string {
|
function hoverCardHtml(cloud: CloudMarkerData): string {
|
||||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
|
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
|
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">
|
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="padding:8px 10px">
|
||||||
<div style="display:flex;align-items:center;gap:6px">
|
<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:13px;font-weight:600;color:#1f2937">${cloud.cloudTypeName}</span>
|
||||||
@@ -94,7 +138,7 @@ function hideHoverCard() {
|
|||||||
async function loadClouds(): Promise<CloudMarkerData[]> {
|
async function loadClouds(): Promise<CloudMarkerData[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('clouds')
|
.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('status', 'approved')
|
||||||
.eq('is_hidden', false)
|
.eq('is_hidden', false)
|
||||||
.not('latitude', 'is', null)
|
.not('latitude', 'is', null)
|
||||||
@@ -118,6 +162,9 @@ async function loadClouds(): Promise<CloudMarkerData[]> {
|
|||||||
latitude: r.latitude as number,
|
latitude: r.latitude as number,
|
||||||
longitude: r.longitude as number,
|
longitude: r.longitude as number,
|
||||||
imageUrl: r.image_url as string,
|
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) || '未知',
|
cloudTypeName: (ct?.name as string) || (r.custom_cloud_type as string) || '未知',
|
||||||
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
|
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
|
||||||
username: (pf?.username as string) || '匿名',
|
username: (pf?.username as string) || '匿名',
|
||||||
@@ -128,6 +175,13 @@ async function loadClouds(): Promise<CloudMarkerData[]> {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleClouds(clouds: CloudMarkerData[]) {
|
||||||
|
return clouds.filter(cloud => {
|
||||||
|
const opacity = getMarkerOpacity(cloud)
|
||||||
|
return opacity !== null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function clearMarkers() {
|
function clearMarkers() {
|
||||||
hideHoverCard()
|
hideHoverCard()
|
||||||
for (const m of mks) m.setMap(null)
|
for (const m of mks) m.setMap(null)
|
||||||
@@ -137,7 +191,14 @@ function clearMarkers() {
|
|||||||
function drawMarkers(clouds: CloudMarkerData[]) {
|
function drawMarkers(clouds: CloudMarkerData[]) {
|
||||||
if (!AMapLib) return
|
if (!AMapLib) return
|
||||||
clearMarkers()
|
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({
|
const m = new AMapLib.Marker({
|
||||||
position: [c.longitude, c.latitude],
|
position: [c.longitude, c.latitude],
|
||||||
content: bubbleHtml(c),
|
content: bubbleHtml(c),
|
||||||
@@ -146,7 +207,9 @@ function drawMarkers(clouds: CloudMarkerData[]) {
|
|||||||
} as AMap.MarkerOptions)
|
} as AMap.MarkerOptions)
|
||||||
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
|
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
|
||||||
m.on('mouseout', () => hideHoverCard())
|
m.on('mouseout', () => hideHoverCard())
|
||||||
m.on('click', () => { previewCloud.value = c })
|
m.on('click', () => {
|
||||||
|
previewCloud.value = c
|
||||||
|
})
|
||||||
m.setMap(mapInst!)
|
m.setMap(mapInst!)
|
||||||
mks.push(m)
|
mks.push(m)
|
||||||
}
|
}
|
||||||
@@ -154,7 +217,15 @@ function drawMarkers(clouds: CloudMarkerData[]) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
statusText.value = '加载中...'
|
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() {
|
function toggleSat() {
|
||||||
@@ -191,12 +262,17 @@ onMounted(async () => {
|
|||||||
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '10px', top: '80px' } } 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() })
|
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
||||||
await refresh()
|
await refresh()
|
||||||
|
startMarkerDecayTimer()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (redrawTimer !== null) {
|
||||||
|
window.clearInterval(redrawTimer)
|
||||||
|
redrawTimer = null
|
||||||
|
}
|
||||||
mapInst?.destroy()
|
mapInst?.destroy()
|
||||||
mapInst = null
|
mapInst = null
|
||||||
AMapLib = 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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
<ImageDetailModal
|
||||||
<div v-if="previewCloud" class="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-6" @click="previewCloud = null">
|
v-if="previewCloud"
|
||||||
<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>
|
:open="!!previewCloud"
|
||||||
<div class="max-w-4xl max-h-full flex flex-col items-center" @click.stop>
|
:image-url="previewCloud.imageUrl"
|
||||||
<img :src="previewCloud.imageUrl" alt="" class="max-w-full max-h-[75vh] rounded-lg object-contain" />
|
:image-alt="previewCloud.cloudTypeName"
|
||||||
<div class="mt-4 text-white text-center">
|
:title="previewCloud.cloudTypeName"
|
||||||
<div class="flex items-center justify-center gap-2 mb-1">
|
:subtitle="`上传者:${previewCloud.username}`"
|
||||||
<span class="text-lg font-semibold">{{ previewCloud.cloudTypeName }}</span>
|
:badge-label="rarityMeta[previewCloud.rarity].label"
|
||||||
<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>
|
:badge-class="rarityMeta[previewCloud.rarity].chip"
|
||||||
</div>
|
@close="previewCloud = null"
|
||||||
<div class="text-sm text-white/70">📷 {{ previewCloud.username }} · 🕐 {{ timeAgo(previewCloud.capturedAt) }}</div>
|
>
|
||||||
</div>
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user