735 lines
16 KiB
Vue
735 lines
16 KiB
Vue
<template>
|
|
<view
|
|
v-if="visible"
|
|
class="route-planner-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="summary" class="panel-summary">{{ summary }}</text>
|
|
</view>
|
|
<view class="panel-actions">
|
|
<view class="panel-light-action" @tap="handleBack">
|
|
<text class="panel-light-action-text">返回</text>
|
|
</view>
|
|
<view class="panel-light-action" @tap="collapsePanel">
|
|
<text class="panel-light-action-text">收起</text>
|
|
</view>
|
|
<view class="panel-light-action" @tap="handleClear">
|
|
<text class="panel-light-action-text">清除</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view class="point-row">
|
|
<view class="point-card" @tap="openPicker('start')">
|
|
<view class="point-dot start"></view>
|
|
<text class="point-label">起点</text>
|
|
<text class="point-name" :class="{ placeholder: !startPoint }">
|
|
{{ startPoint?.name || '选择起点' }}
|
|
</text>
|
|
<text v-if="startPoint" class="point-meta">{{ pointMeta(startPoint) }}</text>
|
|
</view>
|
|
|
|
<view class="swap-btn" @tap="handleSwap">
|
|
<text class="swap-text">⇅</text>
|
|
</view>
|
|
|
|
<view class="point-card" @tap="openPicker('end')">
|
|
<view class="point-dot end"></view>
|
|
<text class="point-label">终点</text>
|
|
<text class="point-name" :class="{ placeholder: !endPoint }">
|
|
{{ endPoint?.name || '选择终点' }}
|
|
</text>
|
|
<text v-if="endPoint" class="point-meta">{{ pointMeta(endPoint) }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="startPoint && endPoint" class="route-options">
|
|
<view class="route-options-header">
|
|
<text class="route-options-title">{{ routeOptionsTitle }}</text>
|
|
<text class="route-options-note">偏好选择</text>
|
|
</view>
|
|
<view class="route-option-list">
|
|
<view
|
|
v-for="option in routeOptions"
|
|
:key="option.id"
|
|
class="route-option"
|
|
:class="{ active: option.active, disabled: option.disabled }"
|
|
>
|
|
<text class="route-option-title">{{ option.title }}</text>
|
|
<text class="route-option-meta">{{ option.meta }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="loading || error || summary" class="panel-status">
|
|
<text v-if="loading" class="panel-status-text">{{ loadingText }}</text>
|
|
<text v-else-if="error" class="panel-status-text error">{{ error }}</text>
|
|
<text v-else class="panel-status-text">{{ summary }}</text>
|
|
</view>
|
|
|
|
<view
|
|
class="view-route-btn"
|
|
:class="{ disabled: !canPrimaryAction }"
|
|
@tap="handlePrimaryAction"
|
|
>
|
|
<text class="view-route-text">{{ primaryActionText }}</text>
|
|
</view>
|
|
</template>
|
|
|
|
<RoutePointPicker
|
|
:visible="pickerMode !== ''"
|
|
:title="pickerTitle"
|
|
:placeholder="pickerPlaceholder"
|
|
:options="pointOptions"
|
|
:selected-poi-id="activeSelectedPoiId"
|
|
:loading="pickerLoading"
|
|
:error="pickerError"
|
|
:empty-text="pickerEmptyText"
|
|
close-text="返回"
|
|
@close="closePicker"
|
|
@select="handlePointSelect"
|
|
@search="handlePickerSearch"
|
|
@keyword-change="handlePickerKeywordChange"
|
|
/>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import RoutePointPicker, {
|
|
type RoutePointOption
|
|
} from '@/components/navigation/RoutePointPicker.vue'
|
|
|
|
type PickerMode = '' | 'start' | 'end'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
visible?: boolean
|
|
startPoint?: RoutePointOption | null
|
|
endPoint?: RoutePointOption | null
|
|
pointOptions?: RoutePointOption[]
|
|
hasRoutePreview?: boolean
|
|
loading?: boolean
|
|
error?: string
|
|
summary?: string
|
|
pickerLoading?: boolean
|
|
pickerError?: string
|
|
pickerEmptyText?: string
|
|
routeReady?: boolean
|
|
}>(), {
|
|
visible: true,
|
|
startPoint: null,
|
|
endPoint: null,
|
|
pointOptions: () => [] as RoutePointOption[],
|
|
hasRoutePreview: false,
|
|
loading: false,
|
|
error: '',
|
|
summary: '',
|
|
pickerLoading: false,
|
|
pickerError: '',
|
|
pickerEmptyText: '暂无匹配地点',
|
|
routeReady: false
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:startPoint': [point: RoutePointOption | null]
|
|
'update:endPoint': [point: RoutePointOption | null]
|
|
startChange: [point: RoutePointOption]
|
|
endChange: [point: RoutePointOption]
|
|
pickerOpen: [mode: 'start' | 'end']
|
|
pickerClose: []
|
|
search: [payload: { mode: 'start' | 'end'; keyword: string }]
|
|
swap: []
|
|
clear: []
|
|
viewRoute: [payload: { startPoint: RoutePointOption; endPoint: RoutePointOption }]
|
|
simulateGuide: []
|
|
back: []
|
|
}>()
|
|
|
|
const pickerMode = ref<PickerMode>('')
|
|
const isCollapsed = ref(false)
|
|
const panelTouchStartY = ref(0)
|
|
const panelTouchCurrentY = ref(0)
|
|
|
|
const pickerTitle = computed(() => (
|
|
pickerMode.value === 'start' ? '选择起点' : '选择终点'
|
|
))
|
|
|
|
const pickerPlaceholder = computed(() => (
|
|
pickerMode.value === 'start' ? '搜索起点' : '搜索终点'
|
|
))
|
|
|
|
const activeSelectedPoiId = computed(() => (
|
|
pickerMode.value === 'start'
|
|
? props.startPoint?.poiId || ''
|
|
: props.endPoint?.poiId || ''
|
|
))
|
|
|
|
const canViewRoute = computed(() => Boolean(
|
|
props.startPoint
|
|
&& props.endPoint
|
|
&& !props.loading
|
|
&& props.startPoint.poiId !== props.endPoint.poiId
|
|
))
|
|
|
|
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
|
|
|
|
const primaryActionText = computed(() => (
|
|
props.hasRoutePreview
|
|
? '开始导览'
|
|
: (props.routeReady ? '查看路线' : '查看位置关系')
|
|
))
|
|
|
|
const routeOptionsTitle = computed(() => '馆内导览')
|
|
|
|
const loadingText = computed(() => (
|
|
props.routeReady ? '正在规划馆内导览路线' : '正在生成馆内位置关系'
|
|
))
|
|
|
|
const collapsedSummary = computed(() => {
|
|
if (props.startPoint && props.endPoint) {
|
|
return `${props.startPoint.name} → ${props.endPoint.name}`
|
|
}
|
|
|
|
return '手动选择起点和终点'
|
|
})
|
|
|
|
const routeOptions = computed(() => {
|
|
const unavailable = Boolean(props.error)
|
|
const summaryText = props.summary || (props.routeReady ? '选择后规划真实路线' : '选择后查看位置关系')
|
|
return [
|
|
{
|
|
id: 'recommended',
|
|
title: '推荐路线',
|
|
meta: unavailable
|
|
? '当前暂不可用'
|
|
: props.hasRoutePreview
|
|
? summaryText
|
|
: props.routeReady
|
|
? '调用 SGS SDK 路线规划'
|
|
: '查看起点终点位置关系',
|
|
active: !unavailable,
|
|
disabled: unavailable
|
|
},
|
|
{
|
|
id: 'stairs',
|
|
title: '少走楼梯',
|
|
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
|
active: false,
|
|
disabled: true
|
|
},
|
|
{
|
|
id: 'elevator',
|
|
title: '电梯优先',
|
|
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
|
active: false,
|
|
disabled: true
|
|
}
|
|
]
|
|
})
|
|
|
|
watch(
|
|
() => props.visible,
|
|
(visible) => {
|
|
if (visible) {
|
|
isCollapsed.value = false
|
|
} else {
|
|
pickerMode.value = ''
|
|
}
|
|
}
|
|
)
|
|
|
|
const pointMeta = (point: RoutePointOption) => {
|
|
return point.categoryLabel
|
|
? `${point.floorLabel} · ${point.categoryLabel}`
|
|
: point.floorLabel
|
|
}
|
|
|
|
const openPicker = (mode: 'start' | 'end') => {
|
|
pickerMode.value = mode
|
|
emit('search', { mode, keyword: '' })
|
|
emit('pickerOpen', mode)
|
|
}
|
|
|
|
const closePicker = () => {
|
|
pickerMode.value = ''
|
|
emit('pickerClose')
|
|
}
|
|
|
|
const handlePointSelect = (point: RoutePointOption) => {
|
|
if (pickerMode.value === 'start') {
|
|
emit('update:startPoint', point)
|
|
emit('startChange', point)
|
|
} else if (pickerMode.value === 'end') {
|
|
emit('update:endPoint', point)
|
|
emit('endChange', point)
|
|
}
|
|
|
|
closePicker()
|
|
}
|
|
|
|
const handlePickerSearch = (keyword: string) => {
|
|
if (!pickerMode.value) return
|
|
emit('search', { mode: pickerMode.value, keyword })
|
|
}
|
|
|
|
const handlePickerKeywordChange = (keyword: string) => {
|
|
if (!pickerMode.value) return
|
|
emit('search', { mode: pickerMode.value, keyword })
|
|
}
|
|
|
|
const handleSwap = () => {
|
|
emit('swap')
|
|
}
|
|
|
|
const handleClear = () => {
|
|
emit('clear')
|
|
}
|
|
|
|
const handleBack = () => {
|
|
if (pickerMode.value) {
|
|
closePicker()
|
|
return
|
|
}
|
|
|
|
emit('back')
|
|
}
|
|
|
|
const collapsePanel = () => {
|
|
isCollapsed.value = true
|
|
}
|
|
|
|
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) => {
|
|
if (pickerMode.value) return
|
|
const clientY = getGestureClientY(event)
|
|
panelTouchStartY.value = clientY
|
|
panelTouchCurrentY.value = clientY
|
|
}
|
|
|
|
const handlePanelTouchMove = (event: TouchEvent) => {
|
|
if (pickerMode.value) return
|
|
panelTouchCurrentY.value = getGestureClientY(event)
|
|
}
|
|
|
|
const handlePanelTouchEnd = (event: TouchEvent) => {
|
|
if (pickerMode.value) return
|
|
panelTouchCurrentY.value = getGestureClientY(event)
|
|
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
|
collapsePanel()
|
|
}
|
|
}
|
|
|
|
const handlePanelMouseStart = (event: MouseEvent) => {
|
|
if (pickerMode.value) return
|
|
const clientY = getGestureClientY(event)
|
|
panelTouchStartY.value = clientY
|
|
panelTouchCurrentY.value = clientY
|
|
}
|
|
|
|
const handlePanelMouseMove = (event: MouseEvent) => {
|
|
if (pickerMode.value) return
|
|
panelTouchCurrentY.value = getGestureClientY(event)
|
|
}
|
|
|
|
const handlePanelMouseEnd = (event: MouseEvent) => {
|
|
if (pickerMode.value) return
|
|
panelTouchCurrentY.value = getGestureClientY(event)
|
|
if (panelTouchCurrentY.value - panelTouchStartY.value > 48) {
|
|
collapsePanel()
|
|
}
|
|
}
|
|
|
|
const handleViewRoute = () => {
|
|
if (!canViewRoute.value || !props.startPoint || !props.endPoint) return
|
|
|
|
emit('viewRoute', {
|
|
startPoint: props.startPoint,
|
|
endPoint: props.endPoint
|
|
})
|
|
}
|
|
|
|
const handlePrimaryAction = () => {
|
|
if (!canViewRoute.value) return
|
|
if (props.hasRoutePreview) {
|
|
emit('simulateGuide')
|
|
return
|
|
}
|
|
|
|
handleViewRoute()
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.route-planner-panel {
|
|
position: absolute;
|
|
left: 12px;
|
|
right: 12px;
|
|
bottom: calc(env(safe-area-inset-bottom) + 112px);
|
|
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;
|
|
}
|
|
|
|
.route-planner-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;
|
|
}
|
|
|
|
.point-row {
|
|
position: relative;
|
|
margin-top: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding-right: 52px;
|
|
}
|
|
|
|
.point-card {
|
|
position: relative;
|
|
min-width: 0;
|
|
min-height: 52px;
|
|
padding: 8px 10px 8px 34px;
|
|
display: grid;
|
|
grid-template-columns: 42px minmax(0, 1fr);
|
|
column-gap: 8px;
|
|
align-items: center;
|
|
box-sizing: border-box;
|
|
background: #f6f7f4;
|
|
border: 1px solid #e5e6de;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.point-dot {
|
|
position: absolute;
|
|
left: 12px;
|
|
top: 21px;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.point-dot.start {
|
|
background: #1fbf6b;
|
|
}
|
|
|
|
.point-dot.end {
|
|
background: #ef5552;
|
|
}
|
|
|
|
.point-label {
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: #8b8b84;
|
|
}
|
|
|
|
.point-name {
|
|
margin-top: 0;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
font-weight: 600;
|
|
color: #1f2329;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.point-name.placeholder {
|
|
color: #545861;
|
|
}
|
|
|
|
.point-meta {
|
|
grid-column: 2;
|
|
margin-top: -2px;
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: #696962;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.swap-btn {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
width: 42px;
|
|
height: 112px;
|
|
min-height: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
background: #ffffff;
|
|
border: 1px solid #d7dad3;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.swap-text {
|
|
font-size: 22px;
|
|
line-height: 24px;
|
|
font-weight: 500;
|
|
color: #151713;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.view-route-btn {
|
|
margin-top: 12px;
|
|
height: 42px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #151713;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.view-route-btn.disabled {
|
|
background: #d8dbd2;
|
|
}
|
|
|
|
.view-route-text {
|
|
font-size: 14px;
|
|
line-height: 19px;
|
|
font-weight: 500;
|
|
color: var(--museum-accent);
|
|
}
|
|
|
|
.view-route-btn.disabled .view-route-text {
|
|
color: #8b8b84;
|
|
}
|
|
|
|
.route-options {
|
|
margin-top: 12px;
|
|
padding: 10px;
|
|
background: #f9fafb;
|
|
border: 1px solid #ecede7;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.route-options-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.route-options-title {
|
|
font-size: 13px;
|
|
line-height: 18px;
|
|
font-weight: 700;
|
|
color: #151713;
|
|
}
|
|
|
|
.route-options-note {
|
|
font-size: 11px;
|
|
line-height: 15px;
|
|
color: #8b8b84;
|
|
}
|
|
|
|
.route-option-list {
|
|
margin-top: 8px;
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 6px;
|
|
}
|
|
|
|
.route-option {
|
|
min-width: 0;
|
|
min-height: 56px;
|
|
padding: 8px 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
box-sizing: border-box;
|
|
background: #ffffff;
|
|
border: 1px solid #e5e6de;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.route-option.active {
|
|
border-color: #151713;
|
|
background: #ffffff;
|
|
}
|
|
|
|
.route-option.disabled {
|
|
opacity: 0.68;
|
|
}
|
|
|
|
.route-option-title {
|
|
font-size: 12px;
|
|
line-height: 16px;
|
|
font-weight: 700;
|
|
color: #151713;
|
|
text-align: center;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.route-option-meta {
|
|
font-size: 10px;
|
|
line-height: 14px;
|
|
color: #696962;
|
|
text-align: center;
|
|
display: -webkit-box;
|
|
overflow: hidden;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
</style>
|