Files
frontend-miniapp/src/pages/route/detail.vue

963 lines
23 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>
<GuidePageFrame
active-tab="guide"
variant="overlay"
show-back
:show-cancel="isManualLocationPanelOpen"
@tab-change="handleTopTabChange"
@back="handlePageBack"
@cancel="handleManualLocationCancel"
>
<GuideMapShell
v-bind="shellConfig"
:indoor-model-source="indoorModelSource"
:floors="guideFloors"
:normalize-floor-id="normalizeGuideFloorId"
:target-focus-request="targetFocusRequest"
@search-tap="handleSearchTap"
@mode-change="handleModeChange"
@floor-change="handleFloorChange"
@tool-click="handleToolClick"
@target-focus="handleTargetFocus"
>
<template #overlay>
<view
v-if="routeViewState === 'location-error' && !isManualLocationPanelOpen"
class="location-error-dialog"
>
<view class="error-icon">
<text class="error-icon-mark">!</text>
</view>
<text class="error-title">无法自动获取起点</text>
<text class="error-desc">
可以返回位置预览或手动选择预览起点所在楼层与区域
</text>
<view class="error-actions">
<view class="dialog-btn secondary" @tap="handleRelocate">
<text class="dialog-btn-text">返回预览</text>
</view>
<view class="dialog-btn primary" @tap="handleManualSelect">
<text class="dialog-btn-text">手动选择起点</text>
</view>
</view>
</view>
<view
v-if="routeViewState === 'location-error' && isManualLocationPanelOpen"
class="manual-location-sheet"
>
<text class="manual-title">选择楼层与区域</text>
<view class="chip-row floor-chip-row">
<view
v-for="floor in manualFloors"
:key="floor"
class="manual-chip"
:class="{ active: floor === selectedManualFloor }"
@tap="handleManualFloorChange(floor)"
>
<text class="manual-chip-text">{{ floor }}</text>
</view>
</view>
<view class="chip-row area-chip-row">
<view
v-for="area in manualAreas"
:key="area"
class="manual-chip area"
:class="{ active: area === selectedManualArea }"
@tap="handleManualAreaChange(area)"
>
<text class="manual-chip-text">{{ area }}</text>
</view>
</view>
<view class="manual-location-actions">
<view class="manual-location-btn secondary" @tap="handleManualLocationCancel">
<text class="manual-location-btn-text">取消</text>
</view>
<view class="manual-location-btn primary" @tap="handleConfirmLocation">
<text class="manual-location-btn-text">
确认预览起点{{ selectedManualFloor }} {{ selectedManualArea }}
</text>
</view>
</view>
</view>
<view v-if="routeViewState === 'outdoor-preview'" class="entrance-tip">
<text class="entrance-tip-text">主入口 4 分钟</text>
</view>
</template>
<view v-if="!hasRouteTarget" class="target-required-card">
<text class="target-required-title">请选择目标位置</text>
<text class="target-required-desc">需要先选择展厅或设施才能查看馆内三维位置</text>
<view class="target-required-btn" @tap="handleSelectTarget">
<text class="target-required-btn-text">选择目标地点</text>
</view>
</view>
<LocationPreview
v-else-if="routeViewState === 'preview'"
:summary="route.summary"
:steps="route.steps"
:target-location="route.targetLocation"
@view-outdoor-map="handleViewOutdoorMap"
@show-target-location="handleShowTargetLocation"
/>
<view v-else-if="routeViewState === 'outdoor-preview'" class="outdoor-preview-card">
<text class="outdoor-preview-title">室外地图预览{{ route.target }}</text>
<text class="outdoor-preview-desc">
室外地图仅作入口参考馆内当前支持三维位置预览
</text>
<view class="outdoor-preview-actions">
<view class="outdoor-preview-btn secondary" @tap="handleReturnToPreview">
<text class="outdoor-preview-btn-text">返回预览</text>
</view>
<view class="outdoor-preview-btn primary" @tap="handleReturnToPreview">
<text class="outdoor-preview-btn-text">返回室内3D</text>
</view>
</view>
</view>
</GuideMapShell>
</GuidePageFrame>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
import LocationPreview from '@/components/navigation/LocationPreview.vue'
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
initialLocationPreviewPlan,
toTargetFocusRequestViewModel
} from '@/view-models/guideViewModels'
import type {
GuideLocationPreview,
GuideLocationPreviewPlan,
GuideStartLocation,
MuseumFloor
} from '@/domain/museum'
import {
navigateToGuideTopTab,
saveLastGuideLocationPreviewUrl,
type GuideTopTab
} from '@/utils/guideTopTabs'
type RouteViewState = 'preview' | 'location-error' | 'outdoor-preview'
type GuideMode = '2d' | '3d'
type RouteTargetLocation = GuideLocationPreview
type RouteStartLocation = GuideStartLocation
interface TargetPoiFocusRequest extends RouteTargetLocation {
requestId: number | string
}
interface TargetPoiFocusResult {
requestId: number | string
poiId: string
floorId: string
status: 'focused' | 'missing' | 'error'
message?: string
}
type RoutePlan = GuideLocationPreviewPlan
const routeDeepLinkStates: RouteViewState[] = ['preview', 'outdoor-preview']
const indoorModelSource = guideUseCase.getModelSource()
const guideFloors = ref<MuseumFloor[]>([])
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
const route = ref<RoutePlan>(initialLocationPreviewPlan())
const hasRouteTarget = ref(false)
const routeViewState = ref<RouteViewState>('preview')
const activeFloor = ref('1F')
const targetFocusRequest = ref<TargetPoiFocusRequest | null>(null)
const targetFocusRequestId = ref(0)
const manualFloors = computed(() => guideFloors.value.map((floor) => floor.label))
const guideFloorLabel = (floorId: string) => (
guideFloors.value.find((floor) => floor.id === floorId)?.label || floorId
)
const defaultGuideFloorLabel = () => (
guideFloors.value.find((floor) => floor.id === 'L1')?.label
|| guideFloors.value[0]?.label
|| '1F'
)
const loadGuideFloors = async () => {
if (guideFloors.value.length) return guideFloors.value
try {
guideFloors.value = await guideUseCase.getFloors()
} catch (error) {
console.error('加载导览楼层失败:', error)
guideFloors.value = []
}
return guideFloors.value
}
const manualAreas = ['主入口', '服务台附近', '停车场入口']
const isManualLocationPanelOpen = ref(false)
const selectedManualFloor = ref('1F')
const selectedManualArea = ref('服务台附近')
const normalizeRouteOption = (value: unknown) => {
if (Array.isArray(value)) {
return value[0]
}
return value
}
const isRouteViewState = (state: unknown): state is RouteViewState => {
const normalizedState = normalizeRouteOption(state)
return typeof normalizedState === 'string' && routeDeepLinkStates.includes(normalizedState as RouteViewState)
}
const shellConfig = computed(() => {
if (!hasRouteTarget.value) {
return {
searchText: '选择目标位置',
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
floorTop: '198px',
toolsTop: '452px',
tools: [],
showSearch: true,
showFloor: false,
modeTop: '104px',
modeLayout: 'full' as const,
mapType: 'indoor' as const
}
}
if (routeViewState.value === 'location-error') {
return {
searchText: '搜索设施、展厅、入口',
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
floorTop: '198px',
toolsTop: '452px',
tools: [],
showSearch: true,
showFloor: false,
modeTop: '104px',
modeLayout: 'full' as const,
mapType: 'indoor' as const,
dimmed: true
}
}
if (routeViewState.value === 'outdoor-preview') {
return {
searchText: `室外地图参考:${route.value.target}`,
searchTop: '60px',
activeMode: '2d' as GuideMode,
activeFloor: activeFloor.value,
floorTop: '198px',
toolsTop: '452px',
tools: [],
showSearch: true,
showFloor: false,
modeTop: '104px',
modeLayout: 'status' as const,
modeStatus: '室外参考',
mapType: 'outdoor' as const,
outdoorVariant: 'entrance' as const
}
}
return {
searchText: `位置预览:${route.value.target}`,
searchTop: '60px',
activeMode: '3d' as GuideMode,
activeFloor: activeFloor.value,
floorTop: '198px',
toolsTop: '452px',
tools: ['回正'],
showSearch: true,
showFloor: true,
modeTop: '104px',
modeLayout: 'full' as const,
mapType: 'indoor' as const
}
})
const safeDecode = (value: string) => {
try {
return decodeURIComponent(value)
} catch (error) {
return value
}
}
const normalizeStringOption = (value: unknown) => {
const normalizedValue = normalizeRouteOption(value)
return typeof normalizedValue === 'string' ? safeDecode(normalizedValue).trim() : ''
}
const setLocationPreviewTitle = () => {
if (typeof uni !== 'undefined') {
uni.setNavigationBarTitle({
title: '位置预览'
})
}
if (typeof document !== 'undefined') {
document.title = '位置预览'
}
}
const saveCurrentLocationPreviewUrl = () => {
if (!route.value.facilityId) return
const params = new URLSearchParams({
facilityId: route.value.facilityId,
target: route.value.target,
state: routeViewState.value
})
if (route.value.startLocation?.label) {
params.set('startLabel', route.value.startLocation.label)
}
if (route.value.startLocation?.floor) {
params.set('startFloor', route.value.startLocation.floor)
}
if (route.value.startLocation?.source) {
params.set('startSource', route.value.startLocation.source)
}
saveLastGuideLocationPreviewUrl(`/pages/route/detail?${params.toString()}`)
}
const createStartLocationFromOptions = (options: Record<string, unknown>) => {
const label = normalizeStringOption(options.startLabel)
if (!label) return null
const floor = normalizeStringOption(options.startFloor)
const source = normalizeStringOption(options.startSource)
return {
label,
floor: floor || undefined,
source: source || undefined
}
}
const requestTargetFocus = (target: Partial<RouteTargetLocation> | null, fallbackName = route.value.target) => {
if (!target?.poiId || !target.floorId) {
targetFocusRequest.value = null
return false
}
const floorLabel = target.floorLabel || guideFloorLabel(target.floorId)
const focusTarget: RouteTargetLocation = {
poiId: target.poiId,
name: target.name || fallbackName,
floorId: target.floorId,
floorLabel,
primaryCategoryZh: target.primaryCategoryZh,
positionGltf: target.positionGltf
}
activeFloor.value = floorLabel
routeViewState.value = 'preview'
targetFocusRequestId.value += 1
targetFocusRequest.value = toTargetFocusRequestViewModel(
focusTarget,
targetFocusRequestId.value
)
return true
}
const applyRouteOptions = async (options: Record<string, unknown> = {}) => {
await loadGuideFloors()
const facilityId = normalizeStringOption(options.facilityId)
const target = normalizeStringOption(options.target)
const state = normalizeRouteOption(options.state)
const startLocation = createStartLocationFromOptions(options)
if (!facilityId && !target) {
hasRouteTarget.value = false
route.value = initialLocationPreviewPlan()
routeViewState.value = 'preview'
activeFloor.value = defaultGuideFloorLabel()
targetFocusRequest.value = null
isManualLocationPanelOpen.value = false
return false
}
const matchedPoi = facilityId ? await guideUseCase.getPoiById(facilityId) : null
if (!matchedPoi) {
hasRouteTarget.value = false
route.value = await guideUseCase.createLocationPreviewPlan('', target || '目标地点', startLocation)
routeViewState.value = 'preview'
activeFloor.value = startLocation?.floor || defaultGuideFloorLabel()
targetFocusRequest.value = null
isManualLocationPanelOpen.value = false
return false
}
route.value = await guideUseCase.createLocationPreviewPlan(
facilityId,
target || matchedPoi.name,
startLocation
)
hasRouteTarget.value = true
routeViewState.value = isRouteViewState(state) ? state : 'preview'
isManualLocationPanelOpen.value = false
if (routeViewState.value === 'preview') {
requestTargetFocus(route.value.targetLocation, matchedPoi.name)
} else {
activeFloor.value = matchedPoi.floorLabel || guideFloorLabel(matchedPoi.floorId)
}
setLocationPreviewTitle()
saveCurrentLocationPreviewUrl()
return true
}
onLoad((options: any) => {
setLocationPreviewTitle()
void applyRouteOptions(options)
})
// #ifdef H5
const readH5RouteOptions = () => {
const hash = window.location.hash || ''
const queryStart = hash.indexOf('?')
const query = queryStart >= 0 ? hash.slice(queryStart + 1) : window.location.search.slice(1)
const params = new URLSearchParams(query)
const options: Record<string, string> = {}
params.forEach((value, key) => {
options[key] = value
})
return options
}
const syncH5RouteOptions = () => {
void applyRouteOptions(readH5RouteOptions())
}
onMounted(() => {
syncH5RouteOptions()
window.addEventListener('hashchange', syncH5RouteOptions)
})
onUnmounted(() => {
window.removeEventListener('hashchange', syncH5RouteOptions)
})
// #endif
const handleSelectTarget = () => {
uni.redirectTo({
url: '/pages/search/index',
fail: () => {
uni.navigateTo({
url: '/pages/search/index'
})
}
})
}
const handleSearchTap = () => {
uni.navigateTo({
url: `/pages/search/index?keyword=${encodeURIComponent(route.value.target)}`
})
}
const handleViewOutdoorMap = () => {
isManualLocationPanelOpen.value = false
routeViewState.value = 'outdoor-preview'
saveCurrentLocationPreviewUrl()
}
const handleShowTargetLocation = (target: Partial<RouteTargetLocation> | null) => {
const focused = requestTargetFocus(target || route.value.targetLocation)
if (!focused) {
uni.showToast({
title: '目标暂无三维位置数据',
icon: 'none'
})
return
}
uni.showToast({
title: '已显示三维位置',
icon: 'none'
})
}
const handleTargetFocus = (result: TargetPoiFocusResult) => {
if (result.status === 'focused') return
uni.showToast({
title: result.message || '目标位置暂时无法聚焦',
icon: 'none'
})
}
const handleReturnToPreview = () => {
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
}
const handleRelocate = () => {
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
}
const handleManualSelect = () => {
isManualLocationPanelOpen.value = true
}
const handleManualFloorChange = (floor: string) => {
selectedManualFloor.value = floor
}
const handleManualAreaChange = (area: string) => {
selectedManualArea.value = area
}
const handleManualLocationCancel = () => {
isManualLocationPanelOpen.value = false
}
const createManualStartLocation = (): RouteStartLocation => ({
label: `${selectedManualFloor.value} ${selectedManualArea.value}`,
floor: selectedManualFloor.value,
source: 'manual'
})
const handleConfirmLocation = async () => {
const startLocation = createManualStartLocation()
route.value = await guideUseCase.createLocationPreviewPlan(
route.value.facilityId,
route.value.target,
startLocation
)
activeFloor.value = startLocation.floor || activeFloor.value
isManualLocationPanelOpen.value = false
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
}
const handleModeChange = (mode: GuideMode) => {
if (routeViewState.value === 'outdoor-preview' && mode === '3d') {
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
return
}
if (routeViewState.value !== 'outdoor-preview' && mode === '2d') {
routeViewState.value = 'outdoor-preview'
saveCurrentLocationPreviewUrl()
return
}
console.log('位置预览切换导览模式:', mode)
}
const handleFloorChange = (floor: string) => {
activeFloor.value = floor
console.log('位置预览切换楼层:', floor)
}
const handleToolClick = (tool: string) => {
if (tool === '重定') {
routeViewState.value = 'location-error'
return
}
uni.showToast({
title: tool === '回正' ? '已回到三维总览' : guideUseCase.getRouteReadinessSnapshot().message,
icon: 'none'
})
}
const handleTopTabChange = (tab: GuideTopTab) => {
navigateToGuideTopTab(tab)
}
const handlePageBack = () => {
if (isManualLocationPanelOpen.value) {
handleManualLocationCancel()
return
}
uni.navigateBack({
delta: 1,
fail: () => {
uni.reLaunch({
url: '/pages/index/index'
})
}
})
}
</script>
<style scoped lang="scss">
.target-required-card {
position: absolute;
left: 16px;
right: 16px;
bottom: calc(env(safe-area-inset-bottom) + 34px);
min-height: 170px;
padding: 22px 20px 20px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #ffffff;
border: 0;
border-radius: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
z-index: 45;
}
.target-required-title {
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.target-required-desc {
margin-top: 10px;
font-size: 13px;
line-height: 19px;
color: #5f666d;
}
.target-required-btn {
margin-top: 22px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #000000;
border: 1px solid #000000;
border-radius: 8px;
}
.target-required-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: var(--museum-accent);
}
.outdoor-preview-actions,
.error-actions {
display: flex;
}
.outdoor-preview-btn,
.dialog-btn {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.outdoor-preview-btn-text,
.dialog-btn-text {
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.outdoor-preview-btn.primary .outdoor-preview-btn-text,
.dialog-btn.primary .dialog-btn-text {
color: var(--museum-accent);
}
.location-error-dialog {
position: absolute;
top: 200px;
left: 24px;
width: 327px;
height: 288px;
padding: 28px 24px 36px;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
background: #ffffff;
border-radius: 18px;
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
z-index: 55;
}
.error-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: #000000;
border-radius: 32px;
}
.error-icon-mark {
font-size: 34px;
line-height: 40px;
font-weight: 700;
color: var(--museum-accent);
}
.error-title {
margin-top: 18px;
font-size: 20px;
line-height: 27px;
font-weight: 700;
color: #000000;
}
.error-desc {
width: 258px;
margin-top: 10px;
font-size: 13px;
line-height: 20px;
color: #626970;
text-align: center;
}
.error-actions {
position: absolute;
left: 24px;
right: 24px;
bottom: 36px;
gap: 16px;
}
.dialog-btn {
width: 132px;
height: 40px;
}
.dialog-btn.secondary {
background: #ffffff;
border: 1px solid #000000;
}
.dialog-btn.primary {
background: #000000;
border: 1px solid #000000;
}
.manual-location-sheet {
position: absolute;
left: 0;
right: 0;
bottom: calc(env(safe-area-inset-bottom) + 34px);
height: 196px;
padding: 21px 20px 14px;
box-sizing: border-box;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
z-index: 54;
}
.manual-title {
display: block;
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.chip-row {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.floor-chip-row {
margin-top: 17px;
}
.area-chip-row {
margin-top: 14px;
}
.manual-chip {
height: 28px;
min-width: 48px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #f4f4ef;
border: 1px solid #e7e8e2;
border-radius: 14px;
}
.manual-chip.area {
min-width: 72px;
}
.manual-chip.active {
background: #000000;
border-color: #000000;
}
.manual-chip-text {
font-size: 12px;
line-height: 16px;
font-weight: 500;
color: #60666d;
}
.manual-chip.active .manual-chip-text {
color: var(--museum-accent);
}
.manual-location-actions {
position: absolute;
left: 20px;
right: 20px;
bottom: 14px;
display: flex;
gap: 10px;
}
.manual-location-btn {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
}
.manual-location-btn.secondary {
width: 82px;
background: #ffffff;
border: 1px solid #000000;
}
.manual-location-btn.primary {
flex: 1;
min-width: 0;
background: #000000;
border: 1px solid #000000;
}
.manual-location-btn-text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #000000;
}
.manual-location-btn.primary .manual-location-btn-text {
color: var(--museum-accent);
}
.entrance-tip {
position: absolute;
top: 154px;
left: 30px;
width: 168px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #e3e6df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(80, 98, 88, 0.12);
z-index: 45;
}
.entrance-tip-text {
font-size: 14px;
line-height: 19px;
font-weight: 600;
color: #1f2329;
}
.outdoor-preview-card {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(env(safe-area-inset-bottom) + 34px);
height: 158px;
padding: 18px 16px 14px;
box-sizing: border-box;
background: #ffffff;
border-radius: 18px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
z-index: 45;
}
.outdoor-preview-title {
display: block;
font-size: 18px;
line-height: 24px;
font-weight: 700;
color: #000000;
}
.outdoor-preview-desc {
display: block;
margin-top: 10px;
font-size: 13px;
line-height: 20px;
color: #626970;
}
.outdoor-preview-actions {
position: absolute;
left: 16px;
right: 16px;
bottom: 14px;
gap: 12px;
}
.outdoor-preview-btn {
height: 40px;
}
.outdoor-preview-btn.secondary {
width: 126px;
background: #ffffff;
border: 1px solid #000000;
}
.outdoor-preview-btn.primary {
width: 181px;
background: #000000;
border: 1px solid #000000;
}
</style>