diff --git a/.env b/.env index e6379bfd..d84b55be 100644 --- a/.env +++ b/.env @@ -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" diff --git a/README.en.md b/README.en.md index 2bd8bc9a..3eb05698 100644 --- a/README.en.md +++ b/README.en.md @@ -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 | diff --git a/README.md b/README.md index a18be6ec..f5c96e31 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ HuLa 是一款基于 Tauri、Vite 7、Vue 3 和 TypeScript 构建的即时通讯 | 📤 | 消息转发 |  | | 📋 | 群公告功能 |  | | 🏷️ | 备注昵称管理 |  | +| 📍 | 获取和发送位置 |  | ### 🎨 界面体验 | 功能 | 描述 | 状态 | diff --git a/src/components/rightBox/chatBox/ChatFooter.vue b/src/components/rightBox/chatBox/ChatFooter.vue index bcd209a5..2fa02edd 100644 --- a/src/components/rightBox/chatBox/ChatFooter.vue +++ b/src/components/rightBox/chatBox/ChatFooter.vue @@ -133,6 +133,12 @@ 语音信息 + + + + + 位置 + @@ -150,6 +156,12 @@ + + + @@ -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(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 diff --git a/src/components/rightBox/location/LocationMap.vue b/src/components/rightBox/location/LocationMap.vue new file mode 100644 index 00000000..820afc1b --- /dev/null +++ b/src/components/rightBox/location/LocationMap.vue @@ -0,0 +1,132 @@ + + + + emit('map-ready')"> + + + + + + + + + diff --git a/src/components/rightBox/location/LocationModal.vue b/src/components/rightBox/location/LocationModal.vue new file mode 100644 index 00000000..6ab07a82 --- /dev/null +++ b/src/components/rightBox/location/LocationModal.vue @@ -0,0 +1,247 @@ + + + + + + + + + + + + {{ modalTitle }} + + + + + + + + + + + + + 取消 + 重试 + + + + + + + + + + + 取消 + 重试 + + + + + + + + + + + + + {{ locationState.loading ? '正在获取位置...' : '地图加载中...' }} + + + + (mapLoading = false)" + @map-error="handleMapError" /> + + + + + + 当前位置 + + {{ selectedLocation.address || '获取地址中...' }} + + + 坐标: {{ selectedLocation.latitude.toFixed(6) }}, {{ selectedLocation.longitude.toFixed(6) }} + + + + + + + + 发送位置 + + + + + + + + diff --git a/src/hooks/useDownload.ts b/src/hooks/useDownload.ts index 4c480bba..972ddfdd 100644 --- a/src/hooks/useDownload.ts +++ b/src/hooks/useDownload.ts @@ -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) diff --git a/src/hooks/useGeolocation.ts b/src/hooks/useGeolocation.ts new file mode 100644 index 00000000..e0a95bd7 --- /dev/null +++ b/src/hooks/useGeolocation.ts @@ -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({ + 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 => { + 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 => { + 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 + } +} diff --git a/src/main.ts b/src/main.ts index 3d2d47c0..14811b2e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) diff --git a/src/services/mapApi.ts b/src/services/mapApi.ts new file mode 100644 index 00000000..9c391036 --- /dev/null +++ b/src/services/mapApi.ts @@ -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 => { + 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 => { + // 验证坐标范围 + 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 => { + // 验证坐标范围 + 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 + } +} diff --git a/src/typings/components.d.ts b/src/typings/components.d.ts index 73dc3d85..cd811aac 100644 --- a/src/typings/components.d.ts +++ b/src/typings/components.d.ts @@ -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']
{{ locationState.loading ? '正在获取位置...' : '地图加载中...' }}