672 lines
15 KiB
Vue
672 lines
15 KiB
Vue
<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>
|