mirror of
https://github.com/HuLaSpark/HuLa.git
synced 2026-09-17 17:40:43 +08:00
feat(location): ✨ add connect tencent map api
This commit is contained in:
@@ -1,7 +1,2 @@
|
||||
# gitee token
|
||||
VITE_GITEE_TOKEN="a9029798336825cea39ac9e4413b8579"
|
||||
|
||||
# 腾讯地图API密钥
|
||||
# 请到腾讯位置服务官网申请:https://lbs.qq.com/
|
||||
# 注意:如果未开启WebserviceAPI功能,系统会自动使用本地算法进行坐标转换
|
||||
VITE_TENCENT_MAP_KEY="PFOBZ-34XCC-J6N2D-AX7BX-6QUQ3-5KF4G"
|
||||
|
||||
@@ -214,6 +214,7 @@ HuLa is an instant messaging system built with Tauri, Vite 7, Vue 3, and TypeScr
|
||||
| 📤 | Message Forwarding |  |
|
||||
| 📋 | Group Announcements |  |
|
||||
| 🏷️ | Nickname & Remark Management |  |
|
||||
| 📍 | Get and Send Location |  |
|
||||
|
||||
### 🎨 User Experience
|
||||
| Feature | Description | Status |
|
||||
|
||||
@@ -215,6 +215,7 @@ HuLa 是一款基于 Tauri、Vite 7、Vue 3 和 TypeScript 构建的即时通讯
|
||||
| 📤 | 消息转发 |  |
|
||||
| 📋 | 群公告功能 |  |
|
||||
| 🏷️ | 备注昵称管理 |  |
|
||||
| 📍 | 获取和发送位置 |  |
|
||||
|
||||
### 🎨 界面体验
|
||||
| 功能 | 描述 | 状态 |
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
</template>
|
||||
<span>语音信息</span>
|
||||
</n-popover>
|
||||
<n-popover trigger="hover" :show-arrow="false" placement="bottom">
|
||||
<template #trigger>
|
||||
<svg @click="showLocationModal = true" class="mr-18px"><use href="#local"></use></svg>
|
||||
</template>
|
||||
<span>位置</span>
|
||||
</n-popover>
|
||||
</n-flex>
|
||||
|
||||
<n-popover trigger="hover" :show-arrow="false" placement="bottom">
|
||||
@@ -150,6 +156,12 @@
|
||||
<MsgInput ref="MsgInputRef" :height="inputAreaHeight" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 位置选择弹窗 -->
|
||||
<LocationModal
|
||||
v-model:visible="showLocationModal"
|
||||
@location-selected="handleLocationSelected"
|
||||
@cancel="showLocationModal = false" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -158,6 +170,7 @@ import { join } from '@tauri-apps/api/path'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, readFile } from '@tauri-apps/plugin-fs'
|
||||
import { FOOTER_HEIGHT, MAX_FOOTER_HEIGHT, MIN_FOOTER_HEIGHT, TOOLBAR_HEIGHT } from '@/common/constants'
|
||||
import LocationModal from '@/components/rightBox/location/LocationModal.vue'
|
||||
import { MittEnum, MsgEnum, RoomTypeEnum } from '@/enums'
|
||||
import { useChatLayoutGlobal } from '@/hooks/useChatLayout'
|
||||
import { type SelectionRange, useCommon } from '@/hooks/useCommon.ts'
|
||||
@@ -187,6 +200,7 @@ const MsgInputRef = ref()
|
||||
const msgInputDom = ref<HTMLInputElement | null>(null)
|
||||
const emojiShow = ref(false)
|
||||
const recentlyTip = ref(false)
|
||||
const showLocationModal = ref(false)
|
||||
const isConceal = computed({
|
||||
get: () => settingStore.screenshot.isConceal,
|
||||
set: (value: boolean) => settingStore.setScreenshotConceal(value)
|
||||
@@ -565,6 +579,28 @@ const handleVoiceRecord = () => {
|
||||
useMitt.emit(MittEnum.VOICE_RECORD_TOGGLE)
|
||||
}
|
||||
|
||||
// 处理位置选择
|
||||
const handleLocationSelected = async (locationData: any) => {
|
||||
try {
|
||||
// 发送位置消息
|
||||
const messageContent = {
|
||||
type: MsgEnum.LOCATION,
|
||||
body: {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
address: locationData.address,
|
||||
precision: locationData.precision,
|
||||
timestamp: locationData.timestamp
|
||||
}
|
||||
}
|
||||
console.log('发送位置消息:', messageContent)
|
||||
|
||||
showLocationModal.value = false
|
||||
} catch (error) {
|
||||
console.error('发送位置消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开聊天记录窗口
|
||||
const openChatHistory = async () => {
|
||||
const currentRoomId = globalStore.currentSession?.roomId
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="relative size-full">
|
||||
<!-- 地图容器 -->
|
||||
<TlbsMap
|
||||
ref="mapRef"
|
||||
:api-key="apiKey"
|
||||
:center="mapCenter"
|
||||
:zoom="zoom"
|
||||
:map-type-id="'vector'"
|
||||
:control="mapControl"
|
||||
:style="{ height: `${height}px` }"
|
||||
@map_inited="() => emit('map-ready')">
|
||||
<!-- 位置标记 -->
|
||||
<TlbsMultiMarker
|
||||
id="location-marker"
|
||||
:styles="markerStyles"
|
||||
:geometries="markerGeometries"
|
||||
@click="handleMarkerClick"
|
||||
@dragend="handleMarkerDragEnd" />
|
||||
</TlbsMap>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
type LocationData = {
|
||||
latitude: number
|
||||
longitude: number
|
||||
address?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type LocationMapProps = {
|
||||
location: LocationData
|
||||
zoom?: number
|
||||
height?: number
|
||||
draggable?: boolean
|
||||
}
|
||||
|
||||
type LocationMapEmits = {
|
||||
'location-change': [location: { lat: number; lng: number }]
|
||||
'map-ready': []
|
||||
'map-error': [error: string]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<LocationMapProps>(), {
|
||||
zoom: 15,
|
||||
height: 300,
|
||||
draggable: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<LocationMapEmits>()
|
||||
|
||||
// 地图实例和状态
|
||||
const mapRef = ref()
|
||||
|
||||
// 计算属性
|
||||
const apiKey = computed(() => import.meta.env.VITE_TENCENT_MAP_KEY)
|
||||
const mapCenter = computed(() => ({
|
||||
lat: props.location.latitude,
|
||||
lng: props.location.longitude
|
||||
}))
|
||||
|
||||
// 地图配置
|
||||
const mapControl = {
|
||||
scale: true,
|
||||
zoom: false,
|
||||
mapType: false
|
||||
}
|
||||
|
||||
const markerStyles = {
|
||||
'current-location': {
|
||||
width: 25,
|
||||
height: 35,
|
||||
anchor: { x: 12, y: 35 },
|
||||
color: '#FF4444'
|
||||
}
|
||||
}
|
||||
|
||||
const markerGeometries = computed(() => [
|
||||
{
|
||||
id: 'current',
|
||||
styleId: 'current-location',
|
||||
position: mapCenter.value,
|
||||
properties: { title: '当前位置' },
|
||||
draggable: props.draggable
|
||||
}
|
||||
])
|
||||
|
||||
// 事件处理
|
||||
const handleMarkerClick = (event: any) => {
|
||||
if (props.draggable) {
|
||||
emit('location-change', {
|
||||
lat: event.latLng.lat,
|
||||
lng: event.latLng.lng
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkerDragEnd = (event: any) => {
|
||||
emit('location-change', {
|
||||
lat: event.geometry.position.lat,
|
||||
lng: event.geometry.position.lng
|
||||
})
|
||||
}
|
||||
|
||||
// 响应式监听
|
||||
watch(
|
||||
() => props.location,
|
||||
(newLocation) => {
|
||||
if (mapRef.value?.map && newLocation) {
|
||||
mapRef.value.map.setCenter({
|
||||
lat: newLocation.latitude,
|
||||
lng: newLocation.longitude
|
||||
})
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.zoom,
|
||||
(newZoom) => {
|
||||
if (mapRef.value?.map) {
|
||||
mapRef.value.map.setZoom(newZoom)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<n-modal v-model:show="modalVisible" :mask-closable="false" class="rounded-8px" transform-origin="center">
|
||||
<div class="h-full w-480px bg-[--bg-edit] box-border flex flex-col items-center justify-between">
|
||||
<!-- 标题栏 -->
|
||||
<n-flex :size="6" vertical class="w-full">
|
||||
<div
|
||||
v-if="isMac()"
|
||||
@click="modalVisible = false"
|
||||
class="mac-close size-13px shadow-inner bg-#ed6a5eff rounded-50% mt-6px select-none absolute left-6px">
|
||||
<svg class="hidden size-7px color-#000 select-none absolute top-3px left-3px">
|
||||
<use href="#close"></use>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<n-flex class="text-(14px [--text-color]) select-none pt-6px" justify="center">{{ modalTitle }}</n-flex>
|
||||
|
||||
<svg
|
||||
v-if="isWindows()"
|
||||
class="size-14px cursor-pointer pt-6px select-none absolute right-6px"
|
||||
@click="modalVisible = false">
|
||||
<use href="#close"></use>
|
||||
</svg>
|
||||
<span class="h-1px w-full bg-[--line-color]"></span>
|
||||
</n-flex>
|
||||
|
||||
<!-- 地图加载错误 -->
|
||||
<div v-if="mapError" class="h-340px flex-center">
|
||||
<n-result status="error" title="地图加载失败" :description="mapError">
|
||||
<template #footer>
|
||||
<n-flex justify="center" :size="12">
|
||||
<n-button secondary @click="modalVisible = false">取消</n-button>
|
||||
<n-button type="primary" secondary @click="retryMapLoad">重试</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
|
||||
<!-- 位置获取失败 -->
|
||||
<div v-else-if="locationState.error && !selectedLocation" class="h-340px flex-center">
|
||||
<n-result status="warning" title="位置获取失败" :description="locationState.error">
|
||||
<template #footer>
|
||||
<n-flex justify="center" :size="12">
|
||||
<n-button secondary @click="modalVisible = false">取消</n-button>
|
||||
<n-button type="primary" secondary @click="relocate">重试</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
|
||||
<!-- 地图容器 -->
|
||||
<div v-else class="flex flex-col gap-16px p-8px">
|
||||
<!-- 地图区域 -->
|
||||
<div class="relative rounded-8px overflow-hidden flex-center h-340px">
|
||||
<!-- 地图加载中 -->
|
||||
<div v-if="locationState.loading || mapLoading" class="flex-col-center gap-42px">
|
||||
<n-spin :size="42" />
|
||||
<p class="text-(14px [--text-cplor])">{{ locationState.loading ? '正在获取位置...' : '地图加载中...' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 地图组件 -->
|
||||
<LocationMap
|
||||
v-else-if="selectedLocation"
|
||||
:location="selectedLocation"
|
||||
:zoom="18"
|
||||
:height="340"
|
||||
@location-change="handleLocationChange"
|
||||
@map-ready="() => (mapLoading = false)"
|
||||
@map-error="handleMapError" />
|
||||
</div>
|
||||
|
||||
<!-- 位置信息显示 -->
|
||||
<div v-if="selectedLocation" class="rounded-6px bg-#fefefe dark:bg-#303030 p-12px">
|
||||
<n-flex vertical :size="8">
|
||||
<span class="text-14px font-medium">当前位置</span>
|
||||
<div class="text-12px text-gray-500">
|
||||
{{ selectedLocation.address || '获取地址中...' }}
|
||||
</div>
|
||||
<div class="text-11px text-gray-400">
|
||||
坐标: {{ selectedLocation.latitude.toFixed(6) }}, {{ selectedLocation.longitude.toFixed(6) }}
|
||||
</div>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<n-flex v-if="showActionButtons" align="center" :size="24" class="py-8px">
|
||||
<n-button type="primary" secondary :loading="sendingLocation" @click="handleConfirm">发送位置</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGeolocation } from '@/hooks/useGeolocation'
|
||||
import { reverseGeocode } from '@/services/mapApi'
|
||||
import { isMac, isWindows } from '@/utils/PlatformConstants'
|
||||
import LocationMap from './LocationMap.vue'
|
||||
|
||||
type LocationData = {
|
||||
latitude: number
|
||||
longitude: number
|
||||
address?: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type LocationModalProps = {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
type LocationModalEmits = {
|
||||
'update:visible': [visible: boolean]
|
||||
'location-selected': [location: LocationData]
|
||||
cancel: []
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<LocationModalProps>(), {
|
||||
visible: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<LocationModalEmits>()
|
||||
|
||||
// 地理位置Hook
|
||||
const { state: locationState, getLocationWithTransform } = useGeolocation()
|
||||
|
||||
// 响应式状态
|
||||
const modalVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value: boolean) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const selectedLocation = ref<LocationData | null>(null)
|
||||
const mapLoading = ref(false)
|
||||
const mapError = ref<string | null>(null)
|
||||
const sendingLocation = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const modalTitle = computed(() => {
|
||||
if (mapError.value) return '地图错误'
|
||||
if (locationState.error) return '位置获取失败'
|
||||
return '选择位置'
|
||||
})
|
||||
|
||||
const showActionButtons = computed(() => {
|
||||
return !mapLoading.value && !locationState.loading && selectedLocation.value !== null && !mapError.value
|
||||
})
|
||||
|
||||
// 获取位置
|
||||
const getLocation = async () => {
|
||||
try {
|
||||
mapError.value = null
|
||||
|
||||
const result = await getLocationWithTransform({
|
||||
enableHighAccuracy: true
|
||||
})
|
||||
|
||||
// 获取地址信息
|
||||
const geocodeResult = await reverseGeocode(result.transformed.lat, result.transformed.lng).catch((error) => {
|
||||
console.warn('获取地址失败:', error)
|
||||
return null
|
||||
})
|
||||
const address = geocodeResult?.formatted_addresses?.recommend || geocodeResult?.address || '未知地址'
|
||||
|
||||
selectedLocation.value = {
|
||||
latitude: result.transformed.lat,
|
||||
longitude: result.transformed.lng,
|
||||
address,
|
||||
timestamp: result.timestamp
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听弹窗显示
|
||||
watch(modalVisible, (visible) => {
|
||||
if (visible) {
|
||||
// 重置状态
|
||||
selectedLocation.value = null
|
||||
mapError.value = null
|
||||
mapLoading.value = false
|
||||
|
||||
// 获取位置
|
||||
getLocation()
|
||||
}
|
||||
})
|
||||
|
||||
// 重新定位
|
||||
const relocate = async () => {
|
||||
selectedLocation.value = null
|
||||
mapError.value = null
|
||||
await getLocation()
|
||||
}
|
||||
|
||||
// 重试地图加载
|
||||
const retryMapLoad = () => {
|
||||
mapError.value = null
|
||||
mapLoading.value = true
|
||||
// 触发地图重新加载
|
||||
if (selectedLocation.value) {
|
||||
// 重新创建地图组件
|
||||
const currentLocation = selectedLocation.value
|
||||
selectedLocation.value = null
|
||||
nextTick(() => {
|
||||
selectedLocation.value = currentLocation
|
||||
mapLoading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地图事件处理
|
||||
const handleLocationChange = async (newLocation: { lat: number; lng: number }) => {
|
||||
if (!selectedLocation.value) return
|
||||
|
||||
// 获取新位置的地址
|
||||
const geocodeResult = await reverseGeocode(newLocation.lat, newLocation.lng).catch((error) => {
|
||||
console.warn('获取地址失败:', error)
|
||||
return null
|
||||
})
|
||||
const address =
|
||||
geocodeResult?.formatted_addresses?.recommend || geocodeResult?.address || selectedLocation.value.address
|
||||
|
||||
selectedLocation.value = {
|
||||
...selectedLocation.value,
|
||||
latitude: newLocation.lat,
|
||||
longitude: newLocation.lng,
|
||||
address,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMapError = (error: string) => {
|
||||
mapError.value = error
|
||||
mapLoading.value = false
|
||||
}
|
||||
|
||||
// 确认发送位置
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedLocation.value) return
|
||||
|
||||
sendingLocation.value = true
|
||||
emit('location-selected', selectedLocation.value)
|
||||
modalVisible.value = false
|
||||
sendingLocation.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BaseDirectory, exists, mkdir, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { createEventHook } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useDownload = () => {
|
||||
const process = ref(0)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { transformCoordinates } from '@/services/mapApi'
|
||||
|
||||
type GeolocationState = {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
position: GeolocationPosition | null
|
||||
permission: PermissionState | null
|
||||
precision: 'high' | 'low'
|
||||
}
|
||||
|
||||
type GeolocationOptions = {
|
||||
enableHighAccuracy?: boolean
|
||||
timeout?: number
|
||||
maximumAge?: number
|
||||
}
|
||||
|
||||
export const useGeolocation = () => {
|
||||
const state = ref<GeolocationState>({
|
||||
loading: false,
|
||||
error: null,
|
||||
position: null,
|
||||
permission: null,
|
||||
precision: 'high'
|
||||
})
|
||||
|
||||
const isSupported = computed(() => 'geolocation' in navigator)
|
||||
const hasPermission = computed(() => state.value.permission === 'granted')
|
||||
const isLoading = computed(() => state.value.loading)
|
||||
const error = computed(() => state.value.error)
|
||||
const currentPosition = computed(() => state.value.position)
|
||||
|
||||
// 检查权限状态
|
||||
const checkPermission = async (): Promise<PermissionState> => {
|
||||
if ('permissions' in navigator) {
|
||||
try {
|
||||
const permission = await navigator.permissions.query({ name: 'geolocation' })
|
||||
state.value.permission = permission.state
|
||||
return permission.state
|
||||
} catch (error) {
|
||||
console.warn('检查地理位置权限失败:', error)
|
||||
}
|
||||
}
|
||||
return 'prompt'
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
const getCurrentPosition = async (options?: GeolocationOptions): Promise<GeolocationPosition> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('浏览器不支持地理位置功能'))
|
||||
return
|
||||
}
|
||||
|
||||
const defaultOptions: PositionOptions = {
|
||||
enableHighAccuracy: state.value.precision === 'high',
|
||||
timeout: 10000,
|
||||
maximumAge: 300000, // 5分钟缓存
|
||||
...options
|
||||
}
|
||||
|
||||
state.value.loading = true
|
||||
state.value.error = null
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
state.value.loading = false
|
||||
state.value.position = position
|
||||
resolve(position)
|
||||
},
|
||||
(error) => {
|
||||
state.value.loading = false
|
||||
let errorMessage = '获取位置失败'
|
||||
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
errorMessage = '位置权限被拒绝'
|
||||
break
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
errorMessage = '位置信息不可用'
|
||||
break
|
||||
case error.TIMEOUT:
|
||||
errorMessage = '获取位置超时'
|
||||
break
|
||||
}
|
||||
|
||||
state.value.error = errorMessage
|
||||
reject(new Error(errorMessage))
|
||||
},
|
||||
defaultOptions
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取位置并转换坐标
|
||||
const getLocationWithTransform = async (options?: GeolocationOptions) => {
|
||||
const position = await getCurrentPosition(options)
|
||||
const { latitude, longitude } = position.coords
|
||||
|
||||
// 转换坐标
|
||||
const transformed = await transformCoordinates(latitude, longitude)
|
||||
|
||||
return {
|
||||
original: { lat: latitude, lng: longitude },
|
||||
transformed,
|
||||
position,
|
||||
address: '', // 后续可以通过逆地理编码获取
|
||||
precision: state.value.precision,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 清除错误状态
|
||||
const clearError = () => {
|
||||
state.value.error = null
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
state: state.value,
|
||||
isSupported,
|
||||
hasPermission,
|
||||
isLoading,
|
||||
error,
|
||||
currentPosition,
|
||||
|
||||
// 方法
|
||||
checkPermission,
|
||||
getCurrentPosition,
|
||||
getLocationWithTransform,
|
||||
clearError
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import 'uno.css'
|
||||
import '@unocss/reset/eric-meyer.css' // unocss提供的浏览器默认样式重置
|
||||
import TlbsMap from 'tlbs-map-vue'
|
||||
import { initMobileClient } from '#/mobile-client/MobileClient'
|
||||
import App from '@/App.vue'
|
||||
import { AppException } from '@/common/exception.ts'
|
||||
@@ -14,7 +15,7 @@ initializePlatform()
|
||||
import('@/services/webSocketAdapter')
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router).use(pinia).directive('resize', vResize).directive('slide', vSlide).mount('#app')
|
||||
app.use(router).use(pinia).use(TlbsMap).directive('resize', vResize).directive('slide', vSlide).mount('#app')
|
||||
app.config.errorHandler = (err) => {
|
||||
if (err instanceof AppException) {
|
||||
window.$message.error(err.message)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { wgs84ToGcj02 } from '@/utils/CoordinateTransform'
|
||||
|
||||
type TransformedCoordinate = {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
type AddressComponent = {
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
street: string
|
||||
street_number: string
|
||||
}
|
||||
|
||||
type ReverseGeocodeResult = {
|
||||
address: string
|
||||
formatted_addresses: {
|
||||
recommend: string
|
||||
rough: string
|
||||
}
|
||||
address_component: AddressComponent
|
||||
ad_info: {
|
||||
nation_code: string
|
||||
adcode: string
|
||||
city_code: string
|
||||
}
|
||||
}
|
||||
|
||||
// JSONP回调函数存储
|
||||
const jsonpCallbacks: { [key: string]: (data: any) => void } = {}
|
||||
|
||||
// 创建JSONP请求
|
||||
const createJsonpRequest = (url: string, callbackName: string): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建script标签
|
||||
const script = document.createElement('script')
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('请求超时'))
|
||||
}, 10000)
|
||||
|
||||
const cleanup = () => {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script)
|
||||
}
|
||||
delete (window as any)[callbackName]
|
||||
delete jsonpCallbacks[callbackName]
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// 设置全局回调函数
|
||||
jsonpCallbacks[callbackName] = (data: any) => {
|
||||
cleanup()
|
||||
resolve(data)
|
||||
}
|
||||
;(window as any)[callbackName] = jsonpCallbacks[callbackName]
|
||||
|
||||
script.onerror = () => {
|
||||
cleanup()
|
||||
reject(new Error('脚本加载失败'))
|
||||
}
|
||||
|
||||
script.src = `${url}&callback=${callbackName}`
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// 坐标系转换(WGS84 -> GCJ-02)
|
||||
export const transformCoordinates = async (lat: number, lng: number): Promise<TransformedCoordinate> => {
|
||||
// 验证坐标范围
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
throw new Error('坐标范围无效')
|
||||
}
|
||||
|
||||
const callbackName = `coordTransform_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
const params = {
|
||||
locations: `${lat},${lng}`,
|
||||
type: '1', // GPS坐标(WGS84)
|
||||
key: import.meta.env.VITE_TENCENT_MAP_KEY || '',
|
||||
output: 'jsonp',
|
||||
from: '1', // 明确指定源坐标系为GPS
|
||||
to: '5' // 明确指定目标坐标系为GCJ02
|
||||
}
|
||||
|
||||
try {
|
||||
const queryString = new URLSearchParams(params).toString()
|
||||
const url = `https://apis.map.qq.com/ws/coord/v1/translate?${queryString}`
|
||||
|
||||
console.log('腾讯地图坐标转换API请求:', { url, params, callbackName })
|
||||
|
||||
// 验证API密钥
|
||||
if (!params.key) {
|
||||
throw new Error('腾讯地图API密钥未配置')
|
||||
}
|
||||
|
||||
const data = await createJsonpRequest(url, callbackName)
|
||||
|
||||
console.log('腾讯地图API响应:', data)
|
||||
|
||||
if (data.status !== 0) {
|
||||
const errorMsg = data.message || `状态码: ${data.status}`
|
||||
throw new Error(`API错误: ${data.status} - ${errorMsg}`)
|
||||
}
|
||||
|
||||
const location = data.locations?.[0]
|
||||
if (!location) {
|
||||
throw new Error('转换结果为空')
|
||||
}
|
||||
|
||||
// 验证返回的坐标
|
||||
if (typeof location.lat !== 'number' || typeof location.lng !== 'number') {
|
||||
throw new Error('API返回的坐标格式无效')
|
||||
}
|
||||
|
||||
const transformed = {
|
||||
lat: location.lat,
|
||||
lng: location.lng
|
||||
}
|
||||
|
||||
console.debug('坐标转换成功:', { original: { lat, lng }, transformed })
|
||||
|
||||
return transformed
|
||||
} catch (error) {
|
||||
console.warn('腾讯地图API坐标转换失败,使用本地算法转换:', error)
|
||||
|
||||
// 降级方案:使用本地坐标转换算法
|
||||
const localTransformed = wgs84ToGcj02(lat, lng)
|
||||
return localTransformed
|
||||
}
|
||||
}
|
||||
|
||||
// 逆地理编码(获取地址信息)
|
||||
export const reverseGeocode = async (lat: number, lng: number): Promise<ReverseGeocodeResult | null> => {
|
||||
// 验证坐标范围
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
throw new Error('坐标范围无效')
|
||||
}
|
||||
|
||||
const callbackName = `geocode_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
|
||||
const params = {
|
||||
location: `${lat},${lng}`,
|
||||
key: import.meta.env.VITE_TENCENT_MAP_KEY || '',
|
||||
get_poi: '1',
|
||||
output: 'jsonp'
|
||||
}
|
||||
|
||||
try {
|
||||
const queryString = new URLSearchParams(params as any).toString()
|
||||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?${queryString}`
|
||||
|
||||
const data = await createJsonpRequest(url, callbackName)
|
||||
|
||||
if (data.status !== 0) {
|
||||
throw new Error(`API错误: ${data.status} - ${data.message || '未知错误'}`)
|
||||
}
|
||||
|
||||
return data.result
|
||||
} catch (error) {
|
||||
console.warn('腾讯地图API逆地理编码失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Vendored
+6
-8
@@ -37,6 +37,12 @@ declare module 'vue' {
|
||||
Image: typeof import('./../components/rightBox/renderMessage/Image.vue')['default']
|
||||
InfoPopover: typeof import('./../components/common/InfoPopover.vue')['default']
|
||||
LoadingSpinner: typeof import('./../components/common/LoadingSpinner.vue')['default']
|
||||
LocationDetailModal: typeof import('./../components/rightBox/location/LocationDetailModal.vue')['default']
|
||||
LocationMap: typeof import('./../components/rightBox/location/LocationMap.vue')['default']
|
||||
LocationMessage: typeof import('./../components/rightBox/location/LocationMessage.vue')['default']
|
||||
LocationModal: typeof import('./../components/rightBox/location/LocationModal.vue')['default']
|
||||
LocationPermission: typeof import('./../components/common/LocationPermission.vue')['default']
|
||||
LocationPrecision: typeof import('./../components/common/LocationPrecision.vue')['default']
|
||||
MeasuredItem: typeof import('./../mobile/components/virtual-scroll/MeasuredItem.vue')['default']
|
||||
MergeMessage: typeof import('./../components/rightBox/renderMessage/MergeMessage.vue')['default']
|
||||
MessageContainer: typeof import('./../mobile/components/chat-room/MessageContainer.vue')['default']
|
||||
@@ -44,7 +50,6 @@ declare module 'vue' {
|
||||
MsgInput: typeof import('./../components/rightBox/MsgInput.vue')['default']
|
||||
MyMessageItem: typeof import('./../mobile/components/my/MyMessageItem.vue')['default']
|
||||
NaiveProvider: typeof import('./../components/common/NaiveProvider.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAutoComplete: typeof import('naive-ui')['NAutoComplete']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NAvatarGroup: typeof import('naive-ui')['NAvatarGroup']
|
||||
@@ -56,7 +61,6 @@ declare module 'vue' {
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
@@ -73,28 +77,23 @@ declare module 'vue' {
|
||||
NIconWrapper: typeof import('naive-ui')['NIconWrapper']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
NInfiniteScroll: typeof import('naive-ui')['NInfiniteScroll']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NPopselect: typeof import('naive-ui')['NPopselect']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NQrCode: typeof import('naive-ui')['NQrCode']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NResult: typeof import('naive-ui')['NResult']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSkeleton: typeof import('naive-ui')['NSkeleton']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NStep: typeof import('naive-ui')['NStep']
|
||||
NSteps: typeof import('naive-ui')['NSteps']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
@@ -121,7 +120,6 @@ declare module 'vue' {
|
||||
SystemMessage: typeof import('./../components/rightBox/renderMessage/special/SystemMessage.vue')['default']
|
||||
Text: typeof import('./../components/rightBox/renderMessage/Text.vue')['default']
|
||||
Validation: typeof import('./../components/common/Validation.vue')['default']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanSwipeCell: typeof import('vant/es')['SwipeCell']
|
||||
VanUploader: typeof import('vant/es')['Uploader']
|
||||
|
||||
Reference in New Issue
Block a user