修复馆内预览状态与点位展示
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

@@ -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,10 +667,12 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
})
console.error('楼层切换失败:', error)
})
emit('indoorViewChange', 'floor')
// floor-change 才代表 ThreeMap 已提交单层模型。
}
emit('layerModeChange', mode)
if (mode === 'multi') {
emit('layerModeChange', mode)
}
}
const handleLayerModeToggle = () => {
@@ -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)!
)