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>