Files
frontend-miniapp/src/components/navigation/ArrivalPanel.vue
lyf 140f13633e
Some checks failed
CI / verify (push) Has been cancelled
改造 H5 第三方地图导航
2026-07-13 16:56:18 +08:00

672 lines
15 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>
<scroll-view
class="arrival-list"
scroll-y
:show-scrollbar="false"
>
<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>
</scroll-view>
<view
class="arrival-primary"
:class="{ disabled: !selectedTarget || isOpeningNavigation }"
@tap="handleNavigateTap"
>
<text class="arrival-primary-text">{{ primaryActionText }}</text>
</view>
</template>
</view>
<!-- #ifdef H5 -->
<view
v-if="buildDiagnostics"
class="arrival-build-diagnostics"
data-testid="arrival-build-diagnostics"
>
<text class="arrival-build-diagnostics-title">H5 构建诊断</text>
<text class="arrival-build-diagnostics-line">构建 ID{{ buildDiagnostics.buildId }}</text>
<text class="arrival-build-diagnostics-line">Git commit{{ buildDiagnostics.gitCommit }}</text>
<text class="arrival-build-diagnostics-line">构建平台{{ buildDiagnostics.platform }}</text>
<text class="arrival-build-diagnostics-line">navigationMode{{ buildDiagnostics.navigationMode }}</text>
<text class="arrival-build-diagnostics-line">页面 chunk{{ buildDiagnostics.chunkFileName }}</text>
<text class="arrival-build-diagnostics-line">location.href{{ buildDiagnostics.href }}</text>
<text class="arrival-build-diagnostics-line">UA{{ buildDiagnostics.userAgent }}</text>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
ARRIVAL_TARGET_TYPES,
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
// #ifndef MP-WEIXIN
import { navigateToMuseum } from '@/services/MapNavigationService'
// #endif
// #ifdef MP-WEIXIN
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
import { validateWechatOpenLocationTarget } from '@/utils/wechatOpenLocationProtocol'
// #endif
type ArrivalNavigationMode = 'third-party-providers' | 'native-location'
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 navigationMode: ArrivalNavigationMode = __APP_BUILD_PLATFORM__ === 'mp-weixin'
? 'native-location'
: 'third-party-providers'
const arrivalPanelRef = ref<unknown>(null)
const isCollapsed = ref(false)
const isOpeningNavigation = ref(false)
let resizeObserver: ResizeObserver | null = null
const primaryActionText = computed(() => {
// #ifdef MP-WEIXIN
if (navigationMode === 'native-location') {
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
}
// #endif
return '第三方导航'
})
// #ifdef H5
const hasDebugBuildFlag = () => {
if (typeof window === 'undefined') return false
const searchParams = new URLSearchParams(window.location.search)
if (searchParams.get('debug-build') === '1') return true
const hash = window.location.hash || ''
const hashQueryStart = hash.indexOf('?')
if (hashQueryStart < 0) return false
return new URLSearchParams(hash.slice(hashQueryStart + 1)).get('debug-build') === '1'
}
const resolveCurrentChunkFileName = () => {
try {
const pathname = new URL(import.meta.url).pathname
return decodeURIComponent(pathname.split('/').pop() || pathname)
} catch {
return import.meta.url
}
}
const buildDiagnostics = hasDebugBuildFlag()
? {
buildId: __APP_BUILD_ID__,
gitCommit: __APP_GIT_COMMIT__,
platform: __APP_BUILD_PLATFORM__,
navigationMode,
chunkFileName: resolveCurrentChunkFileName(),
href: window.location.href,
userAgent: window.navigator.userAgent
}
: null
// #endif
const activeTypeLabel = computed(() => (
typeOptions.value.find((option) => option.type === props.activeType)?.label || '来馆'
))
const handleTypeChange = (type: ArrivalTargetType) => {
if (type === props.activeType) return
emit('update:activeType', type)
}
const handleTargetSelect = (target: ArrivalSearchTarget) => {
emit('selectTarget', target)
}
const collapsePanel = () => {
isCollapsed.value = true
emit('collapsedChange', true)
}
const expandPanel = () => {
isCollapsed.value = false
emit('collapsedChange', 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 = async () => {
if (isOpeningNavigation.value) return
const selectedTarget = props.selectedTarget
if (!selectedTarget) {
uni.showToast({
title: '请先选择点位',
icon: 'none'
})
return
}
// #ifndef MP-WEIXIN
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
isOpeningNavigation.value = true
try {
await navigateToMuseum()
} finally {
isOpeningNavigation.value = false
}
return
}
// #endif
// #ifdef MP-WEIXIN
const validation = validateWechatOpenLocationTarget({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
})
if (!validation.ok) {
uni.showToast({
title: validation.message,
icon: 'none'
})
return
}
isOpeningNavigation.value = true
try {
const result = await openWechatHostLocation(validation.value)
if (result.status !== 'opened') {
uni.showToast({
title: result.reason === 'timeout' ? '微信地图响应超时' : '无法打开微信地图',
icon: 'none'
})
}
} finally {
isOpeningNavigation.value = false
}
// #endif
}
watch(
() => props.visible,
(visible) => {
if (visible) {
isCollapsed.value = false
emit('collapsedChange', false)
startLayoutObserver()
} else {
isCollapsed.value = false
disconnectLayoutObserver()
emit('collapsedChange', false)
emit('layoutChange', { height: 0, collapsed: false })
}
}
)
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;
display: flex;
flex-direction: column;
overflow: hidden;
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 {
flex: 1 1 auto;
min-height: 0;
margin-top: 10px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.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;
flex-shrink: 0;
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;
}
.arrival-build-diagnostics {
position: fixed;
top: calc(env(safe-area-inset-top) + 8px);
left: 8px;
right: 8px;
z-index: 9999;
max-height: 42vh;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
overflow: auto;
box-sizing: border-box;
border: 1px solid rgba(224, 225, 0, 0.75);
border-radius: 6px;
background: rgba(20, 22, 19, 0.94);
pointer-events: none;
}
.arrival-build-diagnostics-title,
.arrival-build-diagnostics-line {
overflow-wrap: anywhere;
font-size: 11px;
line-height: 16px;
color: #ffffff;
}
.arrival-build-diagnostics-title {
font-weight: 700;
color: #e0e100;
}
</style>