修复馆内预览状态与点位展示
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-19 14:05:54 +08:00
parent 58887d928a
commit 215428db90
11 changed files with 280 additions and 35 deletions

View File

@@ -5,8 +5,8 @@
const https = require('https')
// 腾讯地图 WebService API Key需要在腾讯位置服务申请
const API_KEY = 'EJPBZ-DQEEQ-PDN5U-4ZDVX-F4I3F-6MBJC'
// 由部署环境注入,避免把地图服务凭据写入仓库。
const API_KEY = process.env.TENCENT_MAP_WEBSERVICE_KEY
// 深圳自然博物馆坐标
const MUSEUM_LAT = 22.692763
@@ -109,6 +109,12 @@ function calculateRectangle(centerLat, centerLng, widthMeters, heightMeters) {
return polygon
}
if (!API_KEY) {
console.error('缺少 TENCENT_MAP_WEBSERVICE_KEY请通过环境变量注入受域名/IP 限制的腾讯地图 WebService Key。')
process.exitCode = 1
return
}
console.log('正在获取深圳自然博物馆建筑围栏信息...\n')
// 执行查询

View File

@@ -586,10 +586,13 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
const handleFloorChange = (floor: { id: string; label: string }) => {
const floorId = floor.id
if (!floorId || loadingFloorId.value) return
if (floorId === renderedFloorId.value && activeFloorId.value === floorId && props.layerMode !== 'multi') {
if (
floorId === renderedFloorId.value
&& activeFloorId.value === floorId
&& props.indoorView === 'floor'
&& props.layerMode !== 'multi'
) {
emit('floorChange', floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
return
}
@@ -604,9 +607,6 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
floorId,
floorLabel: floor.label
})
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
@@ -667,11 +667,13 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
})
console.error('楼层切换失败:', error)
})
emit('indoorViewChange', 'floor')
// floor-change 才代表 ThreeMap 已提交单层模型。
}
if (mode === 'multi') {
emit('layerModeChange', mode)
}
}
const handleLayerModeToggle = () => {
handleLayerModeChange(nextLayerMode.value)
@@ -786,6 +788,8 @@ defineExpose({
clearRoute: () => {
indoorRendererRef.value?.clearRoute?.()
},
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId),
showOverview: handleShowOverview,
resetToViewBaseline: (options: {
view: 'overview' | 'floor'

View File

@@ -3,13 +3,21 @@
class="guide-top-tabs"
:class="`variant-${variant}`"
:style="{ top }"
role="tablist"
aria-label="主功能"
>
<view
v-for="tab in GUIDE_TOP_TABS"
:key="tab.id"
class="guide-top-tab"
:class="{ active: activeTab === tab.id }"
role="tab"
:tabindex="activeTab === tab.id ? 0 : -1"
:aria-selected="activeTab === tab.id"
:aria-label="`切换至${tab.label}`"
@tap="handleTabTap(tab.id)"
@keydown.enter.prevent="handleTabTap(tab.id)"
@keydown.space.prevent="handleTabTap(tab.id)"
>
<view
v-if="activeTab === tab.id && variant === 'underline'"

View File

@@ -226,6 +226,9 @@ import {
import {
guideUseCase
} from '@/usecases/guideUseCase'
import {
createVisitorPoiPresentations
} from '@/view-models/visitorPoiPresentation'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
@@ -356,13 +359,18 @@ const isPoiLocatable = (poi: MuseumPoi) => !getPoiDataIssues(poi).some((issue) =
const unavailablePoiCount = computed(() => pois.value.filter((poi) => !isPoiLocatable(poi)).length)
// 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。
const dataWarning = computed(() => {
const warnings: string[] = []
if (excludedPoiCount.value) warnings.push(`${excludedPoiCount.value} 个点位分类或楼层信息不完整`)
if (duplicatePoiCount.value) warnings.push(`${duplicatePoiCount.value} 个重复点位已合并`)
if (unavailablePoiCount.value) warnings.push(`${unavailablePoiCount.value} 个点位暂无地图坐标`)
if (floorLoadError.value) warnings.push('楼层信息读取失败')
return warnings.join('')
const diagnostic = {
excluded: excludedPoiCount.value,
duplicates: duplicatePoiCount.value,
unavailable: unavailablePoiCount.value,
floorLoadError: floorLoadError.value
}
if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) {
console.debug('[guide-poi-diagnostics]', diagnostic)
}
return ''
})
const dedupePois = (items: MuseumPoi[]) => {
@@ -860,7 +868,10 @@ const restoreResultListScroll = async () => {
if (resultListElement) resultListElement.scrollTop = scrollTop
}
const poiDisplayName = (poi: MuseumPoi) => poi.name?.trim() || '未命名点位'
const poiPresentations = computed(() => createVisitorPoiPresentations(pois.value))
const poiDisplayName = (poi: MuseumPoi) => (
poiPresentations.value.get(poi.id)?.displayName || '未命名点位'
)
const poiResultMeta = (poi: MuseumPoi) => {
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'

View File

@@ -17,7 +17,7 @@
<button
v-if="!shouldUseHostNavigation"
class="hero-back"
aria-label="返回讲解详情"
aria-label="返回讲解对象列表"
@tap.stop="handleBack"
>
<text class="hero-back-icon"></text>

View File

@@ -424,6 +424,7 @@ const returningToOverview = ref(false)
// GuideMapShell 组件引用,用于显式调用路线清除方法
const guideMapShellRef = ref<{
clearRoute?: () => void
switchFloor?: (floorId: string) => Promise<void> | void
showOverview?: () => Promise<void> | void
resetToViewBaseline?: (options: {
view: 'overview' | 'floor'
@@ -1482,12 +1483,24 @@ const handleMoreRouteGuide = async () => {
closeArrivalPanel()
showRoutePlanner.value = false
is3DMode.value = true
indoorView.value = 'floor'
guideOutdoorState.value = 'home'
selectedGuidePoi.value = null
isPoiCardCollapsed.value = false
isSimulatingRoute.value = false
showIndoorHint(`当前楼层:${getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层'}`, 3200)
const floorId = activeGuideFloor.value
const floorLabel = getGuideFloorLabel(floorId) || '默认楼层'
if (!floorId) {
showIndoorHint('暂未获取到可进入的楼层,请稍后重试', 3200)
return
}
requestedFloorId.value = floorId
requestedFloorLabel.value = floorLabel
loadingFloorId.value = floorId
failedFloorId.value = ''
// 渲染器提交 floor-change 前仍保持建筑外观,避免楼层控件显示假状态。
showIndoorHint(`正在进入 ${floorLabel}`, 3200)
await guideMapShellRef.value?.switchFloor?.(floorId)
}
const selectFirstArrivalTarget = () => {

View File

@@ -132,6 +132,12 @@
<view v-if="routePlanError" class="indoor-guide-error">
<text class="indoor-guide-error-text">{{ routePlanError }}</text>
</view>
<view class="start-point-select" @tap="openStartPointPicker">
<text class="start-point-select-label">起点</text>
<text class="start-point-select-value" :class="{ placeholder: !selectedStartPoint }">
{{ selectedStartPoint ? `${selectedStartPoint.name} · ${selectedStartPoint.floorLabel}` : '请选择起点' }}
</text>
</view>
<view class="indoor-guide-actions">
<view
class="indoor-guide-btn secondary"
@@ -159,6 +165,19 @@
<view class="indoor-guide-peek-handle"></view>
<text class="indoor-guide-peek-text">点位预览</text>
</view>
<RoutePointPicker
:visible="startPointPickerVisible"
title="选择起点"
placeholder="搜索起点"
:options="routePointOptions"
:selected-poi-id="selectedStartPoint?.poiId || ''"
:loading="startPointPickerLoading"
:error="startPointPickerError"
empty-text="暂无可作为起点的位置"
close-text="返回"
@close="startPointPickerVisible = false"
@select="handleStartPointSelect"
/>
</GuideMapShell>
</GuidePageFrame>
</template>
@@ -168,6 +187,7 @@ 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 RoutePointPicker, { type RoutePointOption } from '@/components/navigation/RoutePointPicker.vue'
import {
guideUseCase
} from '@/usecases/guideUseCase'
@@ -226,6 +246,11 @@ const routeReadinessMessage = ref('')
const routePlanning = ref(false)
const routePlanError = ref('')
const activeRoutePreview = ref<GuideRouteResult | null>(null)
const selectedStartPoint = ref<RoutePointOption | null>(null)
const routePointOptions = ref<RoutePointOption[]>([])
const startPointPickerVisible = ref(false)
const startPointPickerLoading = ref(false)
const startPointPickerError = ref('')
const isIndoorGuidePanelHidden = ref(false)
const indoorGuideTouchStartY = ref(0)
const indoorGuideTouchCurrentY = ref(0)
@@ -724,9 +749,41 @@ const handleConfirmLocation = async () => {
activeFloor.value = startLocation.floor || activeFloor.value
isManualLocationPanelOpen.value = false
activeRoutePreview.value = null
selectedStartPoint.value = null
routeViewState.value = 'preview'
requestTargetFocus(route.value.targetLocation)
saveCurrentLocationPreviewUrl()
routePlanError.value = '请选择起点'
}
const openStartPointPicker = async () => {
startPointPickerVisible.value = true
startPointPickerError.value = ''
if (routePointOptions.value.length || startPointPickerLoading.value) return
startPointPickerLoading.value = true
try {
routePointOptions.value = (await guideRouteUseCase.listTargets())
.filter((target) => Boolean(target.poiId && target.routeNodeId))
.map((target) => ({
poiId: target.poiId,
name: target.name,
floorId: target.floorId,
floorLabel: target.floorLabel,
categoryLabel: target.categoryLabel
}))
} catch (error) {
startPointPickerError.value = error instanceof Error ? error.message : '起点列表加载失败,请重试'
} finally {
startPointPickerLoading.value = false
}
}
const handleStartPointSelect = (point: RoutePointOption) => {
selectedStartPoint.value = point
startPointPickerVisible.value = false
routePlanError.value = ''
activeRoutePreview.value = null
}
const handleStartIndoorGuide = async () => {
@@ -734,37 +791,31 @@ const handleStartIndoorGuide = async () => {
await refreshRouteReadinessState()
if (!routeReady.value) return
if (!selectedStartPoint.value?.poiId) {
routePlanError.value = '请选择起点'
return
}
routePlanning.value = true
routePlanError.value = ''
try {
const targets = await guideRouteUseCase.listTargets()
const startName = route.value.startLocation?.label || ''
const startFloor = route.value.startLocation?.floor || ''
const targetLocation = route.value.targetLocation
const fallbackStart = targets.find((target) => (
startName
? target.name.includes(startName) || startName.includes(target.name)
: false
)) || targets.find((target) => (
startFloor
? target.floorLabel === startFloor || target.floorId === normalizeGuideFloorId(startFloor)
: false
)) || targets.find((target) => target.poiId !== route.value.facilityId)
const selectedStart = targets.find((target) => target.poiId === selectedStartPoint.value?.poiId)
const endTarget = targets.find((target) => target.poiId === route.value.facilityId)
|| (targetLocation
? targets.find((target) => target.floorId === targetLocation.floorId && target.name === targetLocation.name)
: null)
if (!fallbackStart || !endTarget) {
routePlanError.value = '未找到可用于位置关系预览的起点或终点'
if (!selectedStart || !selectedStart.routeNodeId || !endTarget) {
routePlanError.value = selectedStart ? '未找到可用于位置关系预览的终点' : '请选择起点'
return
}
const result = await guideRouteUseCase.planRoute({
startPoiId: fallbackStart.poiId,
startPoiId: selectedStart.poiId,
endPoiId: endTarget.poiId
})
@@ -952,6 +1003,41 @@ const handlePageBack = () => {
color: #b44b42;
}
.start-point-select {
min-height: 44px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-sizing: border-box;
background: #f5f7f2;
border: 1px solid #e4e5df;
border-radius: 8px;
}
.start-point-select-label {
flex: 0 0 auto;
font-size: 13px;
line-height: 18px;
color: #545861;
}
.start-point-select-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 19px;
font-weight: 500;
color: #151713;
}
.start-point-select-value.placeholder {
color: #7a7e76;
}
.indoor-guide-actions {
display: flex;
gap: 10px;

View File

@@ -0,0 +1,52 @@
import type { MuseumPoi } from '@/domain/museum'
export type VisitorPoiPresentation = Readonly<{
displayName: string
floorLabel: string
locationHint: string
}>
const normalizeDisplayName = (name: string) => {
const normalized = name.trim().replace(/\s+/g, ' ')
const hallMatch = normalized.match(/^展厅\s*\d+\s*(.+厅)$/)
if (hallMatch?.[1]) return hallMatch[1]
return normalized.replace(/(卫生间|洗手间)(\d+)$/, '$1 $2')
}
type VisitorPoiSource = Pick<MuseumPoi, 'id' | 'name' | 'floorId' | 'floorLabel' | 'hallName'> & {
primaryCategory?: { label?: string }
}
const locationHintFor = (poi: VisitorPoiSource) => [
poi.floorLabel?.trim(),
poi.hallName?.trim(),
poi.primaryCategory?.label?.trim()
].filter((value, index, values) => Boolean(value) && values.indexOf(value) === index).join(' · ')
export const createVisitorPoiPresentations = <TPoi extends VisitorPoiSource>(pois: TPoi[]) => {
const baseNames = new Map<string, number>()
pois.forEach((poi) => {
const name = normalizeDisplayName(poi.name || '') || '未命名点位'
baseNames.set(name, (baseNames.get(name) || 0) + 1)
})
const duplicateIndexes = new Map<string, number>()
return new Map(pois.map((poi) => {
const baseName = normalizeDisplayName(poi.name || '') || '未命名点位'
const duplicateCount = baseNames.get(baseName) || 0
const nextIndex = (duplicateIndexes.get(baseName) || 0) + 1
duplicateIndexes.set(baseName, nextIndex)
const locationHint = locationHintFor(poi)
const disambiguation = duplicateCount > 1 ? (locationHint || `设施 ${nextIndex}`) : locationHint
return [poi.id, {
displayName: duplicateCount > 1 ? `${baseName} · ${disambiguation}` : baseName,
floorLabel: poi.floorLabel || poi.floorId,
locationHint: disambiguation
} satisfies VisitorPoiPresentation]
}))
}
export const presentVisitorPoi = <TPoi extends VisitorPoiSource>(poi: TPoi): VisitorPoiPresentation => (
createVisitorPoiPresentations([poi]).get(poi.id)!
)

View File

@@ -2,7 +2,7 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
const ThreeMapStub = defineComponent({
@@ -26,6 +26,10 @@ const ThreeMapStub = defineComponent({
}
},
emits: ['floor-change', 'target-focus'],
setup(_, { expose }) {
expose({ switchFloor: vi.fn() })
return {}
},
template: '<div data-testid="three-map"></div>'
})
@@ -104,4 +108,27 @@ describe('GuideMapShell 点位过滤契约', () => {
expect(renderer.props('initialFloorId')).toBe('L2')
expect(renderer.props('initialView')).toBe('overview')
})
it('外观状态点击当前业务楼层仍请求渲染器,且等待 floor-change 才提交业务视图', async () => {
const wrapper = mount(GuideMapShell, {
props: {
mapType: 'indoor',
indoorModelSource: modelSource,
floors: [{ id: 'L2', label: '2F' }],
activeFloor: 'L2',
indoorView: 'overview',
indoorInitialView: 'overview'
},
global: { stubs: { ThreeMap: ThreeMapStub } }
})
const renderer = wrapper.getComponent(ThreeMapStub)
await (wrapper.vm as any).switchFloor('L2')
expect((renderer.vm as any).switchFloor).toHaveBeenCalledWith('L2')
expect(wrapper.emitted('indoorViewChange')).toBeUndefined()
renderer.vm.$emit('floor-change', 'L2')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('indoorViewChange')).toEqual([['floor']])
})
})

View File

@@ -334,6 +334,9 @@ describe('首页搜索与地图闭环', () => {
await guideAction.trigger('tap')
await flushPromises()
expect(map.props('indoorView')).toBe('overview')
map.vm.$emit('floor-change', 'L1')
await flushPromises()
expect(map.props('indoorView')).toBe('floor')
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { MuseumPoi } from '@/domain/museum'
import { createVisitorPoiPresentations } from '@/view-models/visitorPoiPresentation'
const poi = (id: string, name: string, floorLabel = '1F', hallName?: string): MuseumPoi => ({
id,
name,
floorId: floorLabel === '1F' ? 'L1' : 'L2',
floorLabel,
hallName,
accessible: false,
primaryCategory: { id: 'facility', label: '服务设施' },
categories: []
})
describe('VisitorPoiPresentation', () => {
it.each([
['展厅 7生态厅', '生态厅'],
['男卫生间00', '男卫生间 00'],
['贵宾卫生间', '贵宾卫生间']
])('规范游客展示名 %s', (rawName, displayName) => {
const result = createVisitorPoiPresentations([poi('p1', rawName)])
expect(result.get('p1')?.displayName).toBe(displayName)
})
it('用真实楼层或展厅区分重复设施', () => {
const result = createVisitorPoiPresentations([
poi('p1', '无障碍卫生间', '1F', '恐龙厅'),
poi('p2', '无障碍卫生间', '2F', '生态厅')
])
expect(result.get('p1')?.displayName).toBe('无障碍卫生间 · 1F · 恐龙厅 · 服务设施')
expect(result.get('p2')?.displayName).toBe('无障碍卫生间 · 2F · 生态厅 · 服务设施')
})
})