feat: 新增 SGS SDK 数据适配器与腾讯地图服务
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
856
src/components/navigation/OutdoorNavigationPanel.vue
Normal file
856
src/components/navigation/OutdoorNavigationPanel.vue
Normal file
@@ -0,0 +1,856 @@
|
||||
<template>
|
||||
<view
|
||||
v-if="visible"
|
||||
class="outdoor-nav-panel"
|
||||
:class="{ collapsed: isCollapsed }"
|
||||
@touchstart.stop="handlePanelTouchStart"
|
||||
@touchmove.stop="handlePanelTouchMove"
|
||||
@touchend.stop="handlePanelTouchEnd"
|
||||
@mousedown.stop="handlePanelMouseStart"
|
||||
@mousemove.stop="handlePanelMouseMove"
|
||||
@mouseup.stop="handlePanelMouseEnd"
|
||||
>
|
||||
<view class="panel-handle" @tap.stop="toggleCollapsed"></view>
|
||||
|
||||
<view v-if="isCollapsed" class="panel-collapsed" @tap="expandPanel">
|
||||
<view class="panel-collapsed-copy">
|
||||
<text class="panel-title">室外导航</text>
|
||||
<text class="panel-summary">{{ collapsedSummary }}</text>
|
||||
</view>
|
||||
<view class="panel-expand">
|
||||
<text class="panel-expand-text">展开</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="panel-header">
|
||||
<view class="panel-title-group">
|
||||
<text class="panel-title">室外导航</text>
|
||||
<text v-if="routeSummary" class="panel-summary">{{ routeSummary }}</text>
|
||||
</view>
|
||||
<view class="panel-actions">
|
||||
<view class="panel-light-action" @tap="handleBack">
|
||||
<text class="panel-light-action-text">返回</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 起点选择区域 -->
|
||||
<view class="start-point-section">
|
||||
<view class="section-label">
|
||||
<text class="section-label-text">起点</text>
|
||||
</view>
|
||||
<view class="start-point-buttons">
|
||||
<view
|
||||
class="start-btn gps-btn"
|
||||
:class="{ active: effectiveStartMode === 'gps' }"
|
||||
@tap="handleGpsStart"
|
||||
>
|
||||
<text class="start-btn-icon">📍</text>
|
||||
<text class="start-btn-text">GPS定位</text>
|
||||
</view>
|
||||
<view
|
||||
class="start-btn manual-btn"
|
||||
:class="{ active: effectiveStartMode === 'manual' }"
|
||||
@tap="handleManualStart"
|
||||
>
|
||||
<text class="start-btn-icon">✏️</text>
|
||||
<text class="start-btn-text">手动选择</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 手动选择模式:显示搜索框 -->
|
||||
<view v-if="effectiveStartMode === 'manual'" class="start-search-container">
|
||||
<view class="start-search-input-wrapper">
|
||||
<input
|
||||
class="start-search-input"
|
||||
type="text"
|
||||
placeholder="输入起点地址或地名"
|
||||
v-model="startSearchQuery"
|
||||
@input="handleStartSearchInput"
|
||||
/>
|
||||
<view v-if="isSearchingStart" class="start-search-loading">
|
||||
<text class="loading-text">搜索中...</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 搜索结果列表 -->
|
||||
<view v-if="startSearchResults.length > 0" class="start-search-results">
|
||||
<view
|
||||
v-for="(result, index) in startSearchResults"
|
||||
:key="index"
|
||||
class="search-result-item"
|
||||
@tap="handleSelectSearchResult(result)"
|
||||
>
|
||||
<text class="result-title">{{ result.title }}</text>
|
||||
<text class="result-address">{{ result.address }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="effectiveStartPoint" class="start-point-display">
|
||||
<view class="point-dot start"></view>
|
||||
<text class="point-name">{{ formatStartPointName() }}</text>
|
||||
<view class="point-change" @tap="handleManualStart">
|
||||
<text class="point-change-text">修改</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 出行方式选择 -->
|
||||
<view class="travel-mode-section">
|
||||
<text class="section-label-text">出行方式</text>
|
||||
<view class="travel-mode-list">
|
||||
<view
|
||||
v-for="mode in travelModes"
|
||||
:key="mode.type"
|
||||
class="travel-mode-item"
|
||||
:class="{ active: selectedTravelMode === mode.type }"
|
||||
@tap="handleTravelModeChange(mode.type)"
|
||||
>
|
||||
<text class="travel-mode-icon">{{ mode.icon }}</text>
|
||||
<text class="travel-mode-label">{{ mode.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 路线状态 -->
|
||||
<view v-if="loading" class="panel-status">
|
||||
<text class="panel-status-text">规划路线中...</text>
|
||||
</view>
|
||||
<view v-else-if="routeError" class="panel-status">
|
||||
<text class="panel-status-text error">{{ routeError }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="action-buttons">
|
||||
<view
|
||||
v-if="hasRoute"
|
||||
class="nav-btn secondary"
|
||||
@tap="handleClearRoute"
|
||||
>
|
||||
<text class="nav-btn-text">清除路线</text>
|
||||
</view>
|
||||
<view
|
||||
class="nav-btn primary"
|
||||
:class="{ disabled: !canNavigate }"
|
||||
@tap="handleStartNavigation"
|
||||
>
|
||||
<text class="nav-btn-text">{{ navigateButtonText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { TRAVEL_MODE_CONFIG, type TravelMode } from '@/config/museum'
|
||||
|
||||
type StartMode = 'gps' | 'manual'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
loading?: boolean
|
||||
routeError?: string
|
||||
routeDistance?: number
|
||||
routeDuration?: number
|
||||
selectingManualStart?: boolean
|
||||
startPoint?: { latitude: number; longitude: number; name?: string } | null
|
||||
startMode?: 'gps' | 'manual'
|
||||
}>(), {
|
||||
visible: false,
|
||||
loading: false,
|
||||
routeError: '',
|
||||
routeDistance: 0,
|
||||
routeDuration: 0,
|
||||
selectingManualStart: false,
|
||||
startPoint: null,
|
||||
startMode: 'gps'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
gpsStart: []
|
||||
manualStartRequest: []
|
||||
manualStartConfirm: [location: { latitude: number; longitude: number }]
|
||||
manualStartCancel: []
|
||||
travelModeChange: [mode: TravelMode]
|
||||
startNavigation: []
|
||||
clearRoute: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const isCollapsed = ref(false)
|
||||
const localStartMode = ref<StartMode>('gps')
|
||||
const manualStartPoint = ref<{ latitude: number; longitude: number; name?: string } | null>(null)
|
||||
const selectedTravelMode = ref<TravelMode>('walking')
|
||||
const panelTouchStartY = ref(0)
|
||||
const panelTouchCurrentY = ref(0)
|
||||
|
||||
// 起点搜索相关状态
|
||||
const startSearchQuery = ref('')
|
||||
const startSearchResults = ref<Array<{ title: string; address: string; latitude: number; longitude: number }>>([])
|
||||
const isSearchingStart = ref(false)
|
||||
|
||||
const travelModes = Object.values(TRAVEL_MODE_CONFIG)
|
||||
|
||||
// 使用外部传入的 startPoint,兼容本地状态
|
||||
const effectiveStartPoint = computed(() => {
|
||||
return props.startPoint ?? manualStartPoint.value
|
||||
})
|
||||
|
||||
// 外部传入的 startMode 优先级更高
|
||||
const effectiveStartMode = computed(() => {
|
||||
if (props.startPoint) return 'manual'
|
||||
return props.startMode ?? localStartMode.value
|
||||
})
|
||||
|
||||
const hasRoute = computed(() => {
|
||||
return effectiveStartPoint.value !== null && !props.loading && !props.routeError
|
||||
})
|
||||
|
||||
const canNavigate = computed(() => {
|
||||
if (props.loading) return false
|
||||
if (props.routeError) return false
|
||||
if (!effectiveStartPoint.value && effectiveStartMode.value !== 'gps') return false
|
||||
return true
|
||||
})
|
||||
|
||||
const navigateButtonText = computed(() => {
|
||||
if (props.loading) return '规划中...'
|
||||
if (hasRoute.value) return '开始导航'
|
||||
return '规划路线'
|
||||
})
|
||||
|
||||
const routeSummary = computed(() => {
|
||||
if (props.routeDistance > 0 && props.routeDuration > 0) {
|
||||
const distanceText = props.routeDistance >= 1000
|
||||
? `${(props.routeDistance / 1000).toFixed(1)}公里`
|
||||
: `${Math.round(props.routeDistance)}米`
|
||||
const durationText = props.routeDuration >= 60
|
||||
? `${Math.round(props.routeDuration / 60)}分钟`
|
||||
: `${Math.round(props.routeDuration)}秒`
|
||||
return `${distanceText} · 约${durationText}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const collapsedSummary = computed(() => {
|
||||
if (effectiveStartPoint.value) {
|
||||
return formatStartPointName()
|
||||
}
|
||||
if (effectiveStartMode.value === 'gps') {
|
||||
return '使用 GPS 定位'
|
||||
}
|
||||
return '室外导航到博物馆'
|
||||
})
|
||||
|
||||
const formatStartPointName = () => {
|
||||
if (!effectiveStartPoint.value) return ''
|
||||
if (effectiveStartPoint.value.name) {
|
||||
return effectiveStartPoint.value.name
|
||||
}
|
||||
return `(${effectiveStartPoint.value.latitude.toFixed(5)}, ${effectiveStartPoint.value.longitude.toFixed(5)})`
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
isCollapsed.value = false
|
||||
} else {
|
||||
// 重置状态
|
||||
localStartMode.value = 'gps'
|
||||
manualStartPoint.value = null
|
||||
selectedTravelMode.value = 'walking'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleGpsStart = () => {
|
||||
localStartMode.value = 'gps'
|
||||
manualStartPoint.value = null
|
||||
emit('gpsStart')
|
||||
}
|
||||
|
||||
// 搜索起点地址
|
||||
const searchStartAddress = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
startSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
isSearchingStart.value = true
|
||||
try {
|
||||
const { searchPoi } = await import('@/services/tencent/TencentMapService')
|
||||
const result = await searchPoi(query, '深圳市', 1, 5)
|
||||
if (result.status === 0 && result.data) {
|
||||
startSearchResults.value = result.data.map(poi => ({
|
||||
title: poi.title,
|
||||
address: poi.address,
|
||||
latitude: poi.location.lat,
|
||||
longitude: poi.location.lng
|
||||
}))
|
||||
} else {
|
||||
startSearchResults.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索起点失败:', error)
|
||||
startSearchResults.value = []
|
||||
} finally {
|
||||
isSearchingStart.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入
|
||||
const handleStartSearchInput = () => {
|
||||
const query = startSearchQuery.value
|
||||
if (query.length >= 2) {
|
||||
void searchStartAddress(query)
|
||||
} else {
|
||||
startSearchResults.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 选择搜索结果作为起点
|
||||
const handleSelectSearchResult = (result: { title: string; address: string; latitude: number; longitude: number }) => {
|
||||
manualStartPoint.value = {
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
name: result.title
|
||||
}
|
||||
startSearchQuery.value = ''
|
||||
startSearchResults.value = []
|
||||
emit('manualStartConfirm', { latitude: result.latitude, longitude: result.longitude })
|
||||
}
|
||||
|
||||
// 切换到手动选择模式
|
||||
const handleManualStart = () => {
|
||||
localStartMode.value = 'manual'
|
||||
startSearchQuery.value = ''
|
||||
startSearchResults.value = []
|
||||
emit('manualStartRequest')
|
||||
}
|
||||
|
||||
const handleTravelModeChange = (mode: TravelMode) => {
|
||||
selectedTravelMode.value = mode
|
||||
emit('travelModeChange', mode)
|
||||
}
|
||||
|
||||
const handleStartNavigation = () => {
|
||||
if (!canNavigate.value) return
|
||||
emit('startNavigation')
|
||||
}
|
||||
|
||||
const handleClearRoute = () => {
|
||||
manualStartPoint.value = null
|
||||
localStartMode.value = 'gps'
|
||||
emit('clearRoute')
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
const expandPanel = () => {
|
||||
isCollapsed.value = false
|
||||
}
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
const getGestureClientY = (event: TouchEvent | MouseEvent) => {
|
||||
if ('changedTouches' in event) {
|
||||
return event.changedTouches?.[0]?.clientY
|
||||
?? event.touches?.[0]?.clientY
|
||||
?? 0
|
||||
}
|
||||
return event.clientY
|
||||
}
|
||||
|
||||
const handlePanelTouchStart = (event: TouchEvent) => {
|
||||
const clientY = getGestureClientY(event)
|
||||
panelTouchStartY.value = clientY
|
||||
panelTouchCurrentY.value = clientY
|
||||
}
|
||||
|
||||
const handlePanelTouchMove = (event: TouchEvent) => {
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
}
|
||||
|
||||
const handlePanelTouchEnd = (event: TouchEvent) => {
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelMouseStart = (event: MouseEvent) => {
|
||||
const clientY = getGestureClientY(event)
|
||||
panelTouchStartY.value = clientY
|
||||
panelTouchCurrentY.value = clientY
|
||||
}
|
||||
|
||||
const handlePanelMouseMove = (event: MouseEvent) => {
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
}
|
||||
|
||||
const handlePanelMouseEnd = (event: MouseEvent) => {
|
||||
panelTouchCurrentY.value = getGestureClientY(event)
|
||||
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.outdoor-nav-panel {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 30px);
|
||||
padding: 10px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
|
||||
z-index: 1003;
|
||||
}
|
||||
|
||||
.outdoor-nav-panel.collapsed {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 10px;
|
||||
background: #d8dbd2;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.panel-collapsed {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-collapsed-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.panel-expand {
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #151713;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-expand-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.panel-light-action {
|
||||
flex: 0 0 auto;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f5f7f2;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-light-action-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.start-point-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-label-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.start-point-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.start-btn.active {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
}
|
||||
|
||||
.start-btn-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.start-btn-text {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.start-btn.active .start-btn-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.start-hint {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #ecede7;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.start-hint-text {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.start-search-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.start-search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.start-search-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.start-search-loading {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 12px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.start-search-results {
|
||||
margin-top: 8px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e5df;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f1ee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-address {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.start-point-display {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
padding: 10px 10px 10px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.point-dot {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.point-dot.start {
|
||||
background: #1fbf6b;
|
||||
}
|
||||
|
||||
.point-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.point-change {
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.point-change-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.travel-mode-section {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.travel-mode-list {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.travel-mode-item {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.travel-mode-item.active {
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
}
|
||||
|
||||
.travel-mode-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.travel-mode-label {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.travel-mode-item.active .travel-mode-label {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.panel-status {
|
||||
margin-top: 10px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.panel-status-text {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.panel-status-text.error {
|
||||
color: #b44b42;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn.primary {
|
||||
flex: 2;
|
||||
background: #151713;
|
||||
}
|
||||
|
||||
.nav-btn.secondary {
|
||||
flex: 1;
|
||||
background: #f6f7f4;
|
||||
border: 1px solid #e5e6de;
|
||||
}
|
||||
|
||||
.nav-btn.disabled {
|
||||
background: #d8dbd2;
|
||||
border-color: #d8dbd2;
|
||||
}
|
||||
|
||||
.nav-btn-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.nav-btn.secondary .nav-btn-text {
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.nav-btn.disabled .nav-btn-text {
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.manual-select-hint {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: #fff9e6;
|
||||
border: 1px solid #ffe58a;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.manual-select-hint-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #7a6108;
|
||||
}
|
||||
|
||||
.manual-select-cancel {
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.manual-select-cancel-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
</style>
|
||||
87
src/config/museum.ts
Normal file
87
src/config/museum.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 深圳自然博物馆配置信息
|
||||
* 统一管理博物馆相关常量
|
||||
*/
|
||||
|
||||
/**
|
||||
* 博物馆坐标信息(GCJ-02 坐标系)
|
||||
*/
|
||||
export const MUSEUM_LOCATION = {
|
||||
/** 纬度 */
|
||||
latitude: 22.692763,
|
||||
/** 经度 */
|
||||
longitude: 114.363572,
|
||||
/** 名称 */
|
||||
name: '深圳自然博物馆',
|
||||
/** 地址 */
|
||||
address: '深圳市坪山区',
|
||||
/** POI ID */
|
||||
poiId: '7043042949572197975'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 博物馆入口坐标(主入口)
|
||||
*/
|
||||
export const MUSEUM_ENTRANCE_LOCATION = {
|
||||
/** 纬度 */
|
||||
latitude: 22.692363,
|
||||
/** 经度 */
|
||||
longitude: 114.363487,
|
||||
/** 名称 */
|
||||
name: '主入口'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 博物馆建筑围栏坐标
|
||||
*/
|
||||
export const MUSEUM_POLYGON_POINTS = [
|
||||
{ latitude: 22.693738, longitude: 114.362331 },
|
||||
{ latitude: 22.69358, longitude: 114.362239 },
|
||||
{ latitude: 22.693513, longitude: 114.362239 },
|
||||
{ latitude: 22.691953, longitude: 114.362252 },
|
||||
{ latitude: 22.692106, longitude: 114.365058 },
|
||||
{ latitude: 22.692167, longitude: 114.365051 },
|
||||
{ latitude: 22.693129, longitude: 114.365018 },
|
||||
{ latitude: 22.693135, longitude: 114.364655 },
|
||||
{ latitude: 22.693196, longitude: 114.363982 },
|
||||
{ latitude: 22.693232, longitude: 114.363764 },
|
||||
{ latitude: 22.693348, longitude: 114.363473 },
|
||||
{ latitude: 22.693476, longitude: 114.36319 },
|
||||
{ latitude: 22.693653, longitude: 114.362714 },
|
||||
{ latitude: 22.693701, longitude: 114.362523 },
|
||||
{ latitude: 22.693738, longitude: 114.362351 }
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 地图默认缩放级别
|
||||
*/
|
||||
export const MAP_DEFAULT_SCALE = 17
|
||||
|
||||
/**
|
||||
* 出行方式类型
|
||||
*/
|
||||
export type TravelMode = 'walking' | 'driving' | 'transit'
|
||||
|
||||
/**
|
||||
* 出行方式配置
|
||||
*/
|
||||
export const TRAVEL_MODE_CONFIG = {
|
||||
walking: {
|
||||
type: 'walking',
|
||||
label: '步行',
|
||||
icon: '🚶'
|
||||
},
|
||||
driving: {
|
||||
type: 'driving',
|
||||
label: '驾车',
|
||||
icon: '🚗'
|
||||
},
|
||||
transit: {
|
||||
type: 'transit',
|
||||
label: '公交',
|
||||
icon: '🚌'
|
||||
}
|
||||
} as const
|
||||
|
||||
export type MuseumLocation = typeof MUSEUM_LOCATION
|
||||
export type MuseumEntranceLocation = typeof MUSEUM_ENTRANCE_LOCATION
|
||||
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
GuideDataIntegrityIssue,
|
||||
GuideDataIntegrityReport,
|
||||
GuideDiagnosticsStatus,
|
||||
GuideFloorDetail,
|
||||
GuideLocationPreview,
|
||||
GuideMapDiagnostics,
|
||||
GuideRouteReadiness,
|
||||
MuseumCategory,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
NAV_ROUTE_UNAVAILABLE_MESSAGE
|
||||
} from '@/domain/guideReadiness'
|
||||
import type {
|
||||
SgsFloorDiagnosticsPayload,
|
||||
SgsMapDiagnosticsPayload,
|
||||
SgsPoiPayload,
|
||||
SgsPositionPayload,
|
||||
SgsSdkFloorSummaryPayload,
|
||||
SgsSdkManifestPayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
|
||||
const defaultCategory: MuseumCategory = {
|
||||
id: 'operation_experience',
|
||||
label: '导览点位',
|
||||
iconType: 'poi'
|
||||
}
|
||||
|
||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
label: '卫生间',
|
||||
iconType: 'toilet'
|
||||
},
|
||||
accessible_toilet: {
|
||||
id: 'accessibility_special_service',
|
||||
label: '无障碍卫生间',
|
||||
iconType: 'accessible_toilet',
|
||||
accessible: true
|
||||
},
|
||||
elevator: {
|
||||
id: 'transport_circulation',
|
||||
label: '电梯',
|
||||
iconType: 'elevator',
|
||||
accessible: true
|
||||
},
|
||||
stairs: {
|
||||
id: 'transport_circulation',
|
||||
label: '楼梯',
|
||||
iconType: 'stairs'
|
||||
},
|
||||
escalator: {
|
||||
id: 'transport_circulation',
|
||||
label: '扶梯',
|
||||
iconType: 'escalator'
|
||||
},
|
||||
entrance_exit: {
|
||||
id: 'transport_circulation',
|
||||
label: '出入口',
|
||||
iconType: 'entrance_exit'
|
||||
},
|
||||
service_desk: {
|
||||
id: 'basic_service_facility',
|
||||
label: '服务台',
|
||||
iconType: 'service_desk'
|
||||
},
|
||||
mother_baby_room: {
|
||||
id: 'basic_service_facility',
|
||||
label: '母婴室',
|
||||
iconType: 'mother_baby_room',
|
||||
accessible: true
|
||||
},
|
||||
locker: {
|
||||
id: 'basic_service_facility',
|
||||
label: '存包处',
|
||||
iconType: 'locker'
|
||||
},
|
||||
rental_service: {
|
||||
id: 'basic_service_facility',
|
||||
label: '租赁服务',
|
||||
iconType: 'rental_service',
|
||||
accessible: true
|
||||
},
|
||||
ticket_office: {
|
||||
id: 'basic_service_facility',
|
||||
label: '售票处',
|
||||
iconType: 'ticket_office'
|
||||
}
|
||||
}
|
||||
|
||||
export const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const toNumber = (value: number | null | undefined, fallback = 0) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback
|
||||
)
|
||||
|
||||
const optionalNumber = (value: number | null | undefined) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||
)
|
||||
|
||||
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
|
||||
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
|
||||
return 'WARN'
|
||||
}
|
||||
|
||||
export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => {
|
||||
if (floorCode === 'EXTERIOR') return '室外'
|
||||
|
||||
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
|
||||
if (basementMatch) return `B${basementMatch[1]}`
|
||||
|
||||
const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
|
||||
if (floorMatch) return `${floorMatch[1]}F`
|
||||
|
||||
return floorName || floorCode || '未知楼层'
|
||||
}
|
||||
|
||||
export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({
|
||||
id: stringifyId(floor.floorId),
|
||||
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
|
||||
order: toNumber(floor.sortOrder)
|
||||
})
|
||||
|
||||
export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
const floorId = stringifyId(floor.floorId)
|
||||
if (!floorId) return
|
||||
|
||||
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
|
||||
aliases.set(floorId, floorId)
|
||||
if (floor.floorCode) aliases.set(String(floor.floorCode), floorId)
|
||||
if (floor.floorName) aliases.set(String(floor.floorName), floorId)
|
||||
aliases.set(label, floorId)
|
||||
aliases.set(label.toLowerCase(), floorId)
|
||||
})
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => {
|
||||
const source: SgsPositionPayload = poi.position || {
|
||||
x: poi.x,
|
||||
y: poi.y,
|
||||
z: poi.z
|
||||
}
|
||||
const x = Number(source.x)
|
||||
const y = Number(source.y)
|
||||
const z = Number(source.z)
|
||||
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
|
||||
const normalizedType = poi.type?.trim()
|
||||
if (normalizedType && categoryBySgsType[normalizedType]) {
|
||||
return categoryBySgsType[normalizedType]
|
||||
}
|
||||
|
||||
if (poi.typeName) {
|
||||
return {
|
||||
...defaultCategory,
|
||||
label: poi.typeName,
|
||||
iconType: normalizedType || defaultCategory.iconType
|
||||
}
|
||||
}
|
||||
|
||||
return defaultCategory
|
||||
}
|
||||
|
||||
export const toMuseumPoiFromSgs = (
|
||||
poi: SgsPoiPayload,
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
): MuseumPoi => {
|
||||
const floorId = stringifyId(poi.floorId)
|
||||
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode)
|
||||
const category = categoryFor(poi)
|
||||
|
||||
return {
|
||||
id: stringifyId(poi.id),
|
||||
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
|
||||
floorId,
|
||||
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
|
||||
primaryCategory: {
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
}
|
||||
],
|
||||
positionGltf: normalizePosition(poi),
|
||||
sourceObjectName: poi.anchorNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: category.accessible === true
|
||||
}
|
||||
}
|
||||
|
||||
export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
})
|
||||
|
||||
export const toGuideFloorDetailFromSgs = (
|
||||
floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload
|
||||
): GuideFloorDetail => ({
|
||||
...toMuseumFloorFromSgs(floor),
|
||||
sourceCode: floor.floorCode || undefined,
|
||||
sourceName: floor.floorName || undefined,
|
||||
modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined,
|
||||
poiCount: optionalNumber(floor.poiCount),
|
||||
spaceCount: optionalNumber(floor.spaceCount),
|
||||
guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined,
|
||||
navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined,
|
||||
routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined,
|
||||
routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined,
|
||||
routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined,
|
||||
warnings: 'warnings' in floor ? floor.warnings || [] : []
|
||||
})
|
||||
|
||||
export const toGuideMapDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload,
|
||||
manifest?: SgsSdkManifestPayload
|
||||
): GuideMapDiagnostics => {
|
||||
const summary = diagnostics.summary || {}
|
||||
const floors = diagnostics.floors?.length
|
||||
? diagnostics.floors.map(toGuideFloorDetailFromSgs)
|
||||
: (manifest?.floors || []).map(toGuideFloorDetailFromSgs)
|
||||
|
||||
return {
|
||||
mapId: stringifyId(diagnostics.mapId || manifest?.mapId),
|
||||
mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图',
|
||||
sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined,
|
||||
status: normalizeStatus(diagnostics.status),
|
||||
floors,
|
||||
summary: {
|
||||
floorCount: toNumber(summary.floorCount, floors.length),
|
||||
modelReadyFloorCount: toNumber(summary.modelReadyFloorCount),
|
||||
poiCount: toNumber(summary.poiCount),
|
||||
spaceCount: toNumber(summary.spaceCount),
|
||||
guideStopCount: toNumber(summary.guideStopCount),
|
||||
navigablePlaceCount: toNumber(summary.navigablePlaceCount),
|
||||
routeNodeCount: toNumber(summary.routeNodeCount),
|
||||
routeEdgeCount: toNumber(summary.routeEdgeCount)
|
||||
},
|
||||
warnings: diagnostics.warnings || []
|
||||
}
|
||||
}
|
||||
|
||||
export const toRouteReadinessFromSgsDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload
|
||||
): GuideRouteReadiness => {
|
||||
const warnings = diagnostics.warnings || []
|
||||
const floors = diagnostics.floors || []
|
||||
const notReadyFloors = floors.filter((floor) => (
|
||||
floor.modelReady !== true
|
||||
|| floor.routePlanningReady !== true
|
||||
|| toNumber(floor.routeNodeCount) <= 0
|
||||
|| toNumber(floor.routeEdgeCount) <= 0
|
||||
|| toNumber(floor.navigablePlaceCount) <= 0
|
||||
))
|
||||
|
||||
const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0
|
||||
|
||||
return {
|
||||
ready,
|
||||
message: ready
|
||||
? 'SGS 后端路线数据已就绪'
|
||||
: NAV_ROUTE_UNAVAILABLE_MESSAGE,
|
||||
requiredData: ready
|
||||
? []
|
||||
: [
|
||||
...warnings,
|
||||
...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const addIssue = (
|
||||
issues: GuideDataIntegrityIssue[],
|
||||
issue: Omit<GuideDataIntegrityIssue, 'id'>
|
||||
) => {
|
||||
issues.push({
|
||||
id: `issue-${issues.length + 1}`,
|
||||
...issue
|
||||
})
|
||||
}
|
||||
|
||||
const missingFieldsForPoi = (poi: MuseumPoi) => {
|
||||
const fields: string[] = []
|
||||
if (!poi.id) fields.push('id')
|
||||
if (!poi.name) fields.push('name')
|
||||
if (!poi.floorId) fields.push('floorId')
|
||||
if (!poi.positionGltf) fields.push('position')
|
||||
return fields
|
||||
}
|
||||
|
||||
export const createGuideDataIntegrityReport = (
|
||||
diagnostics: GuideMapDiagnostics,
|
||||
floors: MuseumFloor[],
|
||||
pois: MuseumPoi[]
|
||||
): GuideDataIntegrityReport => {
|
||||
const issues: GuideDataIntegrityIssue[] = []
|
||||
const floorIds = new Set(floors.map((floor) => floor.id))
|
||||
const poiIds = new Set<string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
if (!floor.id || !floor.label) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'error',
|
||||
message: '楼层缺少稳定 ID 或显示名称',
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
diagnostics.floors.forEach((floor) => {
|
||||
if (floor.modelReady === false) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 模型未就绪`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['modelReady']
|
||||
})
|
||||
}
|
||||
|
||||
if (floor.navigablePlaceCount === 0) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 缺少可导航目的地`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['navigablePlaceCount']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
pois.forEach((poi) => {
|
||||
const missingFields = missingFieldsForPoi(poi)
|
||||
if (missingFields.length) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: missingFields
|
||||
})
|
||||
}
|
||||
|
||||
if (poi.id && poiIds.has(poi.id)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 点位 ID 重复`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['id']
|
||||
})
|
||||
}
|
||||
if (poi.id) poiIds.add(poi.id)
|
||||
|
||||
if (poi.floorId && !floorIds.has(poi.floorId)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 关联的楼层不存在`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['floorId']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const errorCount = issues.filter((issue) => issue.severity === 'error').length
|
||||
const warningCount = issues.filter((issue) => issue.severity === 'warn').length
|
||||
|
||||
return {
|
||||
status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK',
|
||||
summary: {
|
||||
floorCount: floors.length,
|
||||
poiCount: pois.length,
|
||||
issueCount: issues.length,
|
||||
errorCount,
|
||||
warningCount
|
||||
},
|
||||
issues,
|
||||
warnings: diagnostics.warnings
|
||||
}
|
||||
}
|
||||
318
src/data/adapters/sgsSdkRouteAdapter.ts
Normal file
318
src/data/adapters/sgsSdkRouteAdapter.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import type {
|
||||
GuideRouteConnectorPoint,
|
||||
GuideRouteEndpoint,
|
||||
GuideRouteFloorSegment,
|
||||
GuideRoutePoint,
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
SgsRoutePlanResponsePayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import type {
|
||||
SgsRouteResult as SgsSdkRuntimeRouteResult
|
||||
} from '@/types/sgs-map-sdk'
|
||||
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
|
||||
|
||||
type SgsRoutePathNode = {
|
||||
nodeId?: string | number
|
||||
floorId?: string | number
|
||||
x: number
|
||||
y?: number
|
||||
z: number
|
||||
}
|
||||
|
||||
type CompatibleSgsRouteResult = SgsSdkRuntimeRouteResult & {
|
||||
path?: SgsRoutePathNode[]
|
||||
}
|
||||
|
||||
type GeoJsonLineString = {
|
||||
type?: string
|
||||
coordinates?: Array<[number, number] | [number, number, number]>
|
||||
}
|
||||
|
||||
const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
|
||||
Number(point.x || 0),
|
||||
Number(point.y || 0),
|
||||
Number(point.z || 0)
|
||||
]
|
||||
|
||||
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
|
||||
poiId: target.poiId,
|
||||
name: target.name,
|
||||
floorId: target.floorId,
|
||||
floorLabel: target.floorLabel,
|
||||
routeNodeId: target.routeNodeId,
|
||||
position: target.positionGltf || [0, 0, 0]
|
||||
})
|
||||
|
||||
const normalizePath = (
|
||||
route: CompatibleSgsRouteResult,
|
||||
startFloorId: string,
|
||||
endFloorId: string
|
||||
): GuideRoutePoint[] => {
|
||||
const pathPoints = route.pathPoints ?? route.path ?? []
|
||||
|
||||
if (!pathPoints.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (route.path?.length) {
|
||||
return route.path.map((point, index) => ({
|
||||
nodeId: String(point.nodeId || `sdk-route-point-${index}`),
|
||||
floorId: toAppFloorId(point.floorId ?? startFloorId),
|
||||
position: pointPosition(point)
|
||||
}))
|
||||
}
|
||||
|
||||
const isCrossFloor = startFloorId !== endFloorId
|
||||
const totalPoints = pathPoints.length
|
||||
const normalizedStartFloorId = toAppFloorId(startFloorId)
|
||||
const normalizedEndFloorId = toAppFloorId(endFloorId)
|
||||
|
||||
return pathPoints.map((point, index) => {
|
||||
const nodeId = `sdk-pp-${index}`
|
||||
let floorId: string
|
||||
|
||||
if (isCrossFloor) {
|
||||
if (index === 0) {
|
||||
floorId = normalizedStartFloorId
|
||||
} else if (index === totalPoints - 1) {
|
||||
floorId = normalizedEndFloorId
|
||||
} else {
|
||||
floorId = normalizedStartFloorId
|
||||
}
|
||||
} else {
|
||||
floorId = normalizedStartFloorId
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
floorId,
|
||||
position: pointPosition(point)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createFloorSegments = (
|
||||
points: GuideRoutePoint[],
|
||||
start: GuideRouteTarget,
|
||||
end: GuideRouteTarget
|
||||
): GuideRouteFloorSegment[] => {
|
||||
const normalizedStartFloorId = toAppFloorId(start.floorId)
|
||||
const normalizedEndFloorId = toAppFloorId(end.floorId)
|
||||
|
||||
const labels = new Map([
|
||||
[normalizedStartFloorId, start.floorLabel],
|
||||
[normalizedEndFloorId, end.floorLabel]
|
||||
])
|
||||
|
||||
return points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
|
||||
const current = segments[segments.length - 1]
|
||||
if (current && current.floorId === point.floorId) {
|
||||
current.points.push(point)
|
||||
return segments
|
||||
}
|
||||
|
||||
segments.push({
|
||||
floorId: point.floorId,
|
||||
floorLabel: labels.get(point.floorId) || point.floorId,
|
||||
points: [point]
|
||||
})
|
||||
return segments
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const toGuideRouteResultFromSgs = (
|
||||
route: SgsSdkRuntimeRouteResult,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const points = normalizePath(
|
||||
route as CompatibleSgsRouteResult,
|
||||
startTarget.floorId,
|
||||
endTarget.floorId
|
||||
)
|
||||
const routePoints = points.length
|
||||
? points
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
id: `sdk-route-${start.poiId}-${end.poiId}`,
|
||||
start,
|
||||
end,
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: routePoints.map((point) => point.nodeId),
|
||||
points: routePoints,
|
||||
floorSegments: createFloorSegments(routePoints, startTarget, endTarget),
|
||||
connectorPoints: []
|
||||
}
|
||||
}
|
||||
|
||||
const floorLabelFor = (
|
||||
floorId: string,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
) => {
|
||||
if (floorId === startTarget.floorId) return startTarget.floorLabel
|
||||
if (floorId === endTarget.floorId) return endTarget.floorLabel
|
||||
return floorId
|
||||
}
|
||||
|
||||
const pointFromBackendNode = (
|
||||
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
|
||||
index: number,
|
||||
fallbackFloorId: string
|
||||
): GuideRoutePoint => ({
|
||||
nodeId: String(node.id || `sgs-route-node-${index}`),
|
||||
floorId: toAppFloorId(node.floorId ?? fallbackFloorId),
|
||||
position: [
|
||||
Number(node.x || 0),
|
||||
0,
|
||||
Number(node.y || 0)
|
||||
]
|
||||
})
|
||||
|
||||
const pointsFromGeoJson = (
|
||||
geoJson: string | null | undefined,
|
||||
floorId: string
|
||||
): GuideRoutePoint[] => {
|
||||
if (!geoJson) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(geoJson) as GeoJsonLineString
|
||||
const coordinates = parsed.type === 'LineString' && Array.isArray(parsed.coordinates)
|
||||
? parsed.coordinates
|
||||
: []
|
||||
|
||||
return coordinates.map((coordinate, index) => ({
|
||||
nodeId: `${floorId}-geo-${index}`,
|
||||
floorId,
|
||||
position: [
|
||||
Number(coordinate[0] || 0),
|
||||
0,
|
||||
Number(coordinate[1] || 0)
|
||||
]
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const toGuideRouteResultFromSgsPlan = (
|
||||
route: SgsRoutePlanResponsePayload,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const nodePoints = (route.nodePaths || []).map((node, index) => (
|
||||
pointFromBackendNode(node, index, start.floorId)
|
||||
))
|
||||
const routePoints = nodePoints.length
|
||||
? [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
...nodePoints,
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
: pointsFromGeoJson(route.pathGeoJson, start.floorId)
|
||||
|
||||
const fallbackPoints = routePoints.length
|
||||
? routePoints
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
|
||||
const floorSegments = route.segments?.length
|
||||
? route.segments.map((segment, segmentIndex) => {
|
||||
const floorId = String(segment.floorId || start.floorId)
|
||||
const points = pointsFromGeoJson(segment.pathGeoJson, floorId)
|
||||
|
||||
return {
|
||||
floorId,
|
||||
floorLabel: segment.floorName || floorLabelFor(floorId, startTarget, endTarget),
|
||||
points: points.length
|
||||
? points
|
||||
: fallbackPoints.filter((point) => point.floorId === floorId).map((point, index) => ({
|
||||
...point,
|
||||
nodeId: point.nodeId || `${floorId}-segment-${segmentIndex}-${index}`
|
||||
}))
|
||||
}
|
||||
}).filter((segment) => segment.points.length)
|
||||
: createFloorSegments(fallbackPoints, startTarget, endTarget)
|
||||
|
||||
return {
|
||||
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
|
||||
start,
|
||||
end,
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: fallbackPoints.map((point) => point.nodeId),
|
||||
points: fallbackPoints,
|
||||
floorSegments,
|
||||
connectorPoints: extractFloorConnectorPoints(fallbackPoints, startTarget, endTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const extractFloorConnectorPoints = (
|
||||
points: GuideRoutePoint[],
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteConnectorPoint[] => {
|
||||
if (points.length < 2) return []
|
||||
|
||||
const connectors: GuideRouteConnectorPoint[] = []
|
||||
const labels = new Map<string, string>([
|
||||
[startTarget.floorId, startTarget.floorLabel],
|
||||
[endTarget.floorId, endTarget.floorLabel]
|
||||
])
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const currentFloorId = toAppFloorId(points[i].floorId)
|
||||
const nextFloorId = toAppFloorId(points[i + 1].floorId)
|
||||
|
||||
if (currentFloorId !== nextFloorId) {
|
||||
const fromLabel = labels.get(currentFloorId) || currentFloorId
|
||||
const toLabel = labels.get(nextFloorId) || nextFloorId
|
||||
|
||||
connectors.push({
|
||||
nodeId: `connector-${points[i].nodeId}-${nextFloorId}`,
|
||||
floorId: nextFloorId,
|
||||
floorLabel: `经 ${fromLabel} 至 ${toLabel}`,
|
||||
position: points[i].position,
|
||||
connectorType: 'floor_change'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return connectors
|
||||
}
|
||||
358
src/data/providers/sgsSdkApiProvider.ts
Normal file
358
src/data/providers/sgsSdkApiProvider.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
|
||||
export type SgsDiagnosticsStatusPayload = 'OK' | 'WARN' | 'ERROR'
|
||||
|
||||
export interface SgsSdkFloorSummaryPayload {
|
||||
floorId: string | number
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
sortOrder?: number | null
|
||||
modelSizeBytes?: number | null
|
||||
poiCount?: number | null
|
||||
spaceCount?: number | null
|
||||
}
|
||||
|
||||
export interface SgsSdkManifestPayload {
|
||||
mapId: string | number
|
||||
mapName?: string | null
|
||||
sdkVersion?: string | null
|
||||
protocolVersion?: number | null
|
||||
dataVersion?: string | null
|
||||
updatedAt?: string | null
|
||||
coordinateSystem?: string | null
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
capabilities?: Record<string, boolean | undefined>
|
||||
}
|
||||
|
||||
export interface SgsPositionPayload {
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
}
|
||||
|
||||
export interface SgsPoiPayload {
|
||||
id: string | number
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
typeName?: string | null
|
||||
floorCode?: string | null
|
||||
floorId?: string | number | null
|
||||
position?: SgsPositionPayload | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
status?: string | null
|
||||
anchorNodeName?: string | null
|
||||
description?: string | null
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
export interface SgsSpacePayload {
|
||||
id: string | number
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
floorId?: string | number | null
|
||||
boundaryWkt?: string | null
|
||||
center?: SgsPositionPayload | null
|
||||
sourceNodeName?: string | null
|
||||
status?: string | null
|
||||
colorHex?: string | null
|
||||
}
|
||||
|
||||
export interface SgsNavigablePlacePayload {
|
||||
id?: string | number | null
|
||||
name?: string | null
|
||||
category?: string | null
|
||||
type?: string | null
|
||||
typeCode?: string | null
|
||||
typeName?: string | null
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
position?: SgsPositionPayload | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
nodeId?: string | number | null
|
||||
ownerName?: string | null
|
||||
}
|
||||
|
||||
export interface SgsFloorDiagnosticsPayload {
|
||||
floorId: string | number
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
status: SgsDiagnosticsStatusPayload
|
||||
modelReady?: boolean
|
||||
poiCount?: number
|
||||
spaceCount?: number
|
||||
guideStopCount?: number
|
||||
navigablePlaceCount?: number
|
||||
routeNodeCount?: number
|
||||
routeEdgeCount?: number
|
||||
routePlanningReady?: boolean
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface SgsFloorDiagnosticsSummaryPayload extends SgsFloorDiagnosticsPayload {}
|
||||
|
||||
export interface SgsMapDiagnosticsPayload {
|
||||
mapId: string | number
|
||||
mapName?: string | null
|
||||
sdkVersion?: string | null
|
||||
status: SgsDiagnosticsStatusPayload
|
||||
floors: SgsFloorDiagnosticsSummaryPayload[]
|
||||
summary?: {
|
||||
floorCount?: number
|
||||
modelReadyFloorCount?: number
|
||||
poiCount?: number
|
||||
spaceCount?: number
|
||||
guideStopCount?: number
|
||||
navigablePlaceCount?: number
|
||||
routeNodeCount?: number
|
||||
routeEdgeCount?: number
|
||||
}
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface SgsFloorBundlePayload {
|
||||
floor?: SgsSdkFloorSummaryPayload | null
|
||||
model?: SgsModelInfoPayload | null
|
||||
pois?: SgsPoiPayload[]
|
||||
spaces?: SgsSpacePayload[]
|
||||
guideStops?: unknown[]
|
||||
routeSummary?: {
|
||||
hasRouteNetwork?: boolean
|
||||
nodeCount?: number
|
||||
edgeCount?: number
|
||||
} | null
|
||||
hiddenSceneNodeNames?: string[]
|
||||
dataVersion?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SgsModelInfoPayload {
|
||||
modelUrl?: string | null
|
||||
fallbackModelUrl?: string | null
|
||||
compressionType?: string | null
|
||||
modelVersion?: string | null
|
||||
nodeCount?: number | null
|
||||
sizeBytes?: number | null
|
||||
originSizeBytes?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRoutePlanRequestPayload {
|
||||
startFloorId: number | string
|
||||
startX: number
|
||||
startY: number
|
||||
startNodeId?: number | string | null
|
||||
endFloorId: number | string
|
||||
endX: number
|
||||
endY: number
|
||||
endNodeId?: number | string | null
|
||||
wheelchair: 0 | 1
|
||||
}
|
||||
|
||||
export interface SgsRouteStepNodePayload {
|
||||
id?: string | number | null
|
||||
nodeName?: string | null
|
||||
floorId?: string | number | null
|
||||
nodeType?: string | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRouteSegmentPayload {
|
||||
floorId?: string | number | null
|
||||
floorName?: string | null
|
||||
startNodeId?: string | number | null
|
||||
endNodeId?: string | number | null
|
||||
startNodeName?: string | null
|
||||
endNodeName?: string | null
|
||||
transferType?: string | null
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
pathGeoJson?: string | null
|
||||
nodePathIds?: Array<string | number | null>
|
||||
}
|
||||
|
||||
export interface SgsRoutePlanResponsePayload {
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
pathGeoJson?: string | null
|
||||
nodePaths?: SgsRouteStepNodePayload[]
|
||||
segments?: SgsRouteSegmentPayload[]
|
||||
}
|
||||
|
||||
interface CommonResult<T> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface SgsSdkApiProvider {
|
||||
getManifest(mapId?: string): Promise<SgsSdkManifestPayload>
|
||||
getMapDiagnostics(mapId?: string): Promise<SgsMapDiagnosticsPayload>
|
||||
getFloorDiagnostics(floorId: string): Promise<SgsFloorDiagnosticsPayload>
|
||||
getFloorBundle(floorId: string): Promise<SgsFloorBundlePayload>
|
||||
getFloorPois(floorId: string): Promise<SgsPoiPayload[]>
|
||||
getFloorSpaces(floorId: string): Promise<SgsSpacePayload[]>
|
||||
getNavigablePlaces(floorId: string): Promise<SgsNavigablePlacePayload[]>
|
||||
planRoute(payload: SgsRoutePlanRequestPayload): Promise<SgsRoutePlanResponsePayload>
|
||||
}
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const resolveAppApiBaseUrl = () => {
|
||||
const baseUrl = normalizeBaseUrl(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '/app-api')
|
||||
return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api`
|
||||
}
|
||||
|
||||
const parseJsonPayload = <T>(payload: unknown): T => {
|
||||
if (typeof payload === 'string') {
|
||||
return JSON.parse(payload) as T
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
const requestJson = <T>(
|
||||
path: string,
|
||||
options: { method?: 'GET' | 'POST'; data?: unknown } = {}
|
||||
): Promise<T> => new Promise((resolve, reject) => {
|
||||
const baseUrl = resolveAppApiBaseUrl()
|
||||
|
||||
uni.request({
|
||||
url: `${baseUrl}${path}`,
|
||||
method: options.method || 'GET',
|
||||
data: options.method === 'POST' ? JSON.stringify(options.data || {}) : undefined,
|
||||
header: options.method === 'POST'
|
||||
? {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
: undefined,
|
||||
timeout: dataSourceConfig.sgsSdkTimeoutMs,
|
||||
success: (response) => {
|
||||
const statusCode = Number(response.statusCode || 0)
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`SGS 数据接口请求失败: ${statusCode} ${path}`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = parseJsonPayload<CommonResult<T>>(response.data)
|
||||
if (!body || body.code !== 0) {
|
||||
reject(new Error(`SGS 数据接口业务失败: ${path} code=${body?.code} msg=${body?.msg || ''}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(body.data as T)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
fail: (error) => {
|
||||
reject(new Error(`SGS 数据接口网络失败: ${path} ${JSON.stringify(error)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
|
||||
const manifestCache = new Map<string, SgsSdkManifestPayload>()
|
||||
const mapDiagnosticsCache = new Map<string, SgsMapDiagnosticsPayload>()
|
||||
const floorDiagnosticsCache = new Map<string, SgsFloorDiagnosticsPayload>()
|
||||
const floorBundleCache = new Map<string, SgsFloorBundlePayload>()
|
||||
const floorPoiCache = new Map<string, SgsPoiPayload[]>()
|
||||
const floorSpaceCache = new Map<string, SgsSpacePayload[]>()
|
||||
const navigablePlaceCache = new Map<string, SgsNavigablePlacePayload[]>()
|
||||
|
||||
const mapIdFor = (mapId?: string) => mapId || dataSourceConfig.sgsMapId
|
||||
const floorIdFor = (floorId: string) => encodeURIComponent(floorId)
|
||||
|
||||
return {
|
||||
async getManifest(mapId) {
|
||||
const normalizedMapId = mapIdFor(mapId)
|
||||
const cached = manifestCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const manifest = await requestJson<SgsSdkManifestPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/manifest`
|
||||
)
|
||||
manifestCache.set(normalizedMapId, manifest)
|
||||
return manifest
|
||||
},
|
||||
async getMapDiagnostics(mapId) {
|
||||
const normalizedMapId = mapIdFor(mapId)
|
||||
const cached = mapDiagnosticsCache.get(normalizedMapId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsMapDiagnosticsPayload>(
|
||||
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/diagnostics`
|
||||
)
|
||||
mapDiagnosticsCache.set(normalizedMapId, diagnostics)
|
||||
return diagnostics
|
||||
},
|
||||
async getFloorDiagnostics(floorId) {
|
||||
const cached = floorDiagnosticsCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const diagnostics = await requestJson<SgsFloorDiagnosticsPayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/diagnostics`
|
||||
)
|
||||
floorDiagnosticsCache.set(floorId, diagnostics)
|
||||
return diagnostics
|
||||
},
|
||||
async getFloorBundle(floorId) {
|
||||
const cached = floorBundleCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const bundle = await requestJson<SgsFloorBundlePayload>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/bundle`
|
||||
)
|
||||
floorBundleCache.set(floorId, bundle)
|
||||
return bundle
|
||||
},
|
||||
async getFloorPois(floorId) {
|
||||
const cached = floorPoiCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const pois = await requestJson<SgsPoiPayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/pois`
|
||||
)
|
||||
floorPoiCache.set(floorId, pois)
|
||||
return pois
|
||||
},
|
||||
async getFloorSpaces(floorId) {
|
||||
const cached = floorSpaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const spaces = await requestJson<SgsSpacePayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/spaces`
|
||||
)
|
||||
floorSpaceCache.set(floorId, spaces)
|
||||
return spaces
|
||||
},
|
||||
async getNavigablePlaces(floorId) {
|
||||
const cached = navigablePlaceCache.get(floorId)
|
||||
if (cached) return cached
|
||||
|
||||
const places = await requestJson<SgsNavigablePlacePayload[]>(
|
||||
`/gis/sdk/floors/${floorIdFor(floorId)}/navigable-places`
|
||||
)
|
||||
navigablePlaceCache.set(floorId, places)
|
||||
return places
|
||||
},
|
||||
async planRoute(payload) {
|
||||
return requestJson<SgsRoutePlanResponsePayload>(
|
||||
'/gis/sdk/routes/plan',
|
||||
{
|
||||
method: 'POST',
|
||||
data: payload
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultSgsSdkApiProvider = createSgsSdkApiProvider()
|
||||
18
src/repositories/createGuideRepository.ts
Normal file
18
src/repositories/createGuideRepository.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
SgsSdkGuideRepository,
|
||||
StaticGuideRepository,
|
||||
type GuideRepository
|
||||
} from '@/repositories/GuideRepository'
|
||||
|
||||
export const createGuideRepository = (): GuideRepository => {
|
||||
if (dataSourceConfig.mode === 'api' || dataSourceConfig.mode === 'sdk') {
|
||||
return new SgsSdkGuideRepository()
|
||||
}
|
||||
|
||||
return new StaticGuideRepository()
|
||||
}
|
||||
|
||||
export const guideRepository = createGuideRepository()
|
||||
20
src/repositories/createGuideRouteRepository.ts
Normal file
20
src/repositories/createGuideRouteRepository.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
StaticGuideRouteRepository,
|
||||
type GuideRouteRepository
|
||||
} from '@/repositories/GuideRouteRepository'
|
||||
import {
|
||||
SdkGuideRouteRepository
|
||||
} from '@/repositories/SdkGuideRouteRepository'
|
||||
|
||||
export const createGuideRouteRepository = (): GuideRouteRepository => {
|
||||
if (dataSourceConfig.mode === 'api' || dataSourceConfig.mode === 'sdk') {
|
||||
return new SdkGuideRouteRepository()
|
||||
}
|
||||
|
||||
return new StaticGuideRouteRepository()
|
||||
}
|
||||
|
||||
export const guideRouteRepository = createGuideRouteRepository()
|
||||
163
src/services/MapNavigationService.ts
Normal file
163
src/services/MapNavigationService.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 地图导航调起服务
|
||||
* 使用 uni.openLocation 调起设备地图 App 进行导航
|
||||
*/
|
||||
|
||||
import { MUSEUM_LOCATION } from '@/config/museum'
|
||||
|
||||
export interface NavigationOptions {
|
||||
/** 目的地纬度 */
|
||||
latitude: number
|
||||
/** 目的地经度 */
|
||||
longitude: number
|
||||
/** 目的地名称 */
|
||||
name: string
|
||||
/** 目的地地址(可选) */
|
||||
address?: string
|
||||
/** 缩放级别(可选,默认 18) */
|
||||
scale?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起外部地图应用进行导航
|
||||
* @param options 导航选项
|
||||
* @returns Promise<boolean> 是否成功调起
|
||||
*/
|
||||
export function openExternalNavigation(options: NavigationOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const scale = options.scale ?? 18
|
||||
|
||||
uni.openLocation({
|
||||
latitude: options.latitude,
|
||||
longitude: options.longitude,
|
||||
name: options.name,
|
||||
address: options.address ?? '',
|
||||
scale,
|
||||
success: () => {
|
||||
console.log('成功调起地图导航:', options.name)
|
||||
resolve(true)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('调起地图导航失败:', err)
|
||||
|
||||
// 根据错误类型提供友好的提示
|
||||
if (err.errMsg) {
|
||||
if (err.errMsg.includes('cancel')) {
|
||||
// 用户取消
|
||||
resolve(false)
|
||||
} else if (err.errMsg.includes('auth deny') || err.errMsg.includes('auth reject')) {
|
||||
uni.showToast({
|
||||
title: '需要位置权限才能导航',
|
||||
icon: 'none'
|
||||
})
|
||||
resolve(false)
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '无法调起地图应用',
|
||||
icon: 'none'
|
||||
})
|
||||
resolve(false)
|
||||
}
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航到深圳自然博物馆
|
||||
* @param fromLat 起点纬度(可选,用于日志)
|
||||
* @param fromLng 起点经度(可选,用于日志)
|
||||
* @returns Promise<boolean> 是否成功调起
|
||||
*/
|
||||
export function navigateToMuseum(
|
||||
fromLat?: number,
|
||||
fromLng?: number
|
||||
): Promise<boolean> {
|
||||
if (fromLat !== undefined && fromLng !== undefined) {
|
||||
console.log(`从 (${fromLat}, ${fromLng}) 导航到深圳自然博物馆`)
|
||||
} else {
|
||||
console.log('导航到深圳自然博物馆')
|
||||
}
|
||||
|
||||
return openExternalNavigation({
|
||||
latitude: MUSEUM_LOCATION.latitude,
|
||||
longitude: MUSEUM_LOCATION.longitude,
|
||||
name: MUSEUM_LOCATION.name,
|
||||
address: MUSEUM_LOCATION.address,
|
||||
scale: 18
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前位置并导航到博物馆
|
||||
* @returns Promise<boolean> 是否成功调起
|
||||
*/
|
||||
export function getCurrentLocationAndNavigate(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
uni.getLocation({
|
||||
type: 'gcj02',
|
||||
success: (locationRes) => {
|
||||
console.log('获取当前位置成功:', locationRes)
|
||||
navigateToMuseum(locationRes.latitude, locationRes.longitude).then(resolve)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('获取当前位置失败:', err)
|
||||
|
||||
if (err.errMsg) {
|
||||
if (err.errMsg.includes('cancel')) {
|
||||
resolve(false)
|
||||
} else if (err.errMsg.includes('auth deny') || err.errMsg.includes('auth reject')) {
|
||||
uni.showToast({
|
||||
title: '需要位置权限才能获取当前位置',
|
||||
icon: 'none'
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '无法获取当前位置',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否支持调起地图应用
|
||||
* 在某些 H5 环境下可能不支持
|
||||
*/
|
||||
export function isMapNavigationSupported(): boolean {
|
||||
// #ifdef H5
|
||||
// H5 环境检查
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||
// 微信浏览器需要在微信环境中使用 jssdk
|
||||
const isWechat = /MicroMessenger/i.test(ua)
|
||||
if (isWechat) {
|
||||
console.warn('微信环境需要使用微信 JSSDK 进行地图导航')
|
||||
return false
|
||||
}
|
||||
// #endif
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航提示信息
|
||||
*/
|
||||
export function getNavigationHint(): string {
|
||||
// #ifdef H5
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
|
||||
const isWechat = /MicroMessenger/i.test(ua)
|
||||
if (isWechat) {
|
||||
return '请在微信中打开后使用'
|
||||
}
|
||||
// #endif
|
||||
|
||||
return '将调起手机地图应用进行导航'
|
||||
}
|
||||
521
src/services/tencent/TencentMapService.ts
Normal file
521
src/services/tencent/TencentMapService.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 腾讯地图 WebService API 服务
|
||||
* 用于 POI 搜索、地址解析、路线规划等
|
||||
*/
|
||||
|
||||
// 优先从环境变量读取 API Key,回退到硬编码值(仅用于开发环境)
|
||||
const TENCENT_MAP_KEY = import.meta.env.VITE_TENCENT_MAP_KEY || 'EJPBZ-DQEEQ-PDN5U-4ZDVX-F4I3F-6MBJC'
|
||||
const TENCENT_MAP_BASE_URL = 'https://apis.map.qq.com/ws'
|
||||
const TENCENT_DIRECTION_BASE_URL = `${TENCENT_MAP_BASE_URL}/direction/v1`
|
||||
|
||||
export interface TencentPoiResult {
|
||||
id: string
|
||||
title: string
|
||||
address: string
|
||||
category: string
|
||||
lat: number
|
||||
lng: number
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
distance?: number
|
||||
}
|
||||
|
||||
export interface TencentSearchResult {
|
||||
status: number
|
||||
message: string
|
||||
count: number
|
||||
data: TencentPoiResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 路线规划起点/终点坐标
|
||||
*/
|
||||
export interface RoutePoint {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 路线规划结果
|
||||
*/
|
||||
export interface RouteResult {
|
||||
/** 状态码,0 表示成功 */
|
||||
status: number
|
||||
/** 状态消息 */
|
||||
message: string
|
||||
/** 路线数据 */
|
||||
routes?: Route[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 路线数据
|
||||
*/
|
||||
export interface Route {
|
||||
/** 路线唯一标识 */
|
||||
id: number
|
||||
/** 起点坐标 */
|
||||
startPoint: RoutePoint
|
||||
/** 终点坐标 */
|
||||
endPoint: RoutePoint
|
||||
/** 路线总距离(米) */
|
||||
distance: number
|
||||
/** 路线总耗时(秒) */
|
||||
duration: number
|
||||
/** 路线描述 */
|
||||
description?: string
|
||||
/** 路线点串(经纬度数组,用于在地图上绘制路线) */
|
||||
polyline: RoutePoint[]
|
||||
/** 路线步骤列表 */
|
||||
steps: RouteStep[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 路线步骤
|
||||
*/
|
||||
export interface RouteStep {
|
||||
/** 起点坐标 */
|
||||
startPoint: RoutePoint
|
||||
/** 终点坐标 */
|
||||
endPoint: RoutePoint
|
||||
/** 步骤说明 */
|
||||
instruction: string
|
||||
/** 步骤距离(米) */
|
||||
distance: number
|
||||
/** 步骤耗时(秒) */
|
||||
duration: number
|
||||
/** 步骤方向 */
|
||||
direction?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 路线点串解码(腾讯地图使用自定义编码)
|
||||
* @param encoded 点串编码字符串
|
||||
* @returns 解码后的坐标数组
|
||||
*/
|
||||
function decodePolyline(encoded: string): RoutePoint[] {
|
||||
const points: RoutePoint[] = []
|
||||
let index = 0
|
||||
let lat = 0
|
||||
let lng = 0
|
||||
|
||||
while (index < encoded.length) {
|
||||
let b: number
|
||||
let shift = 0
|
||||
let result = 0
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63
|
||||
result |= (b & 0x1f) << shift
|
||||
shift += 5
|
||||
} while (b >= 0x20)
|
||||
|
||||
const dlat = (result & 1) !== 0 ? ~(result >> 1) : (result >> 1)
|
||||
lat += dlat
|
||||
|
||||
shift = 0
|
||||
result = 0
|
||||
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63
|
||||
result |= (b & 0x1f) << shift
|
||||
shift += 5
|
||||
} while (b >= 0x20)
|
||||
|
||||
const dlng = (result & 1) !== 0 ? ~(result >> 1) : (result >> 1)
|
||||
lng += dlng
|
||||
|
||||
points.push({
|
||||
latitude: lat / 1e6,
|
||||
longitude: lng / 1e6
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
/**
|
||||
* 步行路线规划
|
||||
* @param from 起点坐标
|
||||
* @param to 终点坐标
|
||||
* @returns 路线规划结果
|
||||
*/
|
||||
export async function planWalkingRoute(
|
||||
from: RoutePoint,
|
||||
to: RoutePoint
|
||||
): Promise<RouteResult> {
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${TENCENT_DIRECTION_BASE_URL}/walking`,
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
data: {
|
||||
from: `${from.longitude},${from.latitude}`,
|
||||
to: `${to.longitude},${to.latitude}`,
|
||||
key: TENCENT_MAP_KEY
|
||||
},
|
||||
success: (response) => {
|
||||
const data = response.data as any
|
||||
if (response.statusCode === 200 && data && data.status === 0) {
|
||||
const routes: Route[] = data.result.routes.map((route: any) => {
|
||||
const polyline = decodePolyline(route.polyline)
|
||||
return {
|
||||
id: route.id,
|
||||
startPoint: from,
|
||||
endPoint: to,
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
description: route.description,
|
||||
polyline,
|
||||
steps: route.steps.map((step: any) => ({
|
||||
startPoint: {
|
||||
latitude: step.start_point.lat,
|
||||
longitude: step.start_point.lng
|
||||
},
|
||||
endPoint: {
|
||||
latitude: step.end_point.lat,
|
||||
longitude: step.end_point.lng
|
||||
},
|
||||
instruction: step.instruction,
|
||||
distance: step.distance,
|
||||
duration: step.duration,
|
||||
direction: step.direction
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
resolve({
|
||||
status: 0,
|
||||
message: 'ok',
|
||||
routes
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
status: data?.status ?? -1,
|
||||
message: data?.message ?? '路线规划失败'
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('步行路线规划失败:', err)
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '网络请求失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 驾车路线规划
|
||||
* @param from 起点坐标
|
||||
* @param to 终点坐标
|
||||
* @returns 路线规划结果
|
||||
*/
|
||||
export async function planDrivingRoute(
|
||||
from: RoutePoint,
|
||||
to: RoutePoint
|
||||
): Promise<RouteResult> {
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${TENCENT_DIRECTION_BASE_URL}/driving`,
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
data: {
|
||||
from: `${from.longitude},${from.latitude}`,
|
||||
to: `${to.longitude},${to.latitude}`,
|
||||
key: TENCENT_MAP_KEY
|
||||
},
|
||||
success: (response) => {
|
||||
const data = response.data as any
|
||||
if (response.statusCode === 200 && data && data.status === 0) {
|
||||
const routes: Route[] = data.result.routes.map((route: any) => {
|
||||
const polyline = decodePolyline(route.polyline)
|
||||
return {
|
||||
id: route.id,
|
||||
startPoint: from,
|
||||
endPoint: to,
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
description: route.description,
|
||||
polyline,
|
||||
steps: route.steps.map((step: any) => ({
|
||||
startPoint: {
|
||||
latitude: step.start_point.lat,
|
||||
longitude: step.start_point.lng
|
||||
},
|
||||
endPoint: {
|
||||
latitude: step.end_point.lat,
|
||||
longitude: step.end_point.lng
|
||||
},
|
||||
instruction: step.instruction,
|
||||
distance: step.distance,
|
||||
duration: step.duration,
|
||||
direction: step.direction
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
resolve({
|
||||
status: 0,
|
||||
message: 'ok',
|
||||
routes
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
status: data?.status ?? -1,
|
||||
message: data?.message ?? '路线规划失败'
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('驾车路线规划失败:', err)
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '网络请求失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 公交路线规划
|
||||
* @param from 起点坐标
|
||||
* @param to 终点坐标
|
||||
* @returns 路线规划结果
|
||||
*/
|
||||
export async function planTransitRoute(
|
||||
from: RoutePoint,
|
||||
to: RoutePoint
|
||||
): Promise<RouteResult> {
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${TENCENT_DIRECTION_BASE_URL}/transit`,
|
||||
method: 'GET',
|
||||
timeout: 15000,
|
||||
data: {
|
||||
from: `${from.longitude},${from.latitude}`,
|
||||
to: `${to.longitude},${to.latitude}`,
|
||||
key: TENCENT_MAP_KEY,
|
||||
policy: 'LEAST_TIME' // 选择时间最短的路线
|
||||
},
|
||||
success: (response) => {
|
||||
const data = response.data as any
|
||||
if (response.statusCode === 200 && data && data.status === 0) {
|
||||
const routes: Route[] = data.result.routes.map((route: any, index: number) => {
|
||||
// 公交路线使用完整的路线点
|
||||
const allPoints: RoutePoint[] = []
|
||||
route.steps.forEach((step: any) => {
|
||||
if (step.polyline) {
|
||||
allPoints.push(...decodePolyline(step.polyline))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: index,
|
||||
startPoint: from,
|
||||
endPoint: to,
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
description: route.description,
|
||||
polyline: allPoints,
|
||||
steps: route.steps.map((step: any) => ({
|
||||
startPoint: {
|
||||
latitude: step.start_point.lat,
|
||||
longitude: step.start_point.lng
|
||||
},
|
||||
endPoint: {
|
||||
latitude: step.end_point.lat,
|
||||
longitude: step.end_point.lng
|
||||
},
|
||||
instruction: step.instruction,
|
||||
distance: step.distance,
|
||||
duration: step.duration,
|
||||
direction: step.direction
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
resolve({
|
||||
status: 0,
|
||||
message: 'ok',
|
||||
routes
|
||||
})
|
||||
} else {
|
||||
resolve({
|
||||
status: data?.status ?? -1,
|
||||
message: data?.message ?? '路线规划失败'
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('公交路线规划失败:', err)
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '网络请求失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索地点 POI
|
||||
* @param keyword 搜索关键词
|
||||
* @param region 搜索城市/区域
|
||||
* @param pageIndex 页码
|
||||
* @param pageSize 每页数量
|
||||
*/
|
||||
export function searchPoi(
|
||||
keyword: string,
|
||||
region: string = '深圳市',
|
||||
pageIndex: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<TencentSearchResult> {
|
||||
const boundary = `region(${region},0)`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${TENCENT_MAP_BASE_URL}/search`,
|
||||
method: 'GET',
|
||||
data: {
|
||||
keyword,
|
||||
boundary,
|
||||
key: TENCENT_MAP_KEY,
|
||||
page_index: pageIndex,
|
||||
page_size: pageSize
|
||||
},
|
||||
success: (response) => {
|
||||
const data = response.data as TencentSearchResult
|
||||
if (response.statusCode === 200 && data) {
|
||||
resolve(data)
|
||||
} else {
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '请求失败',
|
||||
count: 0,
|
||||
data: []
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('腾讯地图 POI 搜索失败:', err)
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '网络请求失败',
|
||||
count: 0,
|
||||
data: []
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索深圳自然博物馆入口
|
||||
* @returns 入口点位信息
|
||||
*/
|
||||
export async function searchMuseumEntrance(): Promise<TencentPoiResult | null> {
|
||||
// 先搜索博物馆
|
||||
const museumResult = await searchPoi('深圳自然博物馆', '深圳市', 1, 5)
|
||||
|
||||
if (museumResult.status !== 0 || museumResult.data.length === 0) {
|
||||
console.warn('未找到深圳自然博物馆')
|
||||
return null
|
||||
}
|
||||
|
||||
const museum = museumResult.data[0]
|
||||
console.log('找到深圳自然博物馆:', museum)
|
||||
|
||||
// 搜索博物馆周边的入口
|
||||
const entranceKeywords = ['入口', '出入口', '访客入口', '主入口']
|
||||
const entranceResults: TencentPoiResult[] = []
|
||||
|
||||
for (const keyword of entranceKeywords) {
|
||||
const result = await searchNearbyPoi(
|
||||
museum.location.lat,
|
||||
museum.location.lng,
|
||||
keyword,
|
||||
500
|
||||
)
|
||||
|
||||
if (result.status === 0 && result.data.length > 0) {
|
||||
entranceResults.push(...result.data)
|
||||
}
|
||||
}
|
||||
|
||||
if (entranceResults.length === 0) {
|
||||
return museum
|
||||
}
|
||||
|
||||
return entranceResults.sort((a, b) => (a.distance || 0) - (b.distance || 0))[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 周边搜索
|
||||
*/
|
||||
export function searchNearbyPoi(
|
||||
lat: number,
|
||||
lng: number,
|
||||
keyword: string,
|
||||
radius: number = 1000
|
||||
): Promise<TencentSearchResult> {
|
||||
const boundary = `nearby(${lat},${lng},${radius})`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${TENCENT_MAP_BASE_URL}/search`,
|
||||
method: 'GET',
|
||||
data: {
|
||||
keyword,
|
||||
boundary,
|
||||
key: TENCENT_MAP_KEY,
|
||||
page_size: 10
|
||||
},
|
||||
success: (response) => {
|
||||
const data = response.data as TencentSearchResult
|
||||
if (response.statusCode === 200 && data) {
|
||||
resolve(data)
|
||||
} else {
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '请求失败',
|
||||
count: 0,
|
||||
data: []
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('腾讯地图周边搜索失败:', err)
|
||||
resolve({
|
||||
status: -1,
|
||||
message: '网络请求失败',
|
||||
count: 0,
|
||||
data: []
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取深圳自然博物馆入口坐标
|
||||
*/
|
||||
export async function getMuseumEntranceLocation(): Promise<{ lat: number; lng: number } | null> {
|
||||
const entrance = await searchMuseumEntrance()
|
||||
|
||||
if (entrance) {
|
||||
console.log('深圳自然博物馆入口位置:', {
|
||||
name: entrance.title,
|
||||
address: entrance.address,
|
||||
lat: entrance.location.lat,
|
||||
lng: entrance.location.lng
|
||||
})
|
||||
return entrance.location
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user