feat: improve map and upload experience
This commit is contained in:
@@ -119,6 +119,13 @@ export function useUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
for (const item of items.value) {
|
||||||
|
URL.revokeObjectURL(item.preview)
|
||||||
|
}
|
||||||
|
items.value = []
|
||||||
|
}
|
||||||
|
|
||||||
function validateItem(item: UploadItem): boolean {
|
function validateItem(item: UploadItem): boolean {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
if (!item.cloudCategoryId) {
|
if (!item.cloudCategoryId) {
|
||||||
@@ -130,6 +137,14 @@ export function useUpload() {
|
|||||||
if (!item.capturedAt) {
|
if (!item.capturedAt) {
|
||||||
errors.capturedAt = '请选择拍摄时间'
|
errors.capturedAt = '请选择拍摄时间'
|
||||||
}
|
}
|
||||||
|
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
|
||||||
|
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
|
||||||
|
if (hasLat && !hasLng) {
|
||||||
|
errors.longitude = '经纬度必须同时填写'
|
||||||
|
}
|
||||||
|
if (hasLng && !hasLat) {
|
||||||
|
errors.latitude = '经纬度必须同时填写'
|
||||||
|
}
|
||||||
item.errors = errors
|
item.errors = errors
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
}
|
}
|
||||||
@@ -198,6 +213,7 @@ export function useUpload() {
|
|||||||
location_name: item.locationName || null,
|
location_name: item.locationName || null,
|
||||||
description: item.description.trim() || null,
|
description: item.description.trim() || null,
|
||||||
captured_at: item.capturedAt,
|
captured_at: item.capturedAt,
|
||||||
|
status: 'approved',
|
||||||
is_hidden: item.isHidden,
|
is_hidden: item.isHidden,
|
||||||
})
|
})
|
||||||
if (dbError) throw dbError
|
if (dbError) throw dbError
|
||||||
@@ -217,5 +233,5 @@ export function useUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateItem, validateAll, uploadAll }
|
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__'
|
||||||
|
|
||||||
|
type CanvasLikePrototype = {
|
||||||
|
getContext?: (contextId: string, options?: unknown) => unknown
|
||||||
|
[PATCH_FLAG]?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) {
|
||||||
|
const prototype = ctor?.prototype as CanvasLikePrototype | undefined
|
||||||
|
if (!prototype?.getContext || prototype[PATCH_FLAG]) return
|
||||||
|
|
||||||
|
const originalGetContext = prototype.getContext
|
||||||
|
|
||||||
|
prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) {
|
||||||
|
if (contextId === '2d') {
|
||||||
|
const nextOptions =
|
||||||
|
options && typeof options === 'object'
|
||||||
|
? { ...options as Record<string, unknown>, willReadFrequently: true }
|
||||||
|
: { willReadFrequently: true }
|
||||||
|
|
||||||
|
return originalGetContext.call(this, contextId, nextOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalGetContext.call(this, contextId, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
prototype[PATCH_FLAG] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableCanvasReadbackHint() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
patch2DContextOptions(window.HTMLCanvasElement)
|
||||||
|
if (typeof window.OffscreenCanvas !== 'undefined') {
|
||||||
|
patch2DContextOptions(window.OffscreenCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -3,19 +3,23 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { enableCanvasReadbackHint } from './lib/canvas'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
|
enableCanvasReadbackHint()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (window.location.pathname === '/auth/confirm') {
|
if (window.location.pathname === '/auth/confirm') {
|
||||||
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
} else {
|
} else {
|
||||||
authStore.initialize().then(() => {
|
authStore.initialize().then(() => {
|
||||||
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+50
-6
@@ -1,30 +1,69 @@
|
|||||||
declare namespace AMap {
|
declare namespace AMap {
|
||||||
class Map {
|
class Map {
|
||||||
constructor(container: string | HTMLElement, opts?: Record<string, unknown>)
|
constructor(container: string | HTMLElement, opts?: MapOptions)
|
||||||
addControl(control: unknown): void
|
addControl(control: unknown): void
|
||||||
add(layer: unknown): void
|
add(layer: unknown): void
|
||||||
remove(layer: unknown): void
|
remove(layer: unknown): void
|
||||||
destroy(): void
|
destroy(): void
|
||||||
setCenter(center: [number, number]): void
|
setCenter(center: [number, number]): void
|
||||||
setZoom(zoom: number): void
|
setZoom(zoom: number): void
|
||||||
|
setPitch(pitch: number): void
|
||||||
|
setRotation(rotation: number): void
|
||||||
on(event: string, callback: (...args: unknown[]) => void): void
|
on(event: string, callback: (...args: unknown[]) => void): void
|
||||||
off(event: string, callback: (...args: unknown[]) => void): void
|
off(event: string, callback: (...args: unknown[]) => void): void
|
||||||
getCenter(): { lng: number; lat: number }
|
getCenter(): { lng: number; lat: number }
|
||||||
getZoom(): number
|
getZoom(): number
|
||||||
|
getAllOverlays(type?: string): unknown[]
|
||||||
|
addOverlay(overlay: unknown): void
|
||||||
|
removeOverlay(overlay: unknown): void
|
||||||
|
clearMap(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapOptions {
|
||||||
|
viewMode?: '2D' | '3D'
|
||||||
|
pitch?: number
|
||||||
|
rotation?: number
|
||||||
|
zoom?: number
|
||||||
|
center?: [number, number]
|
||||||
|
mapStyle?: string
|
||||||
|
features?: string[]
|
||||||
|
layers?: unknown[]
|
||||||
|
resizeEnable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class Marker {
|
class Marker {
|
||||||
constructor(opts?: Record<string, unknown>)
|
constructor(opts?: MarkerOptions)
|
||||||
on(event: string, callback: (...args: unknown[]) => void): void
|
on(event: string, callback: (...args: unknown[]) => void): void
|
||||||
setPosition(position: [number, number]): void
|
setPosition(position: [number, number]): void
|
||||||
|
getPosition(): { lng: number; lat: number }
|
||||||
|
setContent(content: string): void
|
||||||
|
setOpacity(opacity: number): void
|
||||||
|
setMap(map: Map | null): void
|
||||||
|
remove(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkerOptions {
|
||||||
|
position?: [number, number]
|
||||||
|
content?: string | HTMLElement
|
||||||
|
offset?: Pixel
|
||||||
|
zIndex?: number
|
||||||
|
opacity?: number
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class InfoWindow {
|
class InfoWindow {
|
||||||
constructor(opts?: Record<string, unknown>)
|
constructor(opts?: InfoWindowOptions)
|
||||||
open(map: Map, position: [number, number]): void
|
open(map: Map, position?: [number, number]): void
|
||||||
close(): void
|
close(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InfoWindowOptions {
|
||||||
|
content?: string | HTMLElement
|
||||||
|
offset?: Pixel
|
||||||
|
size?: Size
|
||||||
|
isCustom?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
class Scale {}
|
class Scale {}
|
||||||
class ToolBar {
|
class ToolBar {
|
||||||
constructor(opts?: Record<string, unknown>)
|
constructor(opts?: Record<string, unknown>)
|
||||||
@@ -41,14 +80,19 @@ declare namespace AMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TileLayer {
|
class TileLayer {
|
||||||
static Satellite: new () => unknown
|
static Satellite: new () => TileLayer
|
||||||
static RoadNet: new () => unknown
|
static RoadNet: new () => TileLayer
|
||||||
|
setOpacity(opacity: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
class Pixel {
|
class Pixel {
|
||||||
constructor(x: number, y: number)
|
constructor(x: number, y: number)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Size {
|
||||||
|
constructor(width: number, height: number)
|
||||||
|
}
|
||||||
|
|
||||||
class MarkerCluster {
|
class MarkerCluster {
|
||||||
constructor(map: Map, markers: Marker[], opts?: Record<string, unknown>)
|
constructor(map: Map, markers: Marker[], opts?: Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-4
@@ -1,11 +1,231 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
|
||||||
|
interface CloudMarkerData {
|
||||||
|
id: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
imageUrl: string
|
||||||
|
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('加载中...')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const rarityColors: Record<string, string> = {
|
||||||
|
common: '#cbd5e1',
|
||||||
|
uncommon: '#60a5fa',
|
||||||
|
rare: '#c084fc',
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
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" />
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoverCardHtml(cloud: CloudMarkerData): string {
|
||||||
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
|
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" />
|
||||||
|
<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,latitude,longitude,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,
|
||||||
|
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 clearMarkers() {
|
||||||
|
hideHoverCard()
|
||||||
|
for (const m of mks) m.setMap(null)
|
||||||
|
mks = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkers(clouds: CloudMarkerData[]) {
|
||||||
|
if (!AMapLib) return
|
||||||
|
clearMarkers()
|
||||||
|
for (const c of clouds) {
|
||||||
|
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 = '加载中...'
|
||||||
|
drawMarkers(await loadClouds())
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
} catch (e) {
|
||||||
|
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mapInst?.destroy()
|
||||||
|
mapInst = null
|
||||||
|
AMapLib = null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
|
<div class="relative h-[calc(100vh-4rem)]">
|
||||||
<div class="text-center">
|
<div ref="mapEl" class="w-full h-full"></div>
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">☁️ OpenCloud</h1>
|
|
||||||
<p class="text-gray-500">实时云图地图加载中...</p>
|
<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>
|
</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 }} · 🕐 {{ timeAgo(previewCloud.capturedAt) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
import { useUpload } from '@/composables/useUpload'
|
import { useUpload } from '@/composables/useUpload'
|
||||||
import { loadAMap } from '@/lib/amap'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const cloudsStore = useCloudsStore()
|
const cloudsStore = useCloudsStore()
|
||||||
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateAll, uploadAll } = useUpload()
|
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
|
||||||
|
|
||||||
const activeId = ref<string | null>(null)
|
const activeId = ref<string | null>(null)
|
||||||
const dragOver = ref(false)
|
const dragOver = ref(false)
|
||||||
const successMsg = ref(false)
|
const successMsg = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const locating = ref(false)
|
|
||||||
const locationError = ref('')
|
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
const activeItem = computed(() => {
|
||||||
if (!activeId.value) return null
|
if (!activeId.value) return null
|
||||||
@@ -80,46 +77,21 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocation() {
|
function onLatInput(e: Event) {
|
||||||
if (!activeItem.value) return
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
locating.value = true
|
if (activeItem.value) {
|
||||||
locationError.value = ''
|
activeItem.value.latitude = isNaN(val) ? null : val
|
||||||
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function onLngInput(e: Event) {
|
||||||
const AMap = await loadAMap()
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
const geolocation = new AMap.Geolocation({
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
geolocation.getCurrentPosition(
|
|
||||||
(data: { position: { lng: number; lat: number } }) => {
|
|
||||||
if (activeItem.value) {
|
if (activeItem.value) {
|
||||||
activeItem.value.latitude = data.position.lat
|
activeItem.value.longitude = isNaN(val) ? null : val
|
||||||
activeItem.value.longitude = data.position.lng
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
}
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
() => reject(new Error('AMap error')),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 10000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.latitude = pos.coords.latitude
|
|
||||||
activeItem.value.longitude = pos.coords.longitude
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
locationError.value = '无法获取位置,请确认定位权限已开启,或手动输入'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
locating.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +130,8 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
<!-- 全局隐藏文件输入 -->
|
|
||||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||||
|
|
||||||
<!-- 成功提示 -->
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="text-5xl block mb-4">✅</span>
|
<span class="text-5xl block mb-4">✅</span>
|
||||||
@@ -173,7 +143,6 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
|
||||||
|
|
||||||
<!-- 上传进度 -->
|
|
||||||
<div v-if="uploading" class="mb-6">
|
<div v-if="uploading" class="mb-6">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
||||||
@@ -184,7 +153,6 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="items.length === 0"
|
v-if="items.length === 0"
|
||||||
@dragover.prevent="dragOver = true"
|
@dragover.prevent="dragOver = true"
|
||||||
@@ -199,10 +167,8 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
<p class="text-sm text-gray-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
<p class="text-sm text-gray-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑界面 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<!-- 左侧 -->
|
|
||||||
<div class="flex-shrink-0 w-[480px]">
|
<div class="flex-shrink-0 w-[480px]">
|
||||||
<div v-if="activeItem" class="bg-gray-900 rounded-xl overflow-hidden mb-3">
|
<div v-if="activeItem" class="bg-gray-900 rounded-xl overflow-hidden mb-3">
|
||||||
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
|
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
|
||||||
@@ -232,7 +198,6 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧表单 -->
|
|
||||||
<div class="flex-1 min-w-0" v-if="activeItem">
|
<div class="flex-1 min-w-0" v-if="activeItem">
|
||||||
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -282,29 +247,45 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
|
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 位置 -->
|
<!-- 经纬度 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">位置</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">经纬度</label>
|
||||||
<div class="flex gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
||||||
|
@input="onLatInput"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度(如 39.90)"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
|
||||||
|
@input="onLngInput"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度(如 116.40)"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">选填,纬度范围 -90 ~ 90,经度范围 -180 ~ 180</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 位置名称 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
|
||||||
<input
|
<input
|
||||||
v-model="activeItem.locationName"
|
v-model="activeItem.locationName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="城市或区域名"
|
placeholder="如:北京、成都"
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
@click="getLocation"
|
|
||||||
type="button"
|
|
||||||
:disabled="locating"
|
|
||||||
class="px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors text-sm whitespace-nowrap disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ locating ? '定位中...' : '📍 获取位置' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="locationError" class="text-xs text-red-400 mt-1">{{ locationError }}</p>
|
|
||||||
<p v-else-if="activeItem.latitude && activeItem.longitude" class="text-xs text-gray-400 mt-1">
|
|
||||||
已获取坐标 ({{ activeItem.latitude.toFixed(2) }}, {{ activeItem.longitude.toFixed(2) }})
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片描述 -->
|
<!-- 图片描述 -->
|
||||||
@@ -327,12 +308,11 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
|
||||||
<div class="mt-6 flex items-center justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片,类别和拍摄时间为必填项</p>
|
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片,类别和拍摄时间为必填项</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@click="() => { for (const i of items) URL.revokeObjectURL(i.preview); items = [] }"
|
@click="clearAll()"
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user