Files
frontend-miniapp/src/components/navigation/OutdoorNavigationPanel.vue

1005 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
>
<view class="start-btn-icon icon-gps">
<view class="icon-gps-dot"></view>
</view>
<text class="start-btn-text">GPS定位</text>
</view>
<view
class="start-btn manual-btn"
:class="{ active: effectiveStartMode === 'manual' }"
@tap="handleManualStart"
>
<view class="start-btn-icon icon-manual">
<view class="icon-manual-line"></view>
</view>
<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 }, `mode-${mode.type}`]"
@tap="handleTravelModeChange(mode.type)"
>
<view class="travel-mode-icon"></view>
<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: 15px;
right: 15px;
bottom: calc(env(safe-area-inset-bottom) + 106px);
padding: 12px 15px 15px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-card);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
z-index: 1003;
}
.outdoor-nav-panel.collapsed {
padding: 10px 12px 12px;
}
.panel-handle {
width: 38px;
height: 4px;
margin: 0 auto 12px;
background: #d9d9d9;
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 12px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: var(--radius-button);
}
.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: 18px;
line-height: 25px;
font-weight: 500;
color: var(--museum-text-primary);
}
.panel-summary {
font-size: 12px;
line-height: 18px;
color: var(--museum-text-tertiary);
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: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
}
.panel-light-action-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-text-secondary);
}
.start-point-section {
margin-top: 16px;
}
.section-label {
margin-bottom: 8px;
}
.section-label-text {
font-size: 13px;
line-height: 18px;
font-weight: 600;
color: var(--museum-text-secondary);
}
.start-point-buttons {
display: flex;
gap: 10px;
}
.start-btn {
flex: 1;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
background: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
transition: all 0.2s;
}
.start-btn.active {
background: #000000;
border-color: #000000;
}
.start-btn-icon {
position: relative;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--museum-text-primary);
box-sizing: border-box;
}
.start-btn.active .start-btn-icon {
color: var(--museum-accent);
}
.icon-gps {
border: 1.8px solid currentColor;
border-radius: 50%;
}
.icon-gps::before,
.icon-gps::after {
content: '';
position: absolute;
background: currentColor;
}
.icon-gps::before {
left: 8px;
top: -4px;
width: 1.8px;
height: 24px;
}
.icon-gps::after {
left: -4px;
top: 8px;
width: 24px;
height: 1.8px;
}
.icon-gps-dot {
position: absolute;
left: 5px;
top: 5px;
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
}
.icon-manual {
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-45deg);
}
.icon-manual::before {
content: '';
position: absolute;
right: 0;
bottom: -2px;
width: 7px;
height: 7px;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
}
.icon-manual-line {
position: absolute;
left: 3px;
bottom: 5px;
width: 15px;
height: 2px;
background: currentColor;
}
.start-btn-text {
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: var(--museum-text-primary);
}
.start-btn.active .start-btn-text {
color: var(--museum-accent);
}
.start-hint {
margin-top: 8px;
padding: 10px 12px;
background: var(--museum-bg-light);
border: 1px solid var(--museum-border-light);
border-radius: var(--radius-button);
}
.start-hint-text {
font-size: 12px;
line-height: 17px;
color: var(--museum-text-disabled);
}
.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;
color: var(--museum-text-primary);
background: var(--museum-bg-cream);
border: 1px solid #adb0b4;
border-radius: var(--radius-button);
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: var(--museum-text-disabled);
}
.start-search-results {
margin-top: 8px;
background: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
overflow: hidden;
}
.search-result-item {
padding: 12px;
border-bottom: 1px solid #d9d9d9;
cursor: pointer;
}
.search-result-item:last-child {
border-bottom: none;
}
.result-title {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--museum-text-primary);
margin-bottom: 4px;
}
.result-address {
display: block;
font-size: 12px;
color: var(--museum-text-disabled);
}
.start-point-display {
position: relative;
margin-top: 8px;
padding: 10px 10px 10px 30px;
display: flex;
align-items: center;
gap: 10px;
background: var(--museum-bg-light);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
}
.point-dot {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
}
.point-dot.start {
background: #2c854f;
}
.point-name {
flex: 1;
font-size: 14px;
line-height: 20px;
font-weight: 600;
color: var(--museum-text-primary);
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: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
}
.point-change-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-text-secondary);
}
.travel-mode-section {
margin-top: 16px;
}
.travel-mode-list {
margin-top: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.travel-mode-item {
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
background: var(--museum-bg-surface);
border: 1px solid #d9d9d9;
border-radius: var(--radius-button);
transition: all 0.2s;
}
.travel-mode-item.active {
background: #000000;
border-color: #000000;
}
.travel-mode-icon {
position: relative;
width: 22px;
height: 18px;
color: var(--museum-text-primary);
box-sizing: border-box;
}
.travel-mode-item.active .travel-mode-icon {
color: var(--museum-accent);
}
.mode-walking .travel-mode-icon::before {
content: '';
position: absolute;
left: 8px;
top: 0;
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
}
.mode-walking .travel-mode-icon::after {
content: '';
position: absolute;
left: 6px;
top: 7px;
width: 10px;
height: 10px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: skewX(-18deg);
}
.mode-driving .travel-mode-icon {
border: 2px solid currentColor;
border-radius: 4px 4px 3px 3px;
}
.mode-driving .travel-mode-icon::before,
.mode-driving .travel-mode-icon::after {
content: '';
position: absolute;
bottom: -5px;
width: 5px;
height: 5px;
background: currentColor;
border-radius: 50%;
}
.mode-driving .travel-mode-icon::before {
left: 2px;
}
.mode-driving .travel-mode-icon::after {
right: 2px;
}
.mode-transit .travel-mode-icon {
border: 2px solid currentColor;
border-radius: 3px;
}
.mode-transit .travel-mode-icon::before {
content: '';
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: 5px;
border-top: 2px solid currentColor;
border-bottom: 2px solid currentColor;
}
.travel-mode-label {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: var(--museum-text-primary);
}
.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: var(--museum-text-tertiary);
}
.panel-status-text.error {
color: #e73333;
}
.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: var(--radius-button);
transition: all 0.2s;
}
.nav-btn.primary {
flex: 2;
background: #000000;
}
.nav-btn.secondary {
flex: 1;
background: var(--museum-bg-surface);
border: 1px solid #000000;
}
.nav-btn.disabled {
background: #000000;
border-color: #000000;
opacity: 0.45;
}
.nav-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.nav-btn.secondary .nav-btn-text {
color: var(--museum-text-primary);
}
.nav-btn.disabled .nav-btn-text {
color: var(--museum-accent);
}
.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>