feat: 新增 SGS SDK 数据适配器与腾讯地图服务

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lyf
2026-06-26 04:21:46 +08:00
parent 15fbbec12d
commit cc7c21a285
13 changed files with 2938 additions and 0 deletions

View 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
View 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

View 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
}
}

View 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
}

View 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()

View 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()

View 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()

View 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 '将调起手机地图应用进行导航'
}

View 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
}