@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
const https = require('https')
|
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
|
const MUSEUM_LAT = 22.692763
|
||||||
@@ -109,6 +109,12 @@ function calculateRectangle(centerLat, centerLng, widthMeters, heightMeters) {
|
|||||||
return polygon
|
return polygon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error('缺少 TENCENT_MAP_WEBSERVICE_KEY;请通过环境变量注入受域名/IP 限制的腾讯地图 WebService Key。')
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log('正在获取深圳自然博物馆建筑围栏信息...\n')
|
console.log('正在获取深圳自然博物馆建筑围栏信息...\n')
|
||||||
|
|
||||||
// 执行查询
|
// 执行查询
|
||||||
|
|||||||
@@ -586,10 +586,13 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
|
|||||||
const handleFloorChange = (floor: { id: string; label: string }) => {
|
const handleFloorChange = (floor: { id: string; label: string }) => {
|
||||||
const floorId = floor.id
|
const floorId = floor.id
|
||||||
if (!floorId || loadingFloorId.value) return
|
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('floorChange', floorId)
|
||||||
emit('indoorViewChange', 'floor')
|
|
||||||
emit('layerModeChange', 'single')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,9 +607,6 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
|
|||||||
floorId,
|
floorId,
|
||||||
floorLabel: floor.label
|
floorLabel: floor.label
|
||||||
})
|
})
|
||||||
emit('indoorViewChange', 'floor')
|
|
||||||
emit('layerModeChange', 'single')
|
|
||||||
|
|
||||||
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
|
||||||
@@ -667,10 +667,12 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
|||||||
})
|
})
|
||||||
console.error('楼层切换失败:', error)
|
console.error('楼层切换失败:', error)
|
||||||
})
|
})
|
||||||
emit('indoorViewChange', 'floor')
|
// floor-change 才代表 ThreeMap 已提交单层模型。
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('layerModeChange', mode)
|
if (mode === 'multi') {
|
||||||
|
emit('layerModeChange', mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLayerModeToggle = () => {
|
const handleLayerModeToggle = () => {
|
||||||
@@ -786,6 +788,8 @@ defineExpose({
|
|||||||
clearRoute: () => {
|
clearRoute: () => {
|
||||||
indoorRendererRef.value?.clearRoute?.()
|
indoorRendererRef.value?.clearRoute?.()
|
||||||
},
|
},
|
||||||
|
// 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
|
||||||
|
switchFloor: (floorId: string) => indoorRendererRef.value?.switchFloor?.(floorId),
|
||||||
showOverview: handleShowOverview,
|
showOverview: handleShowOverview,
|
||||||
resetToViewBaseline: (options: {
|
resetToViewBaseline: (options: {
|
||||||
view: 'overview' | 'floor'
|
view: 'overview' | 'floor'
|
||||||
|
|||||||
@@ -3,13 +3,21 @@
|
|||||||
class="guide-top-tabs"
|
class="guide-top-tabs"
|
||||||
:class="`variant-${variant}`"
|
:class="`variant-${variant}`"
|
||||||
:style="{ top }"
|
:style="{ top }"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="主功能"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
v-for="tab in GUIDE_TOP_TABS"
|
v-for="tab in GUIDE_TOP_TABS"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
class="guide-top-tab"
|
class="guide-top-tab"
|
||||||
:class="{ active: activeTab === tab.id }"
|
: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)"
|
@tap="handleTabTap(tab.id)"
|
||||||
|
@keydown.enter.prevent="handleTabTap(tab.id)"
|
||||||
|
@keydown.space.prevent="handleTabTap(tab.id)"
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
v-if="activeTab === tab.id && variant === 'underline'"
|
v-if="activeTab === tab.id && variant === 'underline'"
|
||||||
|
|||||||
@@ -226,6 +226,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
guideUseCase
|
guideUseCase
|
||||||
} from '@/usecases/guideUseCase'
|
} from '@/usecases/guideUseCase'
|
||||||
|
import {
|
||||||
|
createVisitorPoiPresentations
|
||||||
|
} from '@/view-models/visitorPoiPresentation'
|
||||||
import {
|
import {
|
||||||
HOME_POI_CATEGORIES,
|
HOME_POI_CATEGORIES,
|
||||||
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 unavailablePoiCount = computed(() => pois.value.filter((poi) => !isPoiLocatable(poi)).length)
|
||||||
|
|
||||||
|
// 数据质量统计保留给诊断日志,普通游客只看到可恢复的加载状态。
|
||||||
const dataWarning = computed(() => {
|
const dataWarning = computed(() => {
|
||||||
const warnings: string[] = []
|
const diagnostic = {
|
||||||
if (excludedPoiCount.value) warnings.push(`${excludedPoiCount.value} 个点位分类或楼层信息不完整`)
|
excluded: excludedPoiCount.value,
|
||||||
if (duplicatePoiCount.value) warnings.push(`${duplicatePoiCount.value} 个重复点位已合并`)
|
duplicates: duplicatePoiCount.value,
|
||||||
if (unavailablePoiCount.value) warnings.push(`${unavailablePoiCount.value} 个点位暂无地图坐标`)
|
unavailable: unavailablePoiCount.value,
|
||||||
if (floorLoadError.value) warnings.push('楼层信息读取失败')
|
floorLoadError: floorLoadError.value
|
||||||
return warnings.join(',')
|
}
|
||||||
|
if (import.meta.env.DEV && Object.values(diagnostic).some(Boolean)) {
|
||||||
|
console.debug('[guide-poi-diagnostics]', diagnostic)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const dedupePois = (items: MuseumPoi[]) => {
|
const dedupePois = (items: MuseumPoi[]) => {
|
||||||
@@ -860,7 +868,10 @@ const restoreResultListScroll = async () => {
|
|||||||
if (resultListElement) resultListElement.scrollTop = scrollTop
|
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 poiResultMeta = (poi: MuseumPoi) => {
|
||||||
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'
|
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!shouldUseHostNavigation"
|
v-if="!shouldUseHostNavigation"
|
||||||
class="hero-back"
|
class="hero-back"
|
||||||
aria-label="返回讲解详情"
|
aria-label="返回讲解对象列表"
|
||||||
@tap.stop="handleBack"
|
@tap.stop="handleBack"
|
||||||
>
|
>
|
||||||
<text class="hero-back-icon">‹</text>
|
<text class="hero-back-icon">‹</text>
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ const returningToOverview = ref(false)
|
|||||||
// GuideMapShell 组件引用,用于显式调用路线清除方法
|
// GuideMapShell 组件引用,用于显式调用路线清除方法
|
||||||
const guideMapShellRef = ref<{
|
const guideMapShellRef = ref<{
|
||||||
clearRoute?: () => void
|
clearRoute?: () => void
|
||||||
|
switchFloor?: (floorId: string) => Promise<void> | void
|
||||||
showOverview?: () => Promise<void> | void
|
showOverview?: () => Promise<void> | void
|
||||||
resetToViewBaseline?: (options: {
|
resetToViewBaseline?: (options: {
|
||||||
view: 'overview' | 'floor'
|
view: 'overview' | 'floor'
|
||||||
@@ -1482,12 +1483,24 @@ const handleMoreRouteGuide = async () => {
|
|||||||
closeArrivalPanel()
|
closeArrivalPanel()
|
||||||
showRoutePlanner.value = false
|
showRoutePlanner.value = false
|
||||||
is3DMode.value = true
|
is3DMode.value = true
|
||||||
indoorView.value = 'floor'
|
|
||||||
guideOutdoorState.value = 'home'
|
guideOutdoorState.value = 'home'
|
||||||
selectedGuidePoi.value = null
|
selectedGuidePoi.value = null
|
||||||
isPoiCardCollapsed.value = false
|
isPoiCardCollapsed.value = false
|
||||||
isSimulatingRoute.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 = () => {
|
const selectFirstArrivalTarget = () => {
|
||||||
|
|||||||
@@ -132,6 +132,12 @@
|
|||||||
<view v-if="routePlanError" class="indoor-guide-error">
|
<view v-if="routePlanError" class="indoor-guide-error">
|
||||||
<text class="indoor-guide-error-text">{{ routePlanError }}</text>
|
<text class="indoor-guide-error-text">{{ routePlanError }}</text>
|
||||||
</view>
|
</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-actions">
|
||||||
<view
|
<view
|
||||||
class="indoor-guide-btn secondary"
|
class="indoor-guide-btn secondary"
|
||||||
@@ -159,6 +165,19 @@
|
|||||||
<view class="indoor-guide-peek-handle"></view>
|
<view class="indoor-guide-peek-handle"></view>
|
||||||
<text class="indoor-guide-peek-text">点位预览</text>
|
<text class="indoor-guide-peek-text">点位预览</text>
|
||||||
</view>
|
</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>
|
</GuideMapShell>
|
||||||
</GuidePageFrame>
|
</GuidePageFrame>
|
||||||
</template>
|
</template>
|
||||||
@@ -168,6 +187,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||||
|
import RoutePointPicker, { type RoutePointOption } from '@/components/navigation/RoutePointPicker.vue'
|
||||||
import {
|
import {
|
||||||
guideUseCase
|
guideUseCase
|
||||||
} from '@/usecases/guideUseCase'
|
} from '@/usecases/guideUseCase'
|
||||||
@@ -226,6 +246,11 @@ const routeReadinessMessage = ref('')
|
|||||||
const routePlanning = ref(false)
|
const routePlanning = ref(false)
|
||||||
const routePlanError = ref('')
|
const routePlanError = ref('')
|
||||||
const activeRoutePreview = ref<GuideRouteResult | null>(null)
|
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 isIndoorGuidePanelHidden = ref(false)
|
||||||
const indoorGuideTouchStartY = ref(0)
|
const indoorGuideTouchStartY = ref(0)
|
||||||
const indoorGuideTouchCurrentY = ref(0)
|
const indoorGuideTouchCurrentY = ref(0)
|
||||||
@@ -724,9 +749,41 @@ const handleConfirmLocation = async () => {
|
|||||||
activeFloor.value = startLocation.floor || activeFloor.value
|
activeFloor.value = startLocation.floor || activeFloor.value
|
||||||
isManualLocationPanelOpen.value = false
|
isManualLocationPanelOpen.value = false
|
||||||
activeRoutePreview.value = null
|
activeRoutePreview.value = null
|
||||||
|
selectedStartPoint.value = null
|
||||||
routeViewState.value = 'preview'
|
routeViewState.value = 'preview'
|
||||||
requestTargetFocus(route.value.targetLocation)
|
requestTargetFocus(route.value.targetLocation)
|
||||||
saveCurrentLocationPreviewUrl()
|
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 () => {
|
const handleStartIndoorGuide = async () => {
|
||||||
@@ -734,37 +791,31 @@ const handleStartIndoorGuide = async () => {
|
|||||||
|
|
||||||
await refreshRouteReadinessState()
|
await refreshRouteReadinessState()
|
||||||
if (!routeReady.value) return
|
if (!routeReady.value) return
|
||||||
|
if (!selectedStartPoint.value?.poiId) {
|
||||||
|
routePlanError.value = '请选择起点'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
routePlanning.value = true
|
routePlanning.value = true
|
||||||
routePlanError.value = ''
|
routePlanError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targets = await guideRouteUseCase.listTargets()
|
const targets = await guideRouteUseCase.listTargets()
|
||||||
const startName = route.value.startLocation?.label || ''
|
|
||||||
const startFloor = route.value.startLocation?.floor || ''
|
|
||||||
const targetLocation = route.value.targetLocation
|
const targetLocation = route.value.targetLocation
|
||||||
const fallbackStart = targets.find((target) => (
|
const selectedStart = targets.find((target) => target.poiId === selectedStartPoint.value?.poiId)
|
||||||
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 endTarget = targets.find((target) => target.poiId === route.value.facilityId)
|
const endTarget = targets.find((target) => target.poiId === route.value.facilityId)
|
||||||
|| (targetLocation
|
|| (targetLocation
|
||||||
? targets.find((target) => target.floorId === targetLocation.floorId && target.name === targetLocation.name)
|
? targets.find((target) => target.floorId === targetLocation.floorId && target.name === targetLocation.name)
|
||||||
: null)
|
: null)
|
||||||
|
|
||||||
if (!fallbackStart || !endTarget) {
|
if (!selectedStart || !selectedStart.routeNodeId || !endTarget) {
|
||||||
routePlanError.value = '未找到可用于位置关系预览的起点或终点'
|
routePlanError.value = selectedStart ? '未找到可用于位置关系预览的终点' : '请选择起点'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await guideRouteUseCase.planRoute({
|
const result = await guideRouteUseCase.planRoute({
|
||||||
startPoiId: fallbackStart.poiId,
|
startPoiId: selectedStart.poiId,
|
||||||
endPoiId: endTarget.poiId
|
endPoiId: endTarget.poiId
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -952,6 +1003,41 @@ const handlePageBack = () => {
|
|||||||
color: #b44b42;
|
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 {
|
.indoor-guide-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
52
src/view-models/visitorPoiPresentation.ts
Normal file
52
src/view-models/visitorPoiPresentation.ts
Normal 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)!
|
||||||
|
)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
import { mount } from '@vue/test-utils'
|
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'
|
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||||
|
|
||||||
const ThreeMapStub = defineComponent({
|
const ThreeMapStub = defineComponent({
|
||||||
@@ -26,6 +26,10 @@ const ThreeMapStub = defineComponent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['floor-change', 'target-focus'],
|
emits: ['floor-change', 'target-focus'],
|
||||||
|
setup(_, { expose }) {
|
||||||
|
expose({ switchFloor: vi.fn() })
|
||||||
|
return {}
|
||||||
|
},
|
||||||
template: '<div data-testid="three-map"></div>'
|
template: '<div data-testid="three-map"></div>'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,4 +108,27 @@ describe('GuideMapShell 点位过滤契约', () => {
|
|||||||
expect(renderer.props('initialFloorId')).toBe('L2')
|
expect(renderer.props('initialFloorId')).toBe('L2')
|
||||||
expect(renderer.props('initialView')).toBe('overview')
|
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']])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ describe('首页搜索与地图闭环', () => {
|
|||||||
|
|
||||||
await guideAction.trigger('tap')
|
await guideAction.trigger('tap')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(map.props('indoorView')).toBe('overview')
|
||||||
|
map.vm.$emit('floor-change', 'L1')
|
||||||
|
await flushPromises()
|
||||||
expect(map.props('indoorView')).toBe('floor')
|
expect(map.props('indoorView')).toBe('floor')
|
||||||
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
|
expect(wrapper.findAll('.guide-quick-action')[0]?.text()).toBe('导览')
|
||||||
|
|
||||||
|
|||||||
35
tests/unit/visitorPoiPresentation.spec.ts
Normal file
35
tests/unit/visitorPoiPresentation.spec.ts
Normal 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 · 生态厅 · 服务设施')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user