Files
frontend-miniapp/src/components/navigation/ArrivalPanel.vue
lyf a5b19db355
Some checks failed
CI / verify (push) Has been cancelled
修复小程序内来馆地图导航
2026-07-10 11:20:17 +08:00

701 lines
14 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 && !isCollapsed"
class="arrival-scrim"
@tap="collapsePanel"
></view>
<view
ref="arrivalPanelRef"
v-if="visible"
class="arrival-panel"
:class="{ collapsed: isCollapsed }"
@tap.stop
>
<view v-if="isCollapsed" class="arrival-collapsed" @tap="expandPanel">
<view class="arrival-collapsed-copy">
<text class="arrival-collapsed-title">来馆</text>
<text class="arrival-collapsed-summary">{{ selectedTarget?.title || activeTypeLabel }}</text>
</view>
<view class="arrival-collapsed-action">
<text class="arrival-collapsed-action-text">展开</text>
</view>
</view>
<template v-else>
<view class="arrival-handle" @tap="collapsePanel"></view>
<view class="arrival-header">
<view class="arrival-title-group">
<text class="arrival-kicker">来馆</text>
</view>
<view class="arrival-collapse" @tap="collapsePanel">
<text class="arrival-collapse-text">收起</text>
</view>
</view>
<view class="arrival-tabs">
<view
v-for="option in typeOptions"
:key="option.type"
class="arrival-tab"
:class="{ active: option.type === activeType }"
@tap="handleTypeChange(option.type)"
>
<text class="arrival-tab-text">{{ option.label }}</text>
</view>
</view>
<view class="arrival-list">
<view
v-for="target in targets"
:key="target.id"
class="arrival-target-row"
:class="{ active: target.id === selectedTargetId }"
@tap="handleTargetSelect(target)"
>
<view class="arrival-target-dot"></view>
<view class="arrival-target-copy">
<text class="arrival-target-title">{{ target.title }}</text>
<text class="arrival-target-subtitle">{{ target.subtitle }}</text>
<text v-if="target.description" class="arrival-target-desc">{{ target.description }}</text>
</view>
<text v-if="target.id === selectedTargetId" class="arrival-target-state">已选</text>
</view>
</view>
<view
class="arrival-primary"
:class="{ disabled: !selectedTarget }"
@tap="handleNavigateTap"
>
<text class="arrival-primary-text">{{ primaryActionText }}</text>
</view>
</template>
</view>
<view
v-if="providerSheetVisible && selectedTarget"
class="provider-scrim"
@tap="closeProviderSheet"
></view>
<view
v-if="providerSheetVisible && selectedTarget"
class="provider-sheet"
@tap.stop
>
<view class="provider-header">
<view class="provider-title-group">
<text class="provider-kicker">选择地图</text>
<text class="provider-title">{{ selectedTarget.title }}</text>
</view>
<view class="provider-close" @tap="closeProviderSheet">
<text class="provider-close-text">×</text>
</view>
</view>
<view
v-for="provider in mapProviders"
:key="provider.provider"
class="provider-row"
@tap="handleProviderSelect(provider)"
>
<view class="provider-mark">
<text class="provider-mark-text">{{ provider.label.slice(0, 1) }}</text>
</view>
<text class="provider-label">{{ provider.label }}</text>
<text class="provider-action">打开</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
ARRIVAL_TARGET_TYPES,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
import {
openThirdPartyMapSearch,
openWechatMiniProgramLocation,
THIRD_PARTY_MAP_PROVIDERS,
type ThirdPartyMapProviderOption
} from '@/services/ThirdPartyMapSearchService'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
const props = withDefaults(defineProps<{
visible?: boolean
activeType: ArrivalTargetType
targets?: ArrivalSearchTarget[]
selectedTargetId?: string
selectedTarget?: ArrivalSearchTarget | null
}>(), {
visible: false,
targets: () => [] as ArrivalSearchTarget[],
selectedTargetId: '',
selectedTarget: null
})
const emit = defineEmits<{
'update:activeType': [type: ArrivalTargetType]
selectTarget: [target: ArrivalSearchTarget]
collapsedChange: [collapsed: boolean]
layoutChange: [layout: { height: number; collapsed: boolean }]
}>()
const typeOptions = computed(() => ARRIVAL_TARGET_TYPES)
const mapProviders = computed(() => THIRD_PARTY_MAP_PROVIDERS)
const shouldUseMiniProgramLocation = computed(() => isEmbeddedInWechatMiniProgram())
const primaryActionText = computed(() => (
shouldUseMiniProgramLocation.value ? '打开地图导航' : '第三方导航'
))
const arrivalPanelRef = ref<unknown>(null)
const isCollapsed = ref(false)
const providerSheetVisible = ref(false)
let resizeObserver: ResizeObserver | null = null
const activeTypeLabel = computed(() => (
typeOptions.value.find((option) => option.type === props.activeType)?.label || '来馆'
))
const handleTypeChange = (type: ArrivalTargetType) => {
if (type === props.activeType) return
closeProviderSheet()
emit('update:activeType', type)
}
const handleTargetSelect = (target: ArrivalSearchTarget) => {
closeProviderSheet()
emit('selectTarget', target)
}
const collapsePanel = () => {
isCollapsed.value = true
emit('collapsedChange', true)
}
const expandPanel = () => {
isCollapsed.value = false
emit('collapsedChange', false)
}
const closeProviderSheet = () => {
providerSheetVisible.value = false
}
const getArrivalPanelElement = () => {
const rawRef = arrivalPanelRef.value
if (rawRef instanceof HTMLElement) return rawRef
const maybeComponent = rawRef as { $el?: Element } | null
if (maybeComponent?.$el instanceof HTMLElement) return maybeComponent.$el
if (typeof document === 'undefined') return null
return document.querySelector<HTMLElement>('.arrival-panel')
}
const emitLayoutChange = () => {
if (!props.visible) {
emit('layoutChange', { height: 0, collapsed: false })
return
}
void nextTick(() => {
const panelElement = getArrivalPanelElement()
emit('layoutChange', {
height: panelElement?.getBoundingClientRect().height || 0,
collapsed: isCollapsed.value
})
})
}
const disconnectLayoutObserver = () => {
resizeObserver?.disconnect()
resizeObserver = null
}
const startLayoutObserver = () => {
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
emitLayoutChange()
return
}
void nextTick(() => {
const panelElement = getArrivalPanelElement()
if (!panelElement) {
emitLayoutChange()
return
}
disconnectLayoutObserver()
resizeObserver = new ResizeObserver(() => {
emitLayoutChange()
})
resizeObserver.observe(panelElement)
emitLayoutChange()
})
}
const handleNavigateTap = () => {
if (!props.selectedTarget) {
uni.showToast({
title: '请先选择点位',
icon: 'none'
})
return
}
if (shouldUseMiniProgramLocation.value) {
void openWechatMiniProgramLocation({
latitude: props.selectedTarget.latitude,
longitude: props.selectedTarget.longitude,
name: props.selectedTarget.title,
address: props.selectedTarget.subtitle
})
return
}
providerSheetVisible.value = true
}
const handleProviderSelect = (provider: ThirdPartyMapProviderOption) => {
const target = props.selectedTarget
if (!target) return
openThirdPartyMapSearch(provider.provider, {
keyword: target.keyword,
region: target.region
})
}
watch(
() => props.visible,
(visible) => {
if (visible) {
isCollapsed.value = false
emit('collapsedChange', false)
startLayoutObserver()
} else {
closeProviderSheet()
isCollapsed.value = false
disconnectLayoutObserver()
emit('collapsedChange', false)
emit('layoutChange', { height: 0, collapsed: false })
}
}
)
watch(
() => props.selectedTargetId,
() => {
closeProviderSheet()
}
)
watch(
() => [props.targets.length, isCollapsed.value] as const,
() => {
startLayoutObserver()
}
)
onBeforeUnmount(() => {
disconnectLayoutObserver()
})
defineExpose({
expandPanel
})
</script>
<style scoped lang="scss">
.arrival-scrim {
position: fixed;
inset: 0;
z-index: 2090;
background: rgba(0, 0, 0, 0.08);
}
.arrival-panel {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + 16px);
z-index: 2100;
width: min(430px, calc(100vw - 24px));
max-height: min(58vh, 520px);
padding: 10px 14px 14px;
overflow-y: auto;
box-sizing: border-box;
background: #ffffff;
border: 1px solid rgba(31, 35, 41, 0.08);
border-radius: 8px;
box-shadow: 0 10px 34px rgba(31, 35, 41, 0.18);
transform: translateX(-50%);
}
.arrival-panel.collapsed {
bottom: calc(env(safe-area-inset-bottom) + 92px);
max-height: none;
padding: 0;
overflow: visible;
}
.arrival-collapsed {
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px 10px 14px;
box-sizing: border-box;
}
.arrival-collapsed-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.arrival-collapsed-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #262421;
}
.arrival-collapsed-summary {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 17px;
color: #696962;
}
.arrival-collapsed-action {
height: 34px;
min-width: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
box-sizing: border-box;
background: #262421;
border-radius: 8px;
}
.arrival-collapsed-action-text {
font-size: 13px;
line-height: 18px;
font-weight: 600;
color: #e0e100;
}
.arrival-handle {
width: 38px;
height: 4px;
margin: 0 auto 12px;
background: #d7d9cf;
border-radius: 2px;
}
.arrival-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.arrival-title-group {
display: flex;
flex-direction: column;
gap: 3px;
}
.arrival-kicker {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: #696962;
}
.arrival-title {
font-size: 18px;
line-height: 25px;
font-weight: 700;
color: #262421;
}
.arrival-collapse {
flex-shrink: 0;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid #d7d9cf;
border-radius: 8px;
background: #ffffff;
}
.arrival-collapse-text {
font-size: 12px;
line-height: 17px;
font-weight: 600;
color: #424754;
}
.arrival-tabs {
height: 40px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
margin-top: 14px;
padding: 4px;
box-sizing: border-box;
background: #f3f3f3;
border-radius: 8px;
}
.arrival-tab {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.arrival-tab.active {
background: #262421;
}
.arrival-tab-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #424754;
}
.arrival-tab.active .arrival-tab-text {
color: #e0e100;
}
.arrival-list {
margin-top: 10px;
}
.arrival-target-row {
min-height: 68px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-top: 1px solid #eceee7;
border-radius: 8px;
box-sizing: border-box;
}
.arrival-target-row:first-child {
border-top: 0;
}
.arrival-target-row.active {
background: #f5f5ed;
}
.arrival-target-dot {
width: 10px;
height: 10px;
flex-shrink: 0;
background: #d7d9cf;
border: 2px solid #696962;
border-radius: 50%;
}
.arrival-target-row.active .arrival-target-dot {
background: #e0e100;
border-color: #262421;
}
.arrival-target-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.arrival-target-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #262421;
}
.arrival-target-subtitle,
.arrival-target-desc {
font-size: 12px;
line-height: 17px;
color: #696962;
}
.arrival-target-desc {
color: #424754;
}
.arrival-target-state {
flex-shrink: 0;
font-size: 12px;
line-height: 17px;
font-weight: 700;
color: #262421;
}
.arrival-notes {
margin-top: 10px;
padding: 9px 10px;
background: #f5f5ed;
border-radius: 8px;
}
.arrival-note {
font-size: 12px;
line-height: 17px;
color: #424754;
}
.arrival-primary {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 12px;
background: #262421;
border-radius: 8px;
}
.arrival-primary.disabled {
opacity: 0.45;
}
.arrival-primary-text {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #e0e100;
}
.provider-scrim {
position: fixed;
inset: 0;
z-index: 2190;
background: rgba(0, 0, 0, 0.28);
}
.provider-sheet {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + 12px);
z-index: 2200;
width: min(430px, calc(100vw - 24px));
padding: 14px;
box-sizing: border-box;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 12px 36px rgba(31, 35, 41, 0.22);
transform: translateX(-50%);
}
.provider-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.provider-title-group {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.provider-kicker {
font-size: 12px;
line-height: 16px;
font-weight: 600;
color: #696962;
}
.provider-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #262421;
}
.provider-close {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f3f3f3;
border-radius: 8px;
}
.provider-close-text {
font-size: 22px;
line-height: 26px;
color: #424754;
}
.provider-row {
min-height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #eceee7;
}
.provider-mark {
width: 30px;
height: 30px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #262421;
border-radius: 8px;
}
.provider-mark-text {
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #e0e100;
}
.provider-label {
flex: 1;
font-size: 15px;
line-height: 21px;
font-weight: 600;
color: #262421;
}
.provider-action {
flex-shrink: 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
color: #696962;
}
</style>