chore: freeze guide and explain update
This commit is contained in:
@@ -25,6 +25,8 @@
|
||||
:show-controls="false"
|
||||
:show-poi="shouldShowIndoorPois"
|
||||
:target-focus="targetFocusRequest"
|
||||
:route-preview="routePreview"
|
||||
:show-route="showRoute"
|
||||
@floor-change="handleThreeFloorChange"
|
||||
@poi-click="handlePoiClick"
|
||||
@selection-clear="handleSelectionClear"
|
||||
@@ -49,15 +51,31 @@
|
||||
<view v-if="dimmed" class="dim-layer"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="showSearch" class="guide-search-field" :style="searchFieldStyle" @tap="handleSearchTap">
|
||||
<view
|
||||
v-if="showSearch"
|
||||
class="guide-search-field"
|
||||
:class="`variant-${searchVariant}`"
|
||||
:style="searchFieldStyle"
|
||||
@tap="handleSearchTap"
|
||||
>
|
||||
<view v-if="searchVariant === 'home'" class="search-icon leading">
|
||||
<view class="search-icon-circle"></view>
|
||||
<view class="search-icon-handle"></view>
|
||||
</view>
|
||||
<text class="search-field-text">{{ searchText }}</text>
|
||||
<view class="search-icon">
|
||||
<view v-if="showVoiceIcon" class="voice-icon">
|
||||
<view class="voice-head"></view>
|
||||
<view class="voice-stem"></view>
|
||||
<view class="voice-base"></view>
|
||||
</view>
|
||||
<view v-else-if="searchVariant !== 'home'" class="search-icon">
|
||||
<view class="search-icon-circle"></view>
|
||||
<view class="search-icon-handle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="showModeRow"
|
||||
class="guide-mode-row"
|
||||
:class="`layout-${modeLayout}`"
|
||||
:style="modeRowStyle"
|
||||
@@ -112,7 +130,16 @@
|
||||
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showIndoorRightControls && showFloor" class="floor-switcher" :style="floorSwitcherStyle">
|
||||
<view
|
||||
v-if="showIndoorRightControls && showFloor"
|
||||
class="floor-switcher"
|
||||
:class="`side-${floorSide}`"
|
||||
:style="floorSwitcherStyle"
|
||||
>
|
||||
<view v-if="showFloorHeader" class="floor-header">
|
||||
<text class="floor-header-icon">▱</text>
|
||||
<text class="floor-header-label">楼层</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="floor in floorLabels"
|
||||
:key="floor"
|
||||
@@ -124,6 +151,25 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
|
||||
<view class="zoom-btn" @tap="handleZoomClick('in')">
|
||||
<text class="zoom-text">+</text>
|
||||
</view>
|
||||
<view class="zoom-divider"></view>
|
||||
<view class="zoom-btn" @tap="handleZoomClick('out')">
|
||||
<text class="zoom-text">−</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showLocationControl" class="location-control" :style="locationControlStyle" @tap="handleResetView">
|
||||
<view class="location-ring"></view>
|
||||
<view class="location-dot"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="showMoreControl" class="more-control" :style="moreControlStyle" @tap="handleMoreTap">
|
||||
<text class="more-control-text">更多</text>
|
||||
</view>
|
||||
|
||||
<view v-if="tools.length" class="tool-stack" :style="toolStackStyle">
|
||||
<view
|
||||
v-for="tool in tools"
|
||||
@@ -153,6 +199,9 @@ import type {
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import type {
|
||||
GuideRouteResult
|
||||
} from '@/domain/museum'
|
||||
|
||||
interface GuideFloorOption {
|
||||
id: string
|
||||
@@ -192,6 +241,17 @@ const props = withDefaults(defineProps<{
|
||||
layerModeToggleTop?: string
|
||||
toolsTop?: string
|
||||
tools?: string[]
|
||||
searchVariant?: 'default' | 'home'
|
||||
showVoiceIcon?: boolean
|
||||
showModeRow?: boolean
|
||||
showFloorHeader?: boolean
|
||||
showZoomControls?: boolean
|
||||
showLocationControl?: boolean
|
||||
showMoreControl?: boolean
|
||||
floorSide?: 'left' | 'right'
|
||||
zoomControlsTop?: string
|
||||
locationControlBottom?: string
|
||||
moreControlBottom?: string
|
||||
showSearch?: boolean
|
||||
showFloor?: boolean
|
||||
showLayerModeToggle?: boolean
|
||||
@@ -207,6 +267,8 @@ const props = withDefaults(defineProps<{
|
||||
normalizeFloorId?: (labelOrId: string) => string
|
||||
dimmed?: boolean
|
||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||
routePreview?: GuideRouteResult | null
|
||||
showRoute?: boolean
|
||||
}>(), {
|
||||
searchText: '搜索设施、展厅、入口',
|
||||
activeMode: '3d',
|
||||
@@ -219,6 +281,17 @@ const props = withDefaults(defineProps<{
|
||||
layerModeToggleTop: '154px',
|
||||
toolsTop: '406px',
|
||||
tools: () => [] as string[],
|
||||
searchVariant: 'default',
|
||||
showVoiceIcon: false,
|
||||
showModeRow: true,
|
||||
showFloorHeader: false,
|
||||
showZoomControls: false,
|
||||
showLocationControl: false,
|
||||
showMoreControl: false,
|
||||
floorSide: 'right',
|
||||
zoomControlsTop: '520px',
|
||||
locationControlBottom: '34px',
|
||||
moreControlBottom: '34px',
|
||||
showSearch: true,
|
||||
showFloor: true,
|
||||
showLayerModeToggle: false,
|
||||
@@ -233,7 +306,9 @@ const props = withDefaults(defineProps<{
|
||||
floors: () => [] as GuideFloorOption[],
|
||||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||||
dimmed: false,
|
||||
targetFocusRequest: null
|
||||
targetFocusRequest: null,
|
||||
routePreview: null,
|
||||
showRoute: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -241,6 +316,7 @@ const emit = defineEmits<{
|
||||
modeChange: [mode: '2d' | '3d']
|
||||
floorChange: [floor: string]
|
||||
toolClick: [tool: string]
|
||||
moreClick: []
|
||||
indoorViewChange: [view: IndoorViewMode]
|
||||
layerModeChange: [mode: LayerDisplayMode]
|
||||
poiClick: [poi: GuideRenderPoi]
|
||||
@@ -290,6 +366,18 @@ const toolStackStyle = computed(() => ({
|
||||
top: props.toolsTop
|
||||
}))
|
||||
|
||||
const zoomControlsStyle = computed(() => ({
|
||||
top: props.zoomControlsTop
|
||||
}))
|
||||
|
||||
const locationControlStyle = computed(() => ({
|
||||
bottom: props.locationControlBottom
|
||||
}))
|
||||
|
||||
const moreControlStyle = computed(() => ({
|
||||
bottom: props.moreControlBottom
|
||||
}))
|
||||
|
||||
const shouldShowIndoorPois = computed(() => (
|
||||
props.mapType === 'indoor'
|
||||
) || Boolean(props.targetFocusRequest))
|
||||
@@ -354,6 +442,19 @@ const handleToolClick = (tool: string) => {
|
||||
emit('toolClick', tool)
|
||||
}
|
||||
|
||||
const handleResetView = () => {
|
||||
indoorRendererRef.value?.resetCamera?.()
|
||||
emit('toolClick', '重置')
|
||||
}
|
||||
|
||||
const handleZoomClick = (direction: 'in' | 'out') => {
|
||||
emit('toolClick', direction === 'in' ? '放大' : '缩小')
|
||||
}
|
||||
|
||||
const handleMoreTap = () => {
|
||||
emit('moreClick')
|
||||
}
|
||||
|
||||
const handleThreeFloorChange = (floorId: string) => {
|
||||
const floor = props.floors.find((item) => item.id === floorId)
|
||||
emit('floorChange', floor?.label || floorId)
|
||||
@@ -475,6 +576,17 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.guide-search-field.variant-home {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #d7dad3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 22px rgba(36, 49, 42, 0.08);
|
||||
}
|
||||
|
||||
.search-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -486,6 +598,13 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.variant-home .search-field-text {
|
||||
padding: 0 10px;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
color: #8b9097;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
@@ -493,6 +612,64 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-icon.leading {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.search-icon.leading .search-icon-circle {
|
||||
top: 2px;
|
||||
left: 1px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.search-icon.leading .search-icon-handle {
|
||||
left: 14px;
|
||||
top: 15px;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.voice-icon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 24px;
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.voice-head {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 1px;
|
||||
width: 10px;
|
||||
height: 15px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #151713;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.voice-stem {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 15px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.voice-base {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
bottom: 0;
|
||||
width: 12px;
|
||||
height: 2px;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.search-icon-circle {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
@@ -607,7 +784,6 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
|
||||
.floor-switcher {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 46px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
@@ -621,6 +797,49 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.floor-switcher.side-right {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.floor-switcher.side-left {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.floor-header {
|
||||
min-height: 50px;
|
||||
padding: 7px 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.floor-header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
right: 7px;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: #ecede7;
|
||||
}
|
||||
|
||||
.floor-header-icon {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.floor-header-label {
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.layer-mode-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
@@ -718,6 +937,110 @@ const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' |
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
width: 48px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-divider {
|
||||
height: 1px;
|
||||
margin: 0 9px;
|
||||
background: #ecede7;
|
||||
}
|
||||
|
||||
.zoom-text {
|
||||
font-size: 28px;
|
||||
line-height: 32px;
|
||||
font-weight: 300;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.location-control,
|
||||
.more-control {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.location-control {
|
||||
left: 18px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.location-ring {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #151713;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.location-ring::before,
|
||||
.location-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: #151713;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.location-ring::before {
|
||||
left: 23px;
|
||||
top: 10px;
|
||||
width: 2px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.location-ring::after {
|
||||
left: 10px;
|
||||
top: 23px;
|
||||
width: 28px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.location-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #151713;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.more-control {
|
||||
right: 18px;
|
||||
min-width: 54px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.more-control-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.guide-map-shell {
|
||||
max-width: 430px;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<view class="guide-page-frame" :class="`variant-${variant}`">
|
||||
<GuideTopTabs
|
||||
:active-tab="activeTab"
|
||||
:variant="topTabsVariant"
|
||||
:top="topTabsTop"
|
||||
@tab-change="handleTabChange"
|
||||
/>
|
||||
<view
|
||||
@@ -36,6 +38,8 @@ import type { GuideTopTab } from '@/utils/guideTopTabs'
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
variant?: 'overlay' | 'static'
|
||||
topTabsVariant?: 'underline' | 'segmented'
|
||||
topTabsTop?: string
|
||||
showBack?: boolean
|
||||
showCancel?: boolean
|
||||
backLabel?: string
|
||||
@@ -43,6 +47,8 @@ withDefaults(defineProps<{
|
||||
}>(), {
|
||||
activeTab: 'guide',
|
||||
variant: 'overlay',
|
||||
topTabsVariant: 'underline',
|
||||
topTabsTop: '0',
|
||||
showBack: false,
|
||||
showCancel: false,
|
||||
backLabel: '返回',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<view class="guide-top-tabs">
|
||||
<view
|
||||
class="guide-top-tabs"
|
||||
:class="`variant-${variant}`"
|
||||
:style="{ top }"
|
||||
>
|
||||
<view
|
||||
v-for="tab in GUIDE_TOP_TABS"
|
||||
:key="tab.id"
|
||||
@@ -7,7 +11,10 @@
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@tap="handleTabTap(tab.id)"
|
||||
>
|
||||
<view v-if="activeTab === tab.id" class="guide-top-tab-indicator"></view>
|
||||
<view
|
||||
v-if="activeTab === tab.id && variant === 'underline'"
|
||||
class="guide-top-tab-indicator"
|
||||
></view>
|
||||
<text class="guide-top-tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -21,8 +28,12 @@ import {
|
||||
|
||||
withDefaults(defineProps<{
|
||||
activeTab?: GuideTopTab
|
||||
variant?: 'underline' | 'segmented'
|
||||
top?: string
|
||||
}>(), {
|
||||
activeTab: 'guide'
|
||||
activeTab: 'guide',
|
||||
variant: 'underline',
|
||||
top: '0'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -37,7 +48,6 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
<style scoped lang="scss">
|
||||
.guide-top-tabs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 44px;
|
||||
@@ -53,6 +63,19 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.guide-top-tabs.variant-segmented {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 174px;
|
||||
height: 38px;
|
||||
padding: 2px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #151713;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 20px rgba(36, 49, 42, 0.08);
|
||||
transform: translateX(-50%) translateZ(0);
|
||||
}
|
||||
|
||||
.guide-top-tab {
|
||||
position: relative;
|
||||
width: 75px;
|
||||
@@ -62,6 +85,17 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab.active {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.guide-top-tab-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -76,6 +110,16 @@ const handleTabTap = (tab: GuideTopTab) => {
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab-label {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.variant-segmented .guide-top-tab.active .guide-top-tab-label {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.guide-top-tab-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
|
||||
<text class="action-text">查看室外地图</text>
|
||||
<text class="action-text">室外入口参考</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleShowTargetLocation">
|
||||
<text class="action-text">重新定位目标</text>
|
||||
<text class="action-text">查看三维位置</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
421
src/components/navigation/RoutePlannerPanel.vue
Normal file
421
src/components/navigation/RoutePlannerPanel.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<view v-if="visible" class="route-planner-panel">
|
||||
<view class="panel-handle"></view>
|
||||
<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-clear" @tap="handleClear">
|
||||
<text class="panel-clear-text">清除</text>
|
||||
</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="loading || error || summary" class="panel-status">
|
||||
<text v-if="loading" class="panel-status-text">路径加载中</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: !canViewRoute }"
|
||||
@tap="handlePrimaryAction"
|
||||
>
|
||||
<text class="view-route-text">{{ primaryActionText }}</text>
|
||||
</view>
|
||||
|
||||
<RoutePointPicker
|
||||
:visible="pickerMode !== ''"
|
||||
:title="pickerTitle"
|
||||
:placeholder="pickerPlaceholder"
|
||||
:options="pointOptions"
|
||||
:selected-poi-id="activeSelectedPoiId"
|
||||
:loading="pickerLoading"
|
||||
:error="pickerError"
|
||||
:empty-text="pickerEmptyText"
|
||||
@close="closePicker"
|
||||
@select="handlePointSelect"
|
||||
@search="handlePickerSearch"
|
||||
@keyword-change="handlePickerKeywordChange"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } 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
|
||||
}>(), {
|
||||
visible: true,
|
||||
startPoint: null,
|
||||
endPoint: null,
|
||||
pointOptions: () => [] as RoutePointOption[],
|
||||
hasRoutePreview: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
summary: '',
|
||||
pickerLoading: false,
|
||||
pickerError: '',
|
||||
pickerEmptyText: '暂无匹配地点'
|
||||
})
|
||||
|
||||
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: []
|
||||
}>()
|
||||
|
||||
const pickerMode = ref<PickerMode>('')
|
||||
|
||||
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 primaryActionText = computed(() => (
|
||||
props.hasRoutePreview ? '模拟导览' : '生成线路'
|
||||
))
|
||||
|
||||
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 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) + 30px);
|
||||
padding: 10px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 28px rgba(36, 49, 42, 0.12);
|
||||
z-index: 1003;
|
||||
}
|
||||
|
||||
.panel-handle {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 10px;
|
||||
background: #d8dbd2;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.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: #000000;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.panel-clear {
|
||||
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-clear-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: 10px;
|
||||
}
|
||||
|
||||
.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: 10px;
|
||||
}
|
||||
|
||||
.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: #000000;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
328
src/components/navigation/RoutePointPicker.vue
Normal file
328
src/components/navigation/RoutePointPicker.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<view v-if="visible" class="route-point-picker">
|
||||
<view class="picker-mask" @tap="handleClose"></view>
|
||||
<view class="picker-panel">
|
||||
<view class="picker-header">
|
||||
<text class="picker-title">{{ title }}</text>
|
||||
<view class="picker-close" @tap="handleClose">
|
||||
<text class="picker-close-text">关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="picker-search">
|
||||
<input
|
||||
class="picker-search-input"
|
||||
:value="keyword"
|
||||
:placeholder="placeholder"
|
||||
placeholder-class="picker-search-placeholder"
|
||||
confirm-type="search"
|
||||
@input="handleKeywordInput"
|
||||
@confirm="handleSearchConfirm"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="picker-state">
|
||||
<text class="picker-state-text">加载中</text>
|
||||
</view>
|
||||
<view v-else-if="error" class="picker-state">
|
||||
<text class="picker-state-text error">{{ error }}</text>
|
||||
</view>
|
||||
<view v-else-if="filteredOptions.length === 0" class="picker-state">
|
||||
<text class="picker-state-text">{{ emptyText }}</text>
|
||||
</view>
|
||||
<scroll-view v-else class="picker-list" scroll-y>
|
||||
<view
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.poiId"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedPoiId === option.poiId }"
|
||||
@tap="handleSelect(option)"
|
||||
>
|
||||
<view class="option-main">
|
||||
<text class="option-name">{{ option.name }}</text>
|
||||
<text class="option-meta">{{ formatMeta(option) }}</text>
|
||||
</view>
|
||||
<view v-if="selectedPoiId === option.poiId" class="option-check">
|
||||
<text class="option-check-text">已选</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export interface RoutePointOption {
|
||||
poiId: string
|
||||
name: string
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
categoryLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean
|
||||
title?: string
|
||||
placeholder?: string
|
||||
options?: RoutePointOption[]
|
||||
selectedPoiId?: string
|
||||
initialKeyword?: string
|
||||
loading?: boolean
|
||||
error?: string
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
visible: false,
|
||||
title: '选择目标',
|
||||
placeholder: '搜索目标列表',
|
||||
options: () => [] as RoutePointOption[],
|
||||
selectedPoiId: '',
|
||||
initialKeyword: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
emptyText: '暂无匹配地点'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [option: RoutePointOption]
|
||||
search: [keyword: string]
|
||||
keywordChange: [keyword: string]
|
||||
}>()
|
||||
|
||||
const keyword = ref(props.initialKeyword)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
keyword.value = props.initialKeyword
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initialKeyword,
|
||||
(value) => {
|
||||
if (!props.visible) {
|
||||
keyword.value = value
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase())
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!normalizedKeyword.value) return props.options
|
||||
|
||||
return props.options.filter((option) => {
|
||||
const fields = [
|
||||
option.name,
|
||||
option.floorId,
|
||||
option.floorLabel,
|
||||
option.categoryLabel || ''
|
||||
]
|
||||
|
||||
return fields.some((field) => field.toLowerCase().includes(normalizedKeyword.value))
|
||||
})
|
||||
})
|
||||
|
||||
const formatMeta = (option: RoutePointOption) => {
|
||||
return option.categoryLabel
|
||||
? `${option.floorLabel} · ${option.categoryLabel}`
|
||||
: option.floorLabel
|
||||
}
|
||||
|
||||
const handleKeywordInput = (event: Event) => {
|
||||
const detailValue = (event as Event & { detail?: { value?: string } }).detail?.value
|
||||
const targetValue = (event.target as HTMLInputElement | null)?.value
|
||||
|
||||
keyword.value = detailValue ?? targetValue ?? ''
|
||||
emit('keywordChange', keyword.value)
|
||||
}
|
||||
|
||||
const handleSearchConfirm = () => {
|
||||
emit('search', keyword.value)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleSelect = (option: RoutePointOption) => {
|
||||
emit('select', option)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.route-point-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.picker-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 12px);
|
||||
max-height: 72vh;
|
||||
padding: 16px 14px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
min-width: 0;
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.picker-close {
|
||||
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;
|
||||
}
|
||||
|
||||
.picker-close-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #545861;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
margin-top: 14px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
background: var(--museum-bg-cream);
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.picker-search-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.picker-search-placeholder {
|
||||
color: #8b8b84;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
margin-top: 10px;
|
||||
max-height: 48vh;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
min-height: 62px;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #ecede7;
|
||||
}
|
||||
|
||||
.picker-option.active {
|
||||
background: #f6f7f4;
|
||||
}
|
||||
|
||||
.option-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-meta {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-check {
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000000;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.option-check-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.picker-state {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picker-state-text {
|
||||
max-width: 260px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: #696962;
|
||||
}
|
||||
|
||||
.picker-state-text.error {
|
||||
color: #b44b42;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user