feat: add map picker for upload coordinates
This commit is contained in:
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useCloudsStore } from '@/stores/clouds'
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
||||||
|
import { loadAMap } from '@/lib/amap'
|
||||||
import { useUpload } from '@/composables/useUpload'
|
import { useUpload } from '@/composables/useUpload'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -17,6 +18,16 @@ const successMsg = ref(false)
|
|||||||
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
|
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
const mapPickerLoading = ref(false)
|
||||||
|
const mapPickerError = ref('')
|
||||||
|
const mapPickerLat = ref<number | null>(null)
|
||||||
|
const mapPickerLng = ref<number | null>(null)
|
||||||
|
const miniMapEl = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
let mapPickerAMap: typeof AMap | null = null
|
||||||
|
let mapPickerInstance: AMap.Map | null = null
|
||||||
|
let mapPickerMarker: AMap.Marker | null = null
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
const activeItem = computed(() => {
|
||||||
if (!activeId.value) return null
|
if (!activeId.value) return null
|
||||||
@@ -58,6 +69,122 @@ function handleRemove(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未选择' : value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
|
||||||
|
activeItem.value.latitude = latitude
|
||||||
|
activeItem.value.longitude = longitude
|
||||||
|
|
||||||
|
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
||||||
|
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyMapPicker() {
|
||||||
|
mapPickerMarker?.setMap(null)
|
||||||
|
mapPickerMarker = null
|
||||||
|
mapPickerInstance?.destroy()
|
||||||
|
mapPickerInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeMapPickerMarker(longitude: number, latitude: number) {
|
||||||
|
if (!mapPickerAMap || !mapPickerInstance) return
|
||||||
|
|
||||||
|
if (!mapPickerMarker) {
|
||||||
|
mapPickerMarker = new mapPickerAMap.Marker({
|
||||||
|
position: [longitude, latitude],
|
||||||
|
title: '拍摄位置',
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
mapPickerMarker.setMap(mapPickerInstance)
|
||||||
|
} else {
|
||||||
|
mapPickerMarker.setPosition([longitude, latitude])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLngLat(event: unknown) {
|
||||||
|
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
||||||
|
const lng = payload?.lnglat?.lng
|
||||||
|
const lat = payload?.lnglat?.lat
|
||||||
|
|
||||||
|
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
||||||
|
return { lng, lat }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapPickerClick(event: unknown) {
|
||||||
|
const picked = extractLngLat(event)
|
||||||
|
if (!picked) return
|
||||||
|
|
||||||
|
mapPickerLng.value = picked.lng
|
||||||
|
mapPickerLat.value = picked.lat
|
||||||
|
placeMapPickerMarker(picked.lng, picked.lat)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMapPicker() {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
|
||||||
|
mapPickerOpen.value = true
|
||||||
|
mapPickerLoading.value = true
|
||||||
|
mapPickerError.value = ''
|
||||||
|
mapPickerLat.value = activeItem.value.latitude
|
||||||
|
mapPickerLng.value = activeItem.value.longitude
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
destroyMapPicker()
|
||||||
|
|
||||||
|
try {
|
||||||
|
mapPickerAMap = await loadAMap()
|
||||||
|
|
||||||
|
const hasCoordinates =
|
||||||
|
typeof activeItem.value.latitude === 'number' &&
|
||||||
|
typeof activeItem.value.longitude === 'number'
|
||||||
|
|
||||||
|
const center: [number, number] = hasCoordinates
|
||||||
|
? [activeItem.value.longitude as number, activeItem.value.latitude as number]
|
||||||
|
: [104.07, 30.67]
|
||||||
|
|
||||||
|
mapPickerInstance = new mapPickerAMap.Map(miniMapEl.value!, {
|
||||||
|
viewMode: '2D',
|
||||||
|
pitch: 0,
|
||||||
|
rotation: 0,
|
||||||
|
zoom: hasCoordinates ? 11 : 4,
|
||||||
|
center,
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
resizeEnable: true,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapPickerInstance.on('click', handleMapPickerClick)
|
||||||
|
|
||||||
|
if (hasCoordinates) {
|
||||||
|
placeMapPickerMarker(activeItem.value.longitude as number, activeItem.value.latitude as number)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
mapPickerError.value = error instanceof Error ? error.message : '地图加载失败'
|
||||||
|
} finally {
|
||||||
|
mapPickerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMapPicker() {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
mapPickerLoading.value = false
|
||||||
|
mapPickerError.value = ''
|
||||||
|
destroyMapPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker() {
|
||||||
|
if (mapPickerLat.value === null || mapPickerLng.value === null) return
|
||||||
|
|
||||||
|
updateActiveCoordinates(mapPickerLat.value, mapPickerLng.value)
|
||||||
|
closeMapPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCoordinates() {
|
||||||
|
updateActiveCoordinates(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
const allValid = validateAll()
|
const allValid = validateAll()
|
||||||
@@ -159,7 +286,10 @@ function onCategoryChange(e: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { cloudsStore.fetchCloudTypes() })
|
onMounted(() => { cloudsStore.fetchCloudTypes() })
|
||||||
onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.preview) })
|
onUnmounted(() => {
|
||||||
|
for (const item of items.value) URL.revokeObjectURL(item.preview)
|
||||||
|
destroyMapPicker()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -350,8 +480,18 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
|
|
||||||
<!-- 经纬度 -->
|
<!-- 经纬度 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">经纬度</label>
|
<div class="mb-1 flex items-center justify-between gap-3">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<label class="block text-sm font-medium text-gray-700">经纬度</label>
|
||||||
|
<button
|
||||||
|
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
||||||
|
type="button"
|
||||||
|
@click="clearCoordinates"
|
||||||
|
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
||||||
@@ -374,8 +514,19 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
/>
|
/>
|
||||||
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
|
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openMapPicker"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none">🗺️</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400 mt-1">选填,纬度范围 -90 ~ 90,经度范围 -180 ~ 180</p>
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">选填,可手动输入,或点击右侧小地图图标选点</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 位置名称 -->
|
<!-- 位置名称 -->
|
||||||
@@ -435,4 +586,78 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="mapPickerOpen"
|
||||||
|
class="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||||
|
@click="closeMapPicker"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full max-w-3xl overflow-hidden rounded-[28px] bg-white shadow-2xl"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between border-b border-gray-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">地图选点</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">点击地图即可回填当前图片的经纬度,也可以继续手动修改。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeMapPicker"
|
||||||
|
class="rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-5">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3 rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
<span>当前选择:</span>
|
||||||
|
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(mapPickerLat) }}</span>
|
||||||
|
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(mapPickerLng) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative overflow-hidden rounded-3xl border border-gray-200 bg-slate-100">
|
||||||
|
<div ref="miniMapEl" class="h-[420px] w-full"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="mapPickerLoading"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
||||||
|
>
|
||||||
|
正在加载地图...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="mapPickerError"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ mapPickerError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||||
|
<p class="text-sm text-gray-500">建议点选大致拍摄位置,上传时会继续做模糊化处理。</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeMapPicker"
|
||||||
|
class="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="mapPickerLat === null || mapPickerLng === null"
|
||||||
|
@click="confirmMapPicker"
|
||||||
|
class="rounded-xl bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
使用这个位置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user