修复导览与讲解位置预览逻辑
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
这些 JSON 是早期演示数据,当前导览模块不再以这里作为数据源。
|
||||
|
||||
当前室内导览、设施搜索、位置预览统一通过 Provider / Adapter / Repository / UseCase 数据边界读取 `static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy`,页面和展示组件不要直接依赖静态包 JSON 文件。
|
||||
当前馆内导览、设施搜索、位置预览统一通过 Provider / Adapter / Repository / UseCase 数据边界读取 `static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy`,页面和展示组件不要直接依赖静态包 JSON 文件。
|
||||
|
||||
当前展厅、单元、讲解点、展品、讲解词和 POI 业务内容统一通过 `static/guide-data` 静态数据包读取,默认数据源模式为 `VITE_GUIDE_CONTENT_SOURCE_MODE=static`。
|
||||
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-heading">
|
||||
<text class="section-title">请选择展厅</text>
|
||||
<text class="section-count">{{ filteredHalls.length }} 个展厅</text>
|
||||
</view>
|
||||
|
||||
<view v-if="filteredHalls.length" class="hall-list">
|
||||
<view
|
||||
v-for="hall in filteredHalls"
|
||||
@@ -37,10 +32,10 @@
|
||||
@tap="handleHallClick(hall.id)"
|
||||
>
|
||||
<image
|
||||
v-if="hall.image"
|
||||
v-if="hallIconUrl(hall)"
|
||||
class="hall-thumb"
|
||||
:src="hall.image"
|
||||
mode="aspectFill"
|
||||
:src="hallIconUrl(hall)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-else class="hall-thumb placeholder">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
@@ -96,8 +91,26 @@ const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const HALL_ICON_BASE = '/static/icons/halls'
|
||||
|
||||
const hallIconMap: Record<string, string> = {
|
||||
宇宙厅: `${HALL_ICON_BASE}/universe.jpg`,
|
||||
地球厅: `${HALL_ICON_BASE}/earth.jpg`,
|
||||
演化厅: `${HALL_ICON_BASE}/evolution.jpg`,
|
||||
恐龙厅: `${HALL_ICON_BASE}/dinosaur.jpg`,
|
||||
人类厅: `${HALL_ICON_BASE}/human.jpg`,
|
||||
动物厅: `${HALL_ICON_BASE}/animal.jpg`,
|
||||
生物厅: `${HALL_ICON_BASE}/biology.jpg`,
|
||||
生态厅: `${HALL_ICON_BASE}/ecology.jpg`,
|
||||
家园厅: `${HALL_ICON_BASE}/homeland.jpg`
|
||||
}
|
||||
|
||||
const filteredHalls = computed(() => props.halls)
|
||||
|
||||
const hallIconUrl = (hall: ExplainHallSelectItem) => {
|
||||
return hallIconMap[hall.name.trim()] || hall.image || ''
|
||||
}
|
||||
|
||||
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
|
||||
|
||||
const handleHallClick = (hallId: string) => {
|
||||
@@ -124,8 +137,6 @@ const handleBack = () => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.section-count,
|
||||
.state-title,
|
||||
.state-desc,
|
||||
.hall-name,
|
||||
@@ -180,29 +191,6 @@ const handleBack = () => {
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 19px;
|
||||
line-height: 27px;
|
||||
font-weight: 800;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #7b8275;
|
||||
}
|
||||
|
||||
.hall-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -231,7 +219,10 @@ const handleBack = () => {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 8px;
|
||||
background: #f1f2ee;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(36, 49, 42, 0.06);
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hall-thumb.placeholder {
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
|
||||
<!-- 功能按钮 -->
|
||||
<cover-view class="action-buttons">
|
||||
<!-- 进入 3D 室内模式按钮 -->
|
||||
<!-- 进入 3D 馆内模式按钮 -->
|
||||
<cover-view class="action-btn indoor-mode-btn" @tap="handleEnter3D">
|
||||
<cover-view class="btn-text">🏛</cover-view>
|
||||
<cover-view class="btn-sub-text">室内</cover-view>
|
||||
<cover-view class="btn-sub-text">馆内</cover-view>
|
||||
</cover-view>
|
||||
<cover-view class="action-btn" @tap="handleLocation">
|
||||
<cover-view class="btn-text">📍</cover-view>
|
||||
@@ -129,7 +129,7 @@ const emit = defineEmits<{
|
||||
// POI ID: 7043042949572197975
|
||||
const mapCenter = ref<MapCenter>({
|
||||
latitude: 22.692763, // 深圳自然博物馆纬度
|
||||
longitude: 114.363572 // 深圳自然博物馆经度(调整到室外 2D 视觉中心)
|
||||
longitude: 114.363572 // 深圳自然博物馆经度(调整到馆外 2D 视觉中心)
|
||||
})
|
||||
|
||||
// 地图缩放级别(调整为适中比例,既能看到建筑全貌又能看清细节)
|
||||
@@ -378,9 +378,9 @@ const handleZoomOut = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 进入 3D 室内模式
|
||||
// 进入 3D 馆内模式
|
||||
const handleEnter3D = () => {
|
||||
console.log('进入 3D 室内模式')
|
||||
console.log('进入 3D 馆内模式')
|
||||
emit('enter3DMode')
|
||||
}
|
||||
|
||||
@@ -490,7 +490,7 @@ onMounted(async () => {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
// 室内模式按钮
|
||||
// 馆内模式按钮
|
||||
.indoor-mode-btn {
|
||||
width: 46px;
|
||||
height: 64px !important;
|
||||
|
||||
@@ -72,6 +72,7 @@ type TouchGestureMode = 'orbit' | 'pan'
|
||||
type ZoomCameraSource = 'button' | 'gesture'
|
||||
|
||||
const CANVAS_FONT_FAMILY = '"HarmonyOS Sans SC", "HarmonyOS Sans", "鸿蒙黑体", "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", Arial, sans-serif'
|
||||
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
|
||||
|
||||
interface CameraFitOptions {
|
||||
distanceFactor?: number
|
||||
@@ -203,7 +204,7 @@ const props = withDefaults(defineProps<{
|
||||
showPoi: false,
|
||||
targetFocus: null,
|
||||
touchGestureMode: 'orbit',
|
||||
targetFocusDistanceFactor: 0.22,
|
||||
targetFocusDistanceFactor: 0.36,
|
||||
routePreview: null,
|
||||
showRoute: false,
|
||||
autoSwitch: true,
|
||||
@@ -327,6 +328,9 @@ const syncControlInteractionOptions = () => {
|
||||
}
|
||||
|
||||
const corePoiCategories = new Set([
|
||||
'touring_poi',
|
||||
'exhibition_hall',
|
||||
'exhibition_hall_entrance',
|
||||
'basic_service_facility',
|
||||
'transport_circulation',
|
||||
'accessibility_special_service'
|
||||
@@ -334,10 +338,12 @@ const corePoiCategories = new Set([
|
||||
|
||||
const poiCategoryPriority: Record<string, number> = {
|
||||
target_preview: 100,
|
||||
exhibition_hall: 96,
|
||||
exhibition_hall_entrance: 92,
|
||||
touring_poi: 90,
|
||||
accessibility_special_service: 88,
|
||||
basic_service_facility: 82,
|
||||
transport_circulation: 76,
|
||||
touring_poi: 58,
|
||||
operation_experience: 46
|
||||
}
|
||||
|
||||
@@ -351,6 +357,9 @@ const poiGlyphMap: Record<string, string> = {
|
||||
stair: '梯',
|
||||
experience: '展',
|
||||
theater: '演',
|
||||
exhibition_hall: '展',
|
||||
hall_entrance: '门',
|
||||
entrance_exit: '门',
|
||||
target_preview: '位'
|
||||
}
|
||||
|
||||
@@ -2217,6 +2226,11 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
|
||||
screenOffsetRatio: new THREE.Vector2(-0.045, 0.02),
|
||||
up: new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
: activeView.value === 'floor'
|
||||
? {
|
||||
distanceFactor: FLOOR_CAMERA_DISTANCE_FACTOR,
|
||||
up: new THREE.Vector3(0, 1, 0)
|
||||
}
|
||||
: {}
|
||||
|
||||
setCameraView(center, maxDim, direction, overviewFitOptions)
|
||||
@@ -2918,6 +2932,8 @@ const showFocusPoiAffordances = (poi: RenderPoi) => {
|
||||
const getPoiColor = (category: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
touring_poi: '#2f6fed',
|
||||
exhibition_hall: '#2f6fed',
|
||||
exhibition_hall_entrance: '#4659d8',
|
||||
basic_service_facility: '#1f8f5f',
|
||||
transport_circulation: '#d77b20',
|
||||
accessibility_special_service: '#8a5cf6',
|
||||
@@ -3166,7 +3182,7 @@ const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
|
||||
getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
|
||||
}
|
||||
|
||||
const handleSceneTap = (event: PointerEvent) => {
|
||||
const handleSceneTap = async (event: PointerEvent) => {
|
||||
if (!camera || !renderer || !getContainerElement()) return
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
@@ -3177,7 +3193,7 @@ const handleSceneTap = (event: PointerEvent) => {
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
|
||||
if (activeView.value === 'floor' && poiGroup) {
|
||||
if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
|
||||
const poiHits = raycaster.intersectObjects([...getPoiHitTargets(), ...getPoiSprites()], false)
|
||||
const hit = poiHits.find((item) => (
|
||||
item.object.visible
|
||||
@@ -3187,10 +3203,27 @@ const handleSceneTap = (event: PointerEvent) => {
|
||||
const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : findNearestPoiMarkerByScreenPoint(event, rect)
|
||||
|
||||
if (hitMarker?.userData.poi) {
|
||||
const poi = hitMarker.userData.poi as RenderPoi
|
||||
const selectedPoi = hitMarker.userData.poi as RenderPoi
|
||||
if (activeView.value === 'overview') {
|
||||
disableAutoSwitchTemporarily(10000)
|
||||
try {
|
||||
await loadFloor(selectedPoi.floorId, {
|
||||
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
|
||||
suppressProgress: true,
|
||||
allowOverviewFallback: true
|
||||
})
|
||||
emit('floorChange', selectedPoi.floorId)
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) return
|
||||
console.error('展厅点位楼层切换失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
|
||||
const focusMarker = findPoiSprite(poi.id) || hitMarker
|
||||
selectedPOI.value = poi
|
||||
activeFocusPoiId.value = poi.id
|
||||
triggerPoiTapFeedback(hitMarker)
|
||||
triggerPoiTapFeedback(focusMarker)
|
||||
updatePoiMarkerFocus()
|
||||
showFocusPoiAffordances(poi)
|
||||
focusCameraOnPoi(poi)
|
||||
@@ -3231,7 +3264,7 @@ const handlePointerUp = (event: PointerEvent) => {
|
||||
|
||||
if (moveDistance > 8) return
|
||||
|
||||
handleSceneTap(event)
|
||||
void handleSceneTap(event)
|
||||
}
|
||||
|
||||
const emitTargetFocus = (
|
||||
@@ -3302,9 +3335,12 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
|
||||
try {
|
||||
if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
await loadFloor(request.floorId)
|
||||
await loadFloor(request.floorId, {
|
||||
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
|
||||
suppressProgress: true,
|
||||
allowOverviewFallback: true
|
||||
})
|
||||
isLoading.value = false
|
||||
emit('floorChange', request.floorId)
|
||||
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
|
||||
@@ -3419,31 +3455,19 @@ const init3DScene = async () => {
|
||||
|
||||
const handleFloorChange = async (floorId: string) => {
|
||||
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
||||
const showLoading = !hasSceneToKeep && !canLoadFloorSilently(floorId)
|
||||
|
||||
try {
|
||||
if (showLoading) {
|
||||
isLoading.value = true
|
||||
}
|
||||
loadError.value = false
|
||||
await loadFloor(floorId, {
|
||||
preserveCurrentSceneUntilReady: hasSceneToKeep,
|
||||
suppressProgress: !showLoading
|
||||
suppressProgress: true,
|
||||
allowOverviewFallback: true
|
||||
})
|
||||
updatePoiVisibilityByDistance()
|
||||
if (showLoading) {
|
||||
isLoading.value = false
|
||||
} else {
|
||||
isLoading.value = false
|
||||
}
|
||||
isLoading.value = false
|
||||
emit('floorChange', floorId)
|
||||
} catch (error) {
|
||||
if (isStaleModelLoadError(error)) {
|
||||
if (showLoading) {
|
||||
isLoading.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isStaleModelLoadError(error)) return
|
||||
|
||||
console.error('楼层模型加载失败:', error)
|
||||
loadError.value = true
|
||||
|
||||
@@ -126,11 +126,15 @@
|
||||
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
<scroll-view
|
||||
v-if="showIndoorRightControls && showFloor"
|
||||
class="floor-switcher"
|
||||
:class="`side-${floorSide}`"
|
||||
:style="floorSwitcherStyle"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
:scroll-into-view="activeFloorScrollIntoView"
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<view
|
||||
v-if="showFloorHeader"
|
||||
@@ -142,15 +146,16 @@
|
||||
<text class="floor-header-label">楼层</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="floor in floorLabels"
|
||||
:key="floor"
|
||||
v-for="floor in floorItems"
|
||||
:id="floor.scrollId"
|
||||
:key="floor.id"
|
||||
class="floor-item"
|
||||
:class="{ active: activeFloor === floor && layerMode !== 'multi' }"
|
||||
@tap="handleFloorChange(floor)"
|
||||
:class="{ active: activeFloorId === floor.id && layerMode !== 'multi' }"
|
||||
@tap="handleFloorChange(floor.label)"
|
||||
>
|
||||
<text class="floor-label">{{ floor }}</text>
|
||||
<text class="floor-label">{{ floor.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view v-if="showZoomControls" class="zoom-controls" :style="zoomControlsStyle">
|
||||
<view class="zoom-btn" @tap="handleZoomClick('in')">
|
||||
@@ -364,7 +369,7 @@ const props = withDefaults(defineProps<{
|
||||
dimmed: false,
|
||||
targetFocusRequest: null,
|
||||
touchGestureMode: 'orbit',
|
||||
targetFocusDistanceFactor: 0.22,
|
||||
targetFocusDistanceFactor: 0.36,
|
||||
routePreview: null,
|
||||
showRoute: false,
|
||||
disableAutoExit: false,
|
||||
@@ -415,7 +420,11 @@ const indoorRendererRef = ref<{
|
||||
} | null>(null)
|
||||
|
||||
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
|
||||
const floorLabels = computed(() => indoorFloors.value.map((floor) => floor.label))
|
||||
const createFloorScrollId = (floorId: string) => `guide-floor-${floorId.replace(/[^a-zA-Z0-9_-]/g, '-')}`
|
||||
const floorItems = computed(() => indoorFloors.value.map((floor) => ({
|
||||
...floor,
|
||||
scrollId: createFloorScrollId(floor.id)
|
||||
})))
|
||||
// #ifdef H5
|
||||
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
|
||||
// #endif
|
||||
@@ -435,6 +444,29 @@ const activeFloorId = computed(() => {
|
||||
))
|
||||
return normalizedFloor?.id || normalizedFloorId
|
||||
})
|
||||
const activeFloorScrollIntoView = ref('')
|
||||
|
||||
const syncActiveFloorScroll = () => {
|
||||
if (props.mapType !== 'indoor' || props.indoorView === 'overview' || props.layerMode === 'multi') {
|
||||
activeFloorScrollIntoView.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const matchedFloor = floorItems.value.find((floor) => floor.id === activeFloorId.value)
|
||||
activeFloorScrollIntoView.value = matchedFloor?.scrollId || ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
activeFloorId.value,
|
||||
props.indoorView,
|
||||
props.layerMode,
|
||||
props.mapType,
|
||||
floorItems.value.map((floor) => floor.id).join('|')
|
||||
],
|
||||
syncActiveFloorScroll,
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const searchFieldStyle = computed(() => ({
|
||||
top: props.searchTop
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="emit('viewOutdoorMap')">
|
||||
<text class="action-text">室外入口参考</text>
|
||||
<text class="action-text">馆外入口参考</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleShowTargetLocation">
|
||||
<text class="action-text">查看三维位置</text>
|
||||
|
||||
@@ -220,7 +220,7 @@ const canNavigate = computed(() => {
|
||||
|
||||
const navigateButtonText = computed(() => {
|
||||
if (props.loading) return '规划中...'
|
||||
if (hasRoute.value) return '开始导航'
|
||||
if (hasRoute.value) return '打开地图'
|
||||
return '规划路线'
|
||||
})
|
||||
|
||||
|
||||
@@ -195,17 +195,11 @@ const canViewRoute = computed(() => Boolean(
|
||||
|
||||
const canPrimaryAction = computed(() => canViewRoute.value && !props.error)
|
||||
|
||||
const primaryActionText = computed(() => (
|
||||
props.hasRoutePreview
|
||||
? '开始导览'
|
||||
: (props.routeReady ? '查看路线' : '查看位置关系')
|
||||
))
|
||||
const primaryActionText = computed(() => '查看位置关系')
|
||||
|
||||
const routeOptionsTitle = computed(() => '馆内导览')
|
||||
|
||||
const loadingText = computed(() => (
|
||||
props.routeReady ? '正在规划馆内导览路线' : '正在生成馆内位置关系'
|
||||
))
|
||||
const loadingText = computed(() => '正在生成馆内位置关系')
|
||||
|
||||
const collapsedSummary = computed(() => {
|
||||
if (props.startPoint && props.endPoint) {
|
||||
@@ -217,7 +211,7 @@ const collapsedSummary = computed(() => {
|
||||
|
||||
const routeOptions = computed(() => {
|
||||
const unavailable = Boolean(props.error)
|
||||
const summaryText = props.summary || (props.routeReady ? '选择后规划真实路线' : '选择后查看位置关系')
|
||||
const summaryText = props.summary || '选择后查看位置关系'
|
||||
return [
|
||||
{
|
||||
id: 'recommended',
|
||||
@@ -226,23 +220,21 @@ const routeOptions = computed(() => {
|
||||
? '当前暂不可用'
|
||||
: props.hasRoutePreview
|
||||
? summaryText
|
||||
: props.routeReady
|
||||
? '调用 SGS SDK 路线规划'
|
||||
: '查看起点终点位置关系',
|
||||
: '查看起点终点位置关系',
|
||||
active: !unavailable,
|
||||
disabled: unavailable
|
||||
},
|
||||
{
|
||||
id: 'stairs',
|
||||
title: '少走楼梯',
|
||||
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
||||
meta: '偏好预览,待路线数据验证',
|
||||
active: false,
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'elevator',
|
||||
title: '电梯优先',
|
||||
meta: props.routeReady ? '偏好能力待接入' : '偏好预览,待路线数据验证',
|
||||
meta: '偏好预览,待路线数据验证',
|
||||
active: false,
|
||||
disabled: true
|
||||
}
|
||||
|
||||
@@ -67,5 +67,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => ({
|
||||
sourceObjectName: poi.sourceObjectName,
|
||||
sourceConfidence: poi.sourceConfidence,
|
||||
navigationReadiness: poi.navigationReadiness,
|
||||
accessible: isPoiAccessible(poi)
|
||||
accessible: isPoiAccessible(poi),
|
||||
kind: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility',
|
||||
hallName: poi.primaryCategory === 'touring_poi' ? poi.name : undefined
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
GuideRouteTarget
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
SgsRoutePathPointPayload,
|
||||
SgsRoutePlanResponsePayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import type {
|
||||
@@ -31,10 +32,25 @@ type GeoJsonLineString = {
|
||||
coordinates?: Array<[number, number] | [number, number, number]>
|
||||
}
|
||||
|
||||
const finiteNumber = (value: unknown) => {
|
||||
const numericValue = Number(value)
|
||||
return Number.isFinite(numericValue) ? numericValue : undefined
|
||||
}
|
||||
|
||||
const stringifyRouteId = (value: unknown, fallback = '') => {
|
||||
if (value === null || typeof value === 'undefined' || value === '') return fallback
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const samePosition = (
|
||||
a: [number, number, number],
|
||||
b: [number, number, number]
|
||||
) => a.every((value, index) => Math.abs(value - b[index]) < 0.001)
|
||||
|
||||
const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
|
||||
Number(point.x || 0),
|
||||
Number(point.y || 0),
|
||||
Number(point.z || 0)
|
||||
finiteNumber(point.x) ?? 0,
|
||||
finiteNumber(point.y) ?? 0,
|
||||
finiteNumber(point.z) ?? 0
|
||||
]
|
||||
|
||||
const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
|
||||
@@ -165,8 +181,10 @@ export const toGuideRouteResultFromSgs = (
|
||||
const floorLabelFor = (
|
||||
floorId: string,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
endTarget: GuideRouteTarget,
|
||||
fallbackLabel?: string | null
|
||||
) => {
|
||||
if (fallbackLabel) return fallbackLabel
|
||||
if (floorId === startTarget.floorId) return startTarget.floorLabel
|
||||
if (floorId === endTarget.floorId) return endTarget.floorLabel
|
||||
return floorId
|
||||
@@ -176,19 +194,54 @@ const pointFromBackendNode = (
|
||||
node: NonNullable<SgsRoutePlanResponsePayload['nodePaths']>[number],
|
||||
index: number,
|
||||
fallbackFloorId: string
|
||||
): GuideRoutePoint => ({
|
||||
nodeId: String(node.id || `sgs-route-node-${index}`),
|
||||
floorId: toAppFloorId(node.floorId ?? fallbackFloorId),
|
||||
position: [
|
||||
Number(node.x || 0),
|
||||
0,
|
||||
Number(node.y || 0)
|
||||
]
|
||||
})
|
||||
): GuideRoutePoint | null => {
|
||||
const x = finiteNumber(node.x)
|
||||
const z = finiteNumber(node.y)
|
||||
if (typeof x !== 'number' || typeof z !== 'number') return null
|
||||
|
||||
return {
|
||||
nodeId: stringifyRouteId(node.id, `sgs-route-node-${index}`),
|
||||
floorId: stringifyRouteId(node.floorId, fallbackFloorId),
|
||||
position: [
|
||||
x,
|
||||
finiteNumber(node.z) ?? 0,
|
||||
z
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const pointFromPathPoint = (
|
||||
point: SgsRoutePathPointPayload,
|
||||
floorId: string,
|
||||
nodeId: string
|
||||
): GuideRoutePoint | null => {
|
||||
const x = finiteNumber(point.x)
|
||||
const z = finiteNumber(point.z)
|
||||
if (typeof x !== 'number' || typeof z !== 'number') return null
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
floorId,
|
||||
position: [
|
||||
x,
|
||||
finiteNumber(point.y) ?? 0,
|
||||
z
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const pointsFromPathPoints = (
|
||||
pathPoints: SgsRoutePathPointPayload[] | null | undefined,
|
||||
floorId: string,
|
||||
nodeIdPrefix: string
|
||||
) => (pathPoints || [])
|
||||
.map((point, index) => pointFromPathPoint(point, floorId, `${nodeIdPrefix}-pp-${index}`))
|
||||
.filter((point): point is GuideRoutePoint => Boolean(point))
|
||||
|
||||
const pointsFromGeoJson = (
|
||||
geoJson: string | null | undefined,
|
||||
floorId: string
|
||||
floorId: string,
|
||||
nodeIdPrefix = floorId
|
||||
): GuideRoutePoint[] => {
|
||||
if (!geoJson) return []
|
||||
|
||||
@@ -198,20 +251,147 @@ const pointsFromGeoJson = (
|
||||
? parsed.coordinates
|
||||
: []
|
||||
|
||||
return coordinates.map((coordinate, index) => ({
|
||||
nodeId: `${floorId}-geo-${index}`,
|
||||
floorId,
|
||||
position: [
|
||||
Number(coordinate[0] || 0),
|
||||
0,
|
||||
Number(coordinate[1] || 0)
|
||||
]
|
||||
}))
|
||||
return coordinates
|
||||
.map((coordinate, index) => {
|
||||
const x = finiteNumber(coordinate[0])
|
||||
const z = finiteNumber(coordinate[1])
|
||||
if (typeof x !== 'number' || typeof z !== 'number') return null
|
||||
|
||||
return {
|
||||
nodeId: `${nodeIdPrefix}-geo-${index}`,
|
||||
floorId,
|
||||
position: [
|
||||
x,
|
||||
finiteNumber(coordinate[2]) ?? 0,
|
||||
z
|
||||
]
|
||||
}
|
||||
})
|
||||
.filter((point): point is GuideRoutePoint => Boolean(point))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const routePointFromEndpoint = (
|
||||
endpoint: GuideRouteEndpoint,
|
||||
suffix: string
|
||||
): GuideRoutePoint => ({
|
||||
nodeId: endpoint.routeNodeId || `${endpoint.poiId}-${suffix}`,
|
||||
floorId: endpoint.floorId,
|
||||
position: endpoint.position
|
||||
})
|
||||
|
||||
const prependIfDifferent = (
|
||||
points: GuideRoutePoint[],
|
||||
point: GuideRoutePoint
|
||||
) => {
|
||||
const firstPoint = points[0]
|
||||
return firstPoint && samePosition(firstPoint.position, point.position)
|
||||
? points
|
||||
: [point, ...points]
|
||||
}
|
||||
|
||||
const appendIfDifferent = (
|
||||
points: GuideRoutePoint[],
|
||||
point: GuideRoutePoint
|
||||
) => {
|
||||
const lastPoint = points[points.length - 1]
|
||||
return lastPoint && samePosition(lastPoint.position, point.position)
|
||||
? points
|
||||
: [...points, point]
|
||||
}
|
||||
|
||||
const createPlanFloorSegments = (
|
||||
points: GuideRoutePoint[],
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget
|
||||
): GuideRouteFloorSegment[] => points.reduce<GuideRouteFloorSegment[]>((segments, point) => {
|
||||
const current = segments[segments.length - 1]
|
||||
if (current && current.floorId === point.floorId) {
|
||||
current.points.push(point)
|
||||
return segments
|
||||
}
|
||||
|
||||
segments.push({
|
||||
floorId: point.floorId,
|
||||
floorLabel: floorLabelFor(point.floorId, startTarget, endTarget),
|
||||
points: [point]
|
||||
})
|
||||
return segments
|
||||
}, [])
|
||||
|
||||
const isWalkSegment = (
|
||||
segment: NonNullable<SgsRoutePlanResponsePayload['segments']>[number]
|
||||
) => {
|
||||
const type = stringifyRouteId(segment.transferType || segment.segmentType || segment.type).toUpperCase()
|
||||
return !type || type === 'WALK'
|
||||
}
|
||||
|
||||
const createSegmentFloorSegments = (
|
||||
route: SgsRoutePlanResponsePayload,
|
||||
start: GuideRouteEndpoint,
|
||||
end: GuideRouteEndpoint,
|
||||
startTarget: GuideRouteTarget,
|
||||
endTarget: GuideRouteTarget,
|
||||
nodePoints: GuideRoutePoint[]
|
||||
): GuideRouteFloorSegment[] => {
|
||||
const segments = (route.segments || [])
|
||||
.filter(isWalkSegment)
|
||||
.map<GuideRouteFloorSegment | null>((segment, segmentIndex) => {
|
||||
const floorId = stringifyRouteId(segment.floorId, start.floorId)
|
||||
const nodeIdPrefix = `${floorId}-segment-${segmentIndex}`
|
||||
const nodePathIds = new Set(
|
||||
(segment.nodePathIds || [])
|
||||
.map((nodeId) => stringifyRouteId(nodeId))
|
||||
.filter(Boolean)
|
||||
)
|
||||
const points = pointsFromPathPoints(segment.pathPoints, floorId, nodeIdPrefix)
|
||||
const geoPoints = points.length
|
||||
? points
|
||||
: pointsFromGeoJson(segment.pathGeoJson, floorId, nodeIdPrefix)
|
||||
const fallbackPoints = geoPoints.length
|
||||
? geoPoints
|
||||
: nodePoints.filter((point) => (
|
||||
point.floorId === floorId
|
||||
&& (!nodePathIds.size || nodePathIds.has(point.nodeId))
|
||||
))
|
||||
|
||||
if (!fallbackPoints.length) return null
|
||||
|
||||
return {
|
||||
floorId,
|
||||
floorLabel: floorLabelFor(floorId, startTarget, endTarget, segment.floorName),
|
||||
points: fallbackPoints.map((point, index) => ({
|
||||
...point,
|
||||
nodeId: point.nodeId || `${nodeIdPrefix}-${index}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
.filter((segment): segment is GuideRouteFloorSegment => Boolean(segment))
|
||||
|
||||
if (!segments.length) return []
|
||||
|
||||
const startPoint = routePointFromEndpoint(start, 'start')
|
||||
const endPoint = routePointFromEndpoint(end, 'end')
|
||||
const anchoredSegments = segments.map((segment) => ({
|
||||
...segment,
|
||||
points: [...segment.points]
|
||||
}))
|
||||
const firstSegment = anchoredSegments[0]
|
||||
const lastSegment = anchoredSegments[anchoredSegments.length - 1]
|
||||
|
||||
if (firstSegment?.floorId === start.floorId) {
|
||||
firstSegment.points = prependIfDifferent(firstSegment.points, startPoint)
|
||||
}
|
||||
|
||||
if (lastSegment?.floorId === end.floorId) {
|
||||
lastSegment.points = appendIfDifferent(lastSegment.points, endPoint)
|
||||
}
|
||||
|
||||
return anchoredSegments
|
||||
}
|
||||
|
||||
export const toGuideRouteResultFromSgsPlan = (
|
||||
route: SgsRoutePlanResponsePayload,
|
||||
startTarget: GuideRouteTarget,
|
||||
@@ -219,67 +399,39 @@ export const toGuideRouteResultFromSgsPlan = (
|
||||
): GuideRouteResult => {
|
||||
const start = targetEndpoint(startTarget)
|
||||
const end = targetEndpoint(endTarget)
|
||||
const nodePoints = (route.nodePaths || []).map((node, index) => (
|
||||
pointFromBackendNode(node, index, start.floorId)
|
||||
))
|
||||
const routePoints = nodePoints.length
|
||||
? [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
...nodePoints,
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
: pointsFromGeoJson(route.pathGeoJson, start.floorId)
|
||||
|
||||
const fallbackPoints = routePoints.length
|
||||
? routePoints
|
||||
: [
|
||||
{
|
||||
nodeId: start.routeNodeId,
|
||||
floorId: start.floorId,
|
||||
position: start.position
|
||||
},
|
||||
{
|
||||
nodeId: end.routeNodeId,
|
||||
floorId: end.floorId,
|
||||
position: end.position
|
||||
}
|
||||
]
|
||||
|
||||
const floorSegments = route.segments?.length
|
||||
? route.segments.map((segment, segmentIndex) => {
|
||||
const floorId = String(segment.floorId || start.floorId)
|
||||
const points = pointsFromGeoJson(segment.pathGeoJson, floorId)
|
||||
|
||||
return {
|
||||
floorId,
|
||||
floorLabel: segment.floorName || floorLabelFor(floorId, startTarget, endTarget),
|
||||
points: points.length
|
||||
? points
|
||||
: fallbackPoints.filter((point) => point.floorId === floorId).map((point, index) => ({
|
||||
...point,
|
||||
nodeId: point.nodeId || `${floorId}-segment-${segmentIndex}-${index}`
|
||||
}))
|
||||
}
|
||||
}).filter((segment) => segment.points.length)
|
||||
: createFloorSegments(fallbackPoints, startTarget, endTarget)
|
||||
const startPoint = routePointFromEndpoint(start, 'start')
|
||||
const endPoint = routePointFromEndpoint(end, 'end')
|
||||
const nodePoints = (route.nodePaths || [])
|
||||
.map((node, index) => pointFromBackendNode(node, index, start.floorId))
|
||||
.filter((point): point is GuideRoutePoint => Boolean(point))
|
||||
const segmentFloorSegments = createSegmentFloorSegments(
|
||||
route,
|
||||
start,
|
||||
end,
|
||||
startTarget,
|
||||
endTarget,
|
||||
nodePoints
|
||||
)
|
||||
const routeGeoPoints = pointsFromGeoJson(route.pathGeoJson, start.floorId, 'sgs-route')
|
||||
const fallbackPoints = nodePoints.length
|
||||
? [startPoint, ...nodePoints, endPoint]
|
||||
: routeGeoPoints.length
|
||||
? [startPoint, ...routeGeoPoints, endPoint]
|
||||
: [startPoint, endPoint]
|
||||
const floorSegments = segmentFloorSegments.length
|
||||
? segmentFloorSegments
|
||||
: createPlanFloorSegments(fallbackPoints, startTarget, endTarget)
|
||||
const routePoints = floorSegments.flatMap((segment) => segment.points)
|
||||
|
||||
return {
|
||||
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
|
||||
start,
|
||||
end,
|
||||
distanceMeters: Number(route.distance || 0),
|
||||
nodeIds: fallbackPoints.map((point) => point.nodeId),
|
||||
points: fallbackPoints,
|
||||
nodeIds: routePoints.map((point) => point.nodeId),
|
||||
points: routePoints,
|
||||
floorSegments,
|
||||
connectorPoints: extractFloorConnectorPoints(fallbackPoints, startTarget, endTarget)
|
||||
connectorPoints: extractFloorConnectorPoints(routePoints, startTarget, endTarget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,8 +449,8 @@ const extractFloorConnectorPoints = (
|
||||
])
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const currentFloorId = toAppFloorId(points[i].floorId)
|
||||
const nextFloorId = toAppFloorId(points[i + 1].floorId)
|
||||
const currentFloorId = points[i].floorId
|
||||
const nextFloorId = points[i + 1].floorId
|
||||
|
||||
if (currentFloorId !== nextFloorId) {
|
||||
const fromLabel = labels.get(currentFloorId) || currentFloorId
|
||||
|
||||
@@ -161,19 +161,30 @@ export interface SgsRouteStepNodePayload {
|
||||
nodeType?: string | null
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRoutePathPointPayload {
|
||||
x?: number | null
|
||||
y?: number | null
|
||||
z?: number | null
|
||||
}
|
||||
|
||||
export interface SgsRouteSegmentPayload {
|
||||
floorId?: string | number | null
|
||||
floorCode?: string | null
|
||||
floorName?: string | null
|
||||
startNodeId?: string | number | null
|
||||
endNodeId?: string | number | null
|
||||
startNodeName?: string | null
|
||||
endNodeName?: string | null
|
||||
segmentType?: string | null
|
||||
type?: string | null
|
||||
transferType?: string | null
|
||||
distance?: number | null
|
||||
duration?: number | null
|
||||
pathGeoJson?: string | null
|
||||
pathPoints?: SgsRoutePathPointPayload[]
|
||||
nodePathIds?: Array<string | number | null>
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ export const createStaticNavAssetsProvider = (
|
||||
manifestRequest = requestJson<StaticNavManifestPayload>(provider.assetUrl('app_nav_manifest.json'))
|
||||
.then((data) => {
|
||||
if (data.status !== 'pass') {
|
||||
throw new Error('室内导览资源包状态不是 pass')
|
||||
throw new Error('馆内导览资源包状态不是 pass')
|
||||
}
|
||||
|
||||
manifestCache = data
|
||||
|
||||
@@ -18,7 +18,7 @@ interface FloorLike {
|
||||
const indoorFloorCodePattern = /^L-?\d+(?:\.\d+)?$/i
|
||||
const indoorFloorLabelPattern = /^(?:B\d+(?:\.\d+)?|\d+(?:\.\d+)?F)$/i
|
||||
const rawNumericIdPattern = /^\d{6,}$/
|
||||
const nonNavigableFloorPattern = /(EXTERIOR|OUTDOOR|BUILDING|FACADE|EXTERNAL|外立面|建筑外观|建筑外立面|室外模型|室外场景|室外底图|室外建筑|室外外观|室外)$/i
|
||||
const nonNavigableFloorPattern = /(EXTERIOR|OUTDOOR|BUILDING|FACADE|EXTERNAL|外立面|建筑外观|建筑外立面|馆外模型|馆外场景|馆外底图|馆外建筑|馆外外观|馆外)$/i
|
||||
|
||||
const normalizeValue = (value: unknown) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value).trim()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
MuseumPoiEntrance,
|
||||
MuseumPoiKind
|
||||
} from '@/domain/museum'
|
||||
|
||||
export interface GuideRenderPoi {
|
||||
id: string
|
||||
name: string
|
||||
@@ -7,6 +12,10 @@ export interface GuideRenderPoi {
|
||||
iconType: string
|
||||
positionGltf?: [number, number, number]
|
||||
sourceObjectName?: string
|
||||
kind?: MuseumPoiKind
|
||||
hallId?: string
|
||||
hallName?: string
|
||||
entrances?: MuseumPoiEntrance[]
|
||||
}
|
||||
|
||||
export interface GuideModelFloorAsset {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
|
||||
export const NAV_ROUTE_GRAPH_READY = false
|
||||
|
||||
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '当前暂不支持正式导航,可查看位置预览、路线示意和室外参考'
|
||||
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '当前可查看位置预览、位置关系和馆外参考,暂不提供正式室内导航'
|
||||
|
||||
export const NAV_ROUTE_READINESS: GuideRouteReadiness = {
|
||||
ready: NAV_ROUTE_GRAPH_READY,
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<view class="action-bar">
|
||||
<view
|
||||
class="location-btn"
|
||||
:class="{ disabled: !exhibit.location }"
|
||||
:class="{ disabled: !hallLocationId }"
|
||||
@tap="handleNavigate"
|
||||
>
|
||||
<svg class="location-icon" width="19" height="19" viewBox="0 0 24 24" fill="none">
|
||||
@@ -134,12 +134,45 @@ const showAudioPlayer = ref(false)
|
||||
const currentAudio = ref<AudioItem | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const activeTopTab = ref<GuideTopTab>('explain')
|
||||
const resolvedHallId = ref('')
|
||||
|
||||
const heroImage = computed(() => exhibit.value.coverImages[0])
|
||||
const detailMeta = computed(() => [
|
||||
exhibit.value.hallName,
|
||||
exhibit.value.floorLabel
|
||||
].filter(Boolean).join(' · '))
|
||||
const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
|
||||
|
||||
const normalizePoiName = (value: string) => value
|
||||
.trim()
|
||||
.replace(/[\s()()【】[\]_-]/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
|
||||
const resolveHallGuidePoi = async () => {
|
||||
const hallId = exhibit.value.hallId
|
||||
if (hallId) {
|
||||
const hallPoi = await guideUseCase.getPoiById(hallId)
|
||||
if (hallPoi?.kind === 'hall' || hallPoi?.primaryCategory.id === 'exhibition_hall') {
|
||||
return hallPoi
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizePoiName(exhibit.value.hallName || '')
|
||||
if (!normalizedTarget) return null
|
||||
|
||||
const candidates = await guideUseCase.searchPois(exhibit.value.hallName || exhibit.value.title || '')
|
||||
return candidates.find((candidate) => {
|
||||
const candidateName = normalizePoiName(candidate.name)
|
||||
const candidateHallName = normalizePoiName(candidate.hallName || '')
|
||||
return candidate.kind === 'hall'
|
||||
|| candidate.primaryCategory.id === 'exhibition_hall'
|
||||
|| candidateName === normalizedTarget
|
||||
|| candidateHallName === normalizedTarget
|
||||
|| candidateName.includes(normalizedTarget)
|
||||
|| normalizedTarget.includes(candidateName)
|
||||
}) || candidates[0] || null
|
||||
}
|
||||
|
||||
onLoad(async (options: any = {}) => {
|
||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||
@@ -153,6 +186,9 @@ onLoad(async (options: any = {}) => {
|
||||
const exhibitData = await explainUseCase.getExhibitById(exhibitId)
|
||||
if (exhibitData) {
|
||||
exhibit.value = toExplainDetailPageViewModel(exhibitData)
|
||||
if (exhibitData.hallId) {
|
||||
resolvedHallId.value = exhibitData.hallId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,25 +283,34 @@ const handleAudioError = (_audio: AudioItem | null, message: string) => {
|
||||
}
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!exhibit.value.location?.poiId) {
|
||||
const matchedHallPoi = await resolveHallGuidePoi()
|
||||
if (!matchedHallPoi) {
|
||||
uni.showToast({
|
||||
title: '该讲解暂无三维位置数据',
|
||||
title: '该讲解暂无所属展厅位置数据',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId)
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该讲解位置尚未映射到当前三维导览资源',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
resolvedHallId.value = matchedHallPoi.id
|
||||
exhibit.value = {
|
||||
...exhibit.value,
|
||||
hallId: matchedHallPoi.id,
|
||||
hallName: matchedHallPoi.hallName || matchedHallPoi.name,
|
||||
location: exhibit.value.location || {
|
||||
status: 'hallFallback',
|
||||
poiId: matchedHallPoi.id,
|
||||
sourcePoiId: matchedHallPoi.id,
|
||||
actionText: '查看所属展厅',
|
||||
previewOnly: true,
|
||||
floorId: matchedHallPoi.floorId,
|
||||
floorLabel: matchedHallPoi.floorLabel,
|
||||
note: '已定位到该讲解所属展厅。'
|
||||
}
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedHallPoi.id)}&target=${encodeURIComponent(exhibit.value.hallName || exhibit.value.title)}&state=preview`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,39 +5,41 @@
|
||||
:show-top-tabs="false"
|
||||
show-bottom-nav
|
||||
show-back
|
||||
:show-cancel="isStartPanelOpen"
|
||||
@tab-change="handleTopTabChange"
|
||||
@back="handlePageBack"
|
||||
@cancel="handleCancelStartSelection"
|
||||
>
|
||||
<GuideMapShell
|
||||
:search-text="facility.name"
|
||||
search-top="60px"
|
||||
mode-top="104px"
|
||||
floor-top="208px"
|
||||
tools-top="176px"
|
||||
search-top="20px"
|
||||
floor-bottom="153px"
|
||||
floor-max-height="206px"
|
||||
floor-side="left"
|
||||
:active-mode="activeMode"
|
||||
:active-floor="activeFloor"
|
||||
:map-type="activeMode === '2d' ? 'outdoor' : 'indoor'"
|
||||
:tools="mapTools"
|
||||
map-type="indoor"
|
||||
:tools="[]"
|
||||
:indoor-model-source="indoorModelSource"
|
||||
:floors="guideFloors"
|
||||
:normalize-floor-id="normalizeGuideFloorId"
|
||||
@search-tap="handleSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
:target-focus-request="targetFocusRequest"
|
||||
:target-focus-distance-factor="0.86"
|
||||
:show-search="false"
|
||||
:show-mode-row="false"
|
||||
show-floor-header
|
||||
show-zoom-controls
|
||||
zoom-controls-top="calc(100vh - 252px)"
|
||||
@floor-change="handleFloorChange"
|
||||
@tool-click="handleToolClick"
|
||||
>
|
||||
<view class="detail-sheet">
|
||||
<view v-if="isDetailPanelVisible" class="detail-sheet">
|
||||
<view class="detail-title-row">
|
||||
<text class="detail-title">{{ facility.name }}</text>
|
||||
<text class="open-status">{{ facility.status }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-lines">
|
||||
<text class="detail-line">所在区域:{{ facility.location }}</text>
|
||||
<text class="detail-line">能力说明:{{ facility.traffic }}</text>
|
||||
<text v-if="confirmedStart" class="detail-line">预览起点:{{ confirmedStart.label }}</text>
|
||||
<view class="target-dot"></view>
|
||||
<view class="detail-title-copy">
|
||||
<text class="detail-title">{{ facility.name }}</text>
|
||||
<text class="detail-subtitle">{{ facility.location }}</text>
|
||||
</view>
|
||||
<view class="detail-close" @tap.stop="handleCloseDetailPanel">
|
||||
<text class="detail-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tag-row">
|
||||
@@ -51,82 +53,35 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="action-btn secondary" @tap="handleChooseStart">
|
||||
<text class="action-text">{{ startButtonLabel }}</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleStartNavigation">
|
||||
<text class="action-text">查看位置</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="detail-hint">已在对应楼层模型中标出点位</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="isStartPanelOpen"
|
||||
class="start-panel-mask"
|
||||
@tap="handleCancelStartSelection"
|
||||
></view>
|
||||
<view
|
||||
v-if="isStartPanelOpen"
|
||||
class="start-select-sheet"
|
||||
@tap.stop=""
|
||||
v-else
|
||||
class="detail-restore"
|
||||
@tap.stop="handleOpenDetailPanel"
|
||||
>
|
||||
<text class="start-select-title">选择预览起点</text>
|
||||
<text class="start-select-desc">当前支持选择预览起点,用于三维位置预览。</text>
|
||||
|
||||
<view class="start-chip-section">
|
||||
<text class="start-chip-label">楼层</text>
|
||||
<view class="start-chip-row">
|
||||
<view
|
||||
v-for="floor in startFloors"
|
||||
:key="floor"
|
||||
class="start-chip"
|
||||
:class="{ active: floor === selectedStartFloor }"
|
||||
@tap="handleStartFloorChange(floor)"
|
||||
>
|
||||
<text class="start-chip-text">{{ floor }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="start-chip-section area">
|
||||
<text class="start-chip-label">区域</text>
|
||||
<view class="start-chip-row">
|
||||
<view
|
||||
v-for="area in startAreas"
|
||||
:key="area"
|
||||
class="start-chip area"
|
||||
:class="{ active: area === selectedStartArea }"
|
||||
@tap="handleStartAreaChange(area)"
|
||||
>
|
||||
<text class="start-chip-text">{{ area }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="start-panel-actions">
|
||||
<view class="start-panel-btn secondary" @tap="handleCancelStartSelection">
|
||||
<text class="start-panel-btn-text">取消</text>
|
||||
</view>
|
||||
<view class="start-panel-btn primary" @tap="handleConfirmStartSelection">
|
||||
<text class="start-panel-btn-text">确认起点</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-dot small"></view>
|
||||
<text class="detail-restore-text">点位</text>
|
||||
</view>
|
||||
</GuideMapShell>
|
||||
</GuidePageFrame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import GuideMapShell from '@/components/navigation/GuideMapShell.vue'
|
||||
import {
|
||||
guideUseCase
|
||||
} from '@/usecases/guideUseCase'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import {
|
||||
toFacilityDetailViewModel,
|
||||
toTargetFocusRequestViewModel,
|
||||
type TargetPoiFocusRequestViewModel,
|
||||
type FacilityDetailViewModel
|
||||
} from '@/view-models/guideViewModels'
|
||||
import {
|
||||
@@ -134,64 +89,51 @@ import {
|
||||
type GuideTopTab
|
||||
} from '@/utils/guideTopTabs'
|
||||
import type {
|
||||
MuseumFloor
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
import type {
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import {
|
||||
StaticGuideModelRepository
|
||||
} from '@/repositories/GuideModelRepository'
|
||||
|
||||
interface ConfirmedStart {
|
||||
label: string
|
||||
floor: string
|
||||
source: 'facility-detail'
|
||||
}
|
||||
|
||||
const indoorModelSource = guideUseCase.getModelSource()
|
||||
const fallbackIndoorModelSource = new StaticGuideModelRepository()
|
||||
const indoorModelSource = ref<GuideModelSource>(fallbackIndoorModelSource)
|
||||
const guideFloors = ref<MuseumFloor[]>([])
|
||||
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
|
||||
const normalizeGuideFloorId = (labelOrId: string) =>
|
||||
guideFloors.value.find((floor) => floor.id === labelOrId || floor.label === labelOrId)?.id
|
||||
|| guideUseCase.normalizeFloorId(labelOrId)
|
||||
const activeMode = ref<'2d' | '3d'>('3d')
|
||||
const activeFloor = ref('1F')
|
||||
const targetFocusRequest = ref<TargetPoiFocusRequestViewModel | null>(null)
|
||||
const targetFocusRequestId = ref(0)
|
||||
const isDetailPanelVisible = ref(true)
|
||||
|
||||
const facility = ref<FacilityDetailViewModel>({
|
||||
id: '',
|
||||
name: '目标位置',
|
||||
status: '可预览',
|
||||
location: '中心线路网导览点位',
|
||||
location: '正在定位三维点位',
|
||||
traffic: guideUseCase.getRouteReadinessSnapshot().message,
|
||||
tags: ['三维位置', '中心线路网']
|
||||
tags: ['三维位置', '位置预览']
|
||||
})
|
||||
const isStartPanelOpen = ref(false)
|
||||
const selectedStartFloor = ref('1F')
|
||||
const selectedStartArea = ref('服务台附近')
|
||||
const confirmedStart = ref<ConfirmedStart | null>(null)
|
||||
const startFloors = computed(() => guideFloors.value.map((floor) => floor.label))
|
||||
const mapTools = computed(() => ['重置', activeMode.value === '2d' ? '3D' : '2D'])
|
||||
const startAreas = ['主入口', '服务台附近', '停车场入口']
|
||||
|
||||
const pendingStartLabel = computed(() => `${selectedStartFloor.value} ${selectedStartArea.value}`)
|
||||
const startButtonLabel = computed(() => confirmedStart.value ? '更换起点' : '选择起点')
|
||||
|
||||
const resolveConfirmedStartArea = () => {
|
||||
if (!confirmedStart.value) return '服务台附近'
|
||||
|
||||
const area = confirmedStart.value.floor
|
||||
? confirmedStart.value.label.replace(`${confirmedStart.value.floor} `, '').trim()
|
||||
: confirmedStart.value.label.trim()
|
||||
|
||||
return startAreas.includes(area) ? area : '服务台附近'
|
||||
}
|
||||
|
||||
const syncDraftStartSelection = () => {
|
||||
selectedStartFloor.value = confirmedStart.value?.floor || '1F'
|
||||
selectedStartArea.value = resolveConfirmedStartArea()
|
||||
}
|
||||
|
||||
const loadGuideFloors = async () => {
|
||||
try {
|
||||
const floors = await guideUseCase.getFloors()
|
||||
guideFloors.value = floors
|
||||
const defaultFloor = floors.find((floor) => floor.label === selectedStartFloor.value)?.label
|
||||
const modelPackage = await fallbackIndoorModelSource.loadPackage()
|
||||
guideFloors.value = modelPackage.floors.map((floor) => ({
|
||||
id: floor.floorId,
|
||||
label: floor.label,
|
||||
order: floor.order
|
||||
}))
|
||||
const floors = guideFloors.value
|
||||
const defaultFloor = floors.find((floor) => floor.label === activeFloor.value)?.label
|
||||
|| floors.find((floor) => floor.id === 'L1')?.label
|
||||
|| floors[0]?.label
|
||||
|| selectedStartFloor.value
|
||||
selectedStartFloor.value = defaultFloor
|
||||
|| activeFloor.value
|
||||
activeFloor.value = floors.find((floor) => floor.label === activeFloor.value)?.label
|
||||
|| defaultFloor
|
||||
} catch (error) {
|
||||
@@ -200,8 +142,148 @@ const loadGuideFloors = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const focusFacilityPoi = (poi: MuseumPoi) => {
|
||||
if (!poi.floorId) return
|
||||
|
||||
activeMode.value = '3d'
|
||||
activeFloor.value = poi.floorLabel
|
||||
targetFocusRequestId.value += 1
|
||||
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
}, targetFocusRequestId.value)
|
||||
}
|
||||
|
||||
const focusRenderPoi = (poi: GuideRenderPoi) => {
|
||||
if (!poi.floorId) return
|
||||
|
||||
const floorLabel = guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
|
||||
activeMode.value = '3d'
|
||||
activeFloor.value = floorLabel
|
||||
targetFocusRequestId.value += 1
|
||||
targetFocusRequest.value = toTargetFocusRequestViewModel({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategoryZh,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName,
|
||||
kind: poi.kind,
|
||||
hallId: poi.hallId,
|
||||
hallName: poi.hallName,
|
||||
entrances: poi.entrances
|
||||
}, targetFocusRequestId.value)
|
||||
}
|
||||
|
||||
const getRenderPoiFloorLabel = (poi: GuideRenderPoi) =>
|
||||
guideFloors.value.find((floor) => floor.id === poi.floorId)?.label || poi.floorId
|
||||
|
||||
const applyRenderPoiToFacility = (poi: GuideRenderPoi, routeMessage: string) => {
|
||||
const floorLabel = getRenderPoiFloorLabel(poi)
|
||||
const categoryLabel = poi.primaryCategoryZh || '三维位置'
|
||||
facility.value = {
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
status: '可预览',
|
||||
location: `${floorLabel} · ${categoryLabel}`,
|
||||
traffic: routeMessage,
|
||||
tags: [
|
||||
floorLabel,
|
||||
categoryLabel,
|
||||
'展示点位'
|
||||
]
|
||||
}
|
||||
focusRenderPoi(poi)
|
||||
}
|
||||
|
||||
const normalizePoiName = (value: string) => value
|
||||
.trim()
|
||||
.replace(/[\s()()【】[\]_-]/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
const resolvePoiFromHall = async () => {
|
||||
if (!facility.value.id) return null
|
||||
|
||||
const hall = await explainUseCase.getHallById(facility.value.id).catch(() => null)
|
||||
const poiId = hall?.location?.poiId || hall?.poiId
|
||||
return poiId ? guideUseCase.getPoiById(poiId) : null
|
||||
}
|
||||
|
||||
const resolveFacilityPoi = async () => {
|
||||
const poiById = facility.value.id
|
||||
? await guideUseCase.getPoiById(facility.value.id)
|
||||
: null
|
||||
if (poiById) return poiById
|
||||
|
||||
const poiByHall = await resolvePoiFromHall()
|
||||
if (poiByHall) return poiByHall
|
||||
|
||||
const normalizedTarget = normalizePoiName(facility.value.name)
|
||||
if (!normalizedTarget) return null
|
||||
|
||||
const candidates = await guideUseCase.searchPois(facility.value.name)
|
||||
return candidates.find((candidate) => {
|
||||
const candidateName = normalizePoiName(candidate.name)
|
||||
const candidateHallName = normalizePoiName(candidate.hallName || '')
|
||||
return candidateName === normalizedTarget
|
||||
|| candidateHallName === normalizedTarget
|
||||
|| candidateName.includes(normalizedTarget)
|
||||
|| normalizedTarget.includes(candidateName)
|
||||
}) || candidates[0] || null
|
||||
}
|
||||
|
||||
const resolveRenderPoiByName = async () => {
|
||||
const normalizedTarget = normalizePoiName(facility.value.name)
|
||||
if (!normalizedTarget) return null
|
||||
|
||||
const modelPackage = await indoorModelSource.value.loadPackage()
|
||||
for (const floor of modelPackage.floors) {
|
||||
const floorId = floor.floorId
|
||||
const pois = await indoorModelSource.value.loadFloorPois(floorId).catch(() => [])
|
||||
const matchedPoi = pois.find((poi) => {
|
||||
const poiName = normalizePoiName(poi.name)
|
||||
const hallName = normalizePoiName(poi.hallName || '')
|
||||
const sourceObjectName = normalizePoiName(poi.sourceObjectName || '')
|
||||
return poiName === normalizedTarget
|
||||
|| hallName === normalizedTarget
|
||||
|| sourceObjectName === normalizedTarget
|
||||
|| poiName.includes(normalizedTarget)
|
||||
|| normalizedTarget.includes(poiName)
|
||||
|| sourceObjectName.includes(normalizedTarget)
|
||||
|| normalizedTarget.includes(sourceObjectName)
|
||||
})
|
||||
if (matchedPoi) return matchedPoi
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const tryResolveRenderPoiByName = async () => {
|
||||
try {
|
||||
return await resolveRenderPoiByName()
|
||||
} catch (error) {
|
||||
console.warn('当前模型源点位解析失败,尝试静态资源兜底:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const tryResolveFacilityPoi = async () => {
|
||||
try {
|
||||
return await resolveFacilityPoi()
|
||||
} catch (error) {
|
||||
console.warn('内容点位解析失败,继续使用模型点位预览:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options: any) => {
|
||||
void loadGuideFloors()
|
||||
await loadGuideFloors()
|
||||
|
||||
if (options.id) {
|
||||
facility.value.id = options.id
|
||||
@@ -211,108 +293,41 @@ onLoad(async (options: any) => {
|
||||
facility.value.name = decodeURIComponent(options.target)
|
||||
}
|
||||
|
||||
if (!facility.value.id) return
|
||||
if (!facility.value.id && !normalizePoiName(facility.value.name)) return
|
||||
|
||||
try {
|
||||
const routeReadiness = await guideUseCase.getRouteReadiness()
|
||||
const poi = await guideUseCase.getPoiById(facility.value.id)
|
||||
if (!poi) return
|
||||
const routeReadinessMessage = guideUseCase.getRouteReadinessSnapshot().message
|
||||
|
||||
facility.value = toFacilityDetailViewModel(
|
||||
poi,
|
||||
routeReadiness.message
|
||||
)
|
||||
// 详情页以位置预览为主,优先使用模型点位,避免旧内容兜底覆盖真实三维点位。
|
||||
const renderPoi = await tryResolveRenderPoiByName()
|
||||
if (renderPoi) {
|
||||
applyRenderPoiToFacility(renderPoi, routeReadinessMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const poi = await tryResolveFacilityPoi()
|
||||
if (poi) {
|
||||
facility.value = toFacilityDetailViewModel(
|
||||
poi,
|
||||
routeReadinessMessage
|
||||
)
|
||||
focusFacilityPoi(poi)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载中心线路网设施详情失败:', error)
|
||||
console.error('加载设施位置详情失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearchTap = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/search/index?keyword=${encodeURIComponent(facility.value.name)}`
|
||||
})
|
||||
}
|
||||
|
||||
const handleChooseStart = () => {
|
||||
syncDraftStartSelection()
|
||||
isStartPanelOpen.value = true
|
||||
}
|
||||
|
||||
const handleStartFloorChange = (floor: string) => {
|
||||
selectedStartFloor.value = floor
|
||||
}
|
||||
|
||||
const handleStartAreaChange = (area: string) => {
|
||||
selectedStartArea.value = area
|
||||
}
|
||||
|
||||
const handleCancelStartSelection = () => {
|
||||
syncDraftStartSelection()
|
||||
isStartPanelOpen.value = false
|
||||
}
|
||||
|
||||
const handleConfirmStartSelection = () => {
|
||||
confirmedStart.value = {
|
||||
label: pendingStartLabel.value,
|
||||
floor: selectedStartFloor.value,
|
||||
source: 'facility-detail'
|
||||
}
|
||||
isStartPanelOpen.value = false
|
||||
}
|
||||
|
||||
const handleStartNavigation = () => {
|
||||
const params: Record<string, string> = {
|
||||
facilityId: facility.value.id,
|
||||
target: facility.value.name,
|
||||
state: 'preview'
|
||||
}
|
||||
|
||||
if (confirmedStart.value) {
|
||||
params.startLabel = confirmedStart.value.label
|
||||
params.startFloor = confirmedStart.value.floor
|
||||
params.startSource = confirmedStart.value.source
|
||||
}
|
||||
|
||||
const query = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?${query}`
|
||||
})
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: '2d' | '3d') => {
|
||||
activeMode.value = mode
|
||||
uni.showToast({
|
||||
title: mode === '2d' ? '已切换馆外入口参考' : '已切换馆内三维位置',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
const handleFloorChange = (floor: string) => {
|
||||
activeFloor.value = floor
|
||||
}
|
||||
|
||||
const handleToolClick = (tool: string) => {
|
||||
if (tool === '2D') {
|
||||
handleModeChange('2d')
|
||||
return
|
||||
}
|
||||
const handleCloseDetailPanel = () => {
|
||||
isDetailPanelVisible.value = false
|
||||
}
|
||||
|
||||
if (tool === '3D') {
|
||||
handleModeChange('3d')
|
||||
return
|
||||
}
|
||||
|
||||
if (tool === '重置') {
|
||||
activeMode.value = '3d'
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: tool === '重置' ? '已回到三维位置预览' : '该工具暂未开放',
|
||||
icon: 'none'
|
||||
})
|
||||
const handleOpenDetailPanel = () => {
|
||||
isDetailPanelVisible.value = true
|
||||
}
|
||||
|
||||
const handleTopTabChange = (tab: GuideTopTab) => {
|
||||
@@ -335,86 +350,106 @@ const handlePageBack = () => {
|
||||
<style scoped lang="scss">
|
||||
.detail-sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
left: 84px;
|
||||
right: 16px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 116px);
|
||||
height: 252px;
|
||||
padding: 22px 20px 20px;
|
||||
min-height: 110px;
|
||||
padding: 14px 14px 12px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.14);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px rgba(36, 49, 42, 0.12);
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.detail-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.target-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 5px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
background: var(--museum-accent);
|
||||
border: 3px solid #151713;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 4px rgba(224, 225, 0, 0.18);
|
||||
}
|
||||
|
||||
.detail-title-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #f4f5ef;
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-close-text {
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: #30352f;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
color: #151713;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.open-status {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
.detail-lines {
|
||||
margin-top: 17px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-line {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #6b7178;
|
||||
.detail-subtitle {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #696962;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
margin-top: 21px;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
height: 28px;
|
||||
min-width: 88px;
|
||||
padding: 0 15px;
|
||||
height: 26px;
|
||||
min-width: 68px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e5e6de;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.detail-tag.active {
|
||||
background: #000000;
|
||||
border-color: #000000;
|
||||
background: #151713;
|
||||
border-color: #151713;
|
||||
}
|
||||
|
||||
.detail-tag-text {
|
||||
@@ -428,191 +463,58 @@ const handlePageBack = () => {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
width: 151px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
width: 168px;
|
||||
background: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.action-btn.primary .action-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.start-panel-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: 52;
|
||||
}
|
||||
|
||||
.start-select-sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 116px);
|
||||
height: 276px;
|
||||
padding: 22px 20px 16px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
.start-select-title {
|
||||
.detail-hint {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.start-select-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: #626970;
|
||||
}
|
||||
|
||||
.start-chip-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.start-chip-section.area {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.start-chip-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #60666d;
|
||||
color: #1a7f37;
|
||||
}
|
||||
|
||||
.start-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.start-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;
|
||||
}
|
||||
|
||||
.start-chip.area {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.start-chip.active {
|
||||
background: #000000;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
.start-chip-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: #60666d;
|
||||
}
|
||||
|
||||
.start-chip.active .start-chip-text {
|
||||
color: var(--museum-accent);
|
||||
}
|
||||
|
||||
.start-panel-actions {
|
||||
.detail-restore {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.start-panel-btn {
|
||||
height: 42px;
|
||||
right: 18px;
|
||||
bottom: calc(env(safe-area-inset-bottom) + 266px);
|
||||
height: 40px;
|
||||
min-width: 74px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.start-panel-btn.secondary {
|
||||
width: 151px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
.target-dot.small {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 0;
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.start-panel-btn.primary {
|
||||
width: 168px;
|
||||
background: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
.start-panel-btn-text {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.start-panel-btn.primary .start-panel-btn-text {
|
||||
color: var(--museum-accent);
|
||||
.detail-restore-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.start-panel-actions {
|
||||
gap: 10px;
|
||||
.detail-sheet {
|
||||
left: 78px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.start-panel-btn.secondary,
|
||||
.start-panel-btn.primary {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
.detail-tag {
|
||||
min-width: 0;
|
||||
padding: 0 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<text class="hall-meta">{{ hall.floorLabel }} · {{ explainCountText }}</text>
|
||||
<view
|
||||
class="location-action"
|
||||
:class="{ disabled: !hall.poiId }"
|
||||
:class="{ disabled: !hallLocationPoiId }"
|
||||
@tap="handleNavigate"
|
||||
>
|
||||
<svg class="location-icon" width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
const activeTopTab = ref<GuideTopTab>('explain')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const resolvedHallPoiId = ref('')
|
||||
|
||||
const hall = ref<HallDetailViewModel>({
|
||||
id: '',
|
||||
@@ -116,6 +117,37 @@ const explainCountText = computed(() => (
|
||||
exhibits.value.length > 0 ? `${exhibits.value.length} 条讲解` : '暂无讲解'
|
||||
))
|
||||
|
||||
const hallLocationPoiId = computed(() => hall.value.poiId || resolvedHallPoiId.value)
|
||||
|
||||
const normalizePoiName = (value: string) => value
|
||||
.trim()
|
||||
.replace(/[\s()()【】[\]_-]/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
// 展厅数据里可能没有直接写入 poiId,优先走内容层映射,再按展厅名称兜底匹配导览点位。
|
||||
const resolveHallGuidePoi = async () => {
|
||||
const directPoiId = hall.value.location?.poiId || hall.value.poiId
|
||||
if (directPoiId) {
|
||||
const directPoi = await guideUseCase.getPoiById(directPoiId)
|
||||
if (directPoi) return directPoi
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizePoiName(hall.value.name)
|
||||
if (!normalizedTarget) return null
|
||||
|
||||
const candidates = await guideUseCase.searchPois(hall.value.name)
|
||||
return candidates.find((candidate) => {
|
||||
const candidateName = normalizePoiName(candidate.name)
|
||||
const candidateHallName = normalizePoiName(candidate.hallName || '')
|
||||
return candidate.kind === 'hall'
|
||||
|| candidate.primaryCategory.id === 'exhibition_hall'
|
||||
|| candidateName === normalizedTarget
|
||||
|| candidateHallName === normalizedTarget
|
||||
|| candidateName.includes(normalizedTarget)
|
||||
|| normalizedTarget.includes(candidateName)
|
||||
}) || candidates[0] || null
|
||||
}
|
||||
|
||||
onLoad(async (options: any = {}) => {
|
||||
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
|
||||
if (isGuideTopTab(tab)) {
|
||||
@@ -141,6 +173,25 @@ onLoad(async (options: any = {}) => {
|
||||
hall.value = toHallDetailViewModel(hallData)
|
||||
const hallExhibits = await explainUseCase.listExhibitsByHallId(hallData.id)
|
||||
exhibits.value = hallExhibits.map(toExplainExhibitViewModel)
|
||||
|
||||
const matchedPoi = await resolveHallGuidePoi()
|
||||
if (matchedPoi) {
|
||||
resolvedHallPoiId.value = matchedPoi.id
|
||||
hall.value = {
|
||||
...hall.value,
|
||||
poiId: matchedPoi.id,
|
||||
location: hall.value.location || {
|
||||
status: 'exact',
|
||||
poiId: matchedPoi.id,
|
||||
sourcePoiId: matchedPoi.id,
|
||||
actionText: '查看三维位置',
|
||||
previewOnly: true,
|
||||
floorId: matchedPoi.floorId,
|
||||
floorLabel: matchedPoi.floorLabel,
|
||||
note: '已定位到该展厅的三维点位。'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载展厅讲解失败:', err)
|
||||
error.value = '展厅讲解加载失败,请稍后重试'
|
||||
@@ -163,7 +214,8 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => {
|
||||
}
|
||||
|
||||
const handleNavigate = async () => {
|
||||
if (!hall.value.poiId) {
|
||||
const matchedPoi = await resolveHallGuidePoi()
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该展厅暂无三维位置数据',
|
||||
icon: 'none'
|
||||
@@ -171,17 +223,24 @@ const handleNavigate = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该展厅位置尚未映射到当前三维导览资源',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
resolvedHallPoiId.value = matchedPoi.id
|
||||
hall.value = {
|
||||
...hall.value,
|
||||
poiId: matchedPoi.id,
|
||||
location: hall.value.location || {
|
||||
status: 'exact',
|
||||
poiId: matchedPoi.id,
|
||||
sourcePoiId: matchedPoi.id,
|
||||
actionText: '查看三维位置',
|
||||
previewOnly: true,
|
||||
floorId: matchedPoi.floorId,
|
||||
floorLabel: matchedPoi.floorLabel,
|
||||
note: '已定位到该展厅的三维点位。'
|
||||
}
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedPoi.id)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@
|
||||
:layer-mode="indoorLayerMode"
|
||||
:active-floor="activeGuideFloor"
|
||||
layer-mode-toggle-top="154px"
|
||||
floor-bottom="185px"
|
||||
floor-bottom="153px"
|
||||
floor-max-height="206px"
|
||||
floor-side="left"
|
||||
show-floor-header
|
||||
:show-layer-mode-toggle="false"
|
||||
:show-floor="is3DMode"
|
||||
show-zoom-controls
|
||||
zoom-controls-top="calc(100vh - 284px)"
|
||||
zoom-controls-top="calc(100vh - 252px)"
|
||||
:route-preview="activeRoutePreview"
|
||||
:show-route="Boolean(activeRoutePreview)"
|
||||
:disable-auto-exit="disableIndoorAutoExit"
|
||||
@@ -71,7 +71,7 @@
|
||||
<view v-if="isPoiCardCollapsed" class="poi-card-collapsed" @tap="expandPoiCard">
|
||||
<view class="poi-card-copy">
|
||||
<text class="poi-card-title">{{ selectedGuidePoi.name }}</text>
|
||||
<text class="poi-card-subtitle">位置预览 · 点按展开操作</text>
|
||||
<text class="poi-card-subtitle">{{ selectedGuidePoiCollapsedSubtitle }}</text>
|
||||
</view>
|
||||
<view class="poi-card-mini-action">
|
||||
<text class="poi-card-mini-text">展开</text>
|
||||
@@ -92,15 +92,12 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="poi-card-actions">
|
||||
<view class="poi-action primary" @tap="handlePreviewSelectedPoi">
|
||||
<text class="poi-action-text">查看位置</text>
|
||||
</view>
|
||||
<view class="poi-action" @tap="handleSetSelectedPoiAsStart">
|
||||
<text class="poi-action-text">从这里出发</text>
|
||||
</view>
|
||||
<view class="poi-action" @tap="handleSetSelectedPoiAsEnd">
|
||||
<text class="poi-action-text">去这里</text>
|
||||
<view
|
||||
v-if="selectedGuidePoiIsHall"
|
||||
class="poi-card-actions"
|
||||
>
|
||||
<view class="poi-action primary" @tap="handleViewSelectedHall">
|
||||
<text class="poi-action-text">查看展厅</text>
|
||||
</view>
|
||||
<view class="poi-action" @tap="handleSelectedPoiRelated">
|
||||
<text class="poi-action-text">相关讲解</text>
|
||||
@@ -248,7 +245,8 @@ import type {
|
||||
GuideRouteResult,
|
||||
GuideRouteTarget,
|
||||
MuseumExhibit,
|
||||
MuseumFloor
|
||||
MuseumFloor,
|
||||
MuseumHall
|
||||
} from '@/domain/museum'
|
||||
import { MUSEUM_LOCATION, type TravelMode } from '@/config/museum'
|
||||
import {
|
||||
@@ -400,13 +398,27 @@ const selectedGuidePoiSubtitle = computed(() => {
|
||||
return `${floorLabel} · ${poi.primaryCategoryZh || '位置预览'}`
|
||||
})
|
||||
|
||||
const selectedGuidePoiIsHall = computed(() => {
|
||||
const poi = selectedGuidePoi.value
|
||||
return Boolean(
|
||||
poi
|
||||
&& (
|
||||
poi.kind === 'hall'
|
||||
|| poi.primaryCategory === 'touring_poi'
|
||||
|| poi.primaryCategory === 'exhibition_hall'
|
||||
|| poi.primaryCategory === 'exhibition_hall_entrance'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedGuidePoiCollapsedSubtitle = computed(() => (
|
||||
selectedGuidePoiIsHall.value ? '已定位展厅 · 点按展开操作' : '已定位点位'
|
||||
))
|
||||
|
||||
const routeSimulationStepText = computed(() => {
|
||||
if (!activeRoutePreview.value) return routeReady.value ? '馆内导览 · 查看路线' : '馆内导览 · 查看位置'
|
||||
if (!activeRoutePreview.value) return '位置预览 · 查看位置关系'
|
||||
const hasConnector = activeRoutePreview.value.connectorPoints.length > 0
|
||||
if (routeReady.value) {
|
||||
return hasConnector ? '馆内导览 · 跨楼层路线' : '馆内导览 · 同层路线'
|
||||
}
|
||||
return hasConnector ? '馆内导览 · 跨楼层位置关系' : '馆内导览 · 同层位置关系'
|
||||
return hasConnector ? '位置预览 · 跨楼层位置关系' : '位置预览 · 同层位置关系'
|
||||
})
|
||||
|
||||
const explainHallItems = ref<ExplainHallSelectItem[]>([])
|
||||
@@ -667,12 +679,12 @@ const handleIndoorToolClick = (tool: string) => {
|
||||
|
||||
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
|
||||
selectedGuidePoi.value = poi
|
||||
isPoiCardCollapsed.value = false
|
||||
isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value
|
||||
indoorView.value = 'floor'
|
||||
isSimulatingRoute.value = false
|
||||
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
|
||||
activeGuideFloor.value = matchedFloor?.label || poi.floorId
|
||||
showIndoorHint('已选中标记,可查看位置预览', 3200)
|
||||
showIndoorHint('已在当前楼层标出点位', 3000)
|
||||
}
|
||||
|
||||
const handleGuideSelectionClear = () => {
|
||||
@@ -692,55 +704,84 @@ const togglePoiCardCollapsed = () => {
|
||||
isPoiCardCollapsed.value = !isPoiCardCollapsed.value
|
||||
}
|
||||
|
||||
const findRouteOptionForPoi = async (poi: GuideRenderPoi) => {
|
||||
if (!routePointOptions.value.length) {
|
||||
await loadRouteTargets()
|
||||
const normalizeHallMatchText = (value?: string) => (value || '')
|
||||
.trim()
|
||||
.replace(/[\s()()【】[\]_-]/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
const resolveSelectedPoiHall = async (): Promise<MuseumHall | null> => {
|
||||
const poi = selectedGuidePoi.value
|
||||
if (!poi) return null
|
||||
|
||||
const halls = await explainUseCase.listHalls()
|
||||
const hallId = poi.hallId
|
||||
if (hallId) {
|
||||
const matchedById = halls.find((hall) => hall.id === hallId || hall.poiId === hallId)
|
||||
if (matchedById) return matchedById
|
||||
}
|
||||
|
||||
return routePointOptions.value.find((option) => option.poiId === poi.id) || null
|
||||
const matchedByPoiId = halls.find((hall) => (
|
||||
hall.poiId === poi.id
|
||||
|| hall.location?.poiId === poi.id
|
||||
|| (poi.entrances || []).some((entrance) => hall.poiId === entrance.sourcePlaceId)
|
||||
))
|
||||
if (matchedByPoiId) return matchedByPoiId
|
||||
|
||||
const poiHallName = normalizeHallMatchText(poi.hallName || poi.name)
|
||||
return halls.find((hall) => {
|
||||
const hallName = normalizeHallMatchText(hall.name)
|
||||
return hallName && poiHallName && (hallName.includes(poiHallName) || poiHallName.includes(hallName))
|
||||
}) || null
|
||||
}
|
||||
|
||||
const setSelectedPoiAsRoutePoint = async (mode: 'start' | 'end') => {
|
||||
if (!selectedGuidePoi.value) return
|
||||
const navigateToHallDetail = (hallId: string) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain`
|
||||
})
|
||||
}
|
||||
|
||||
const option = await findRouteOptionForPoi(selectedGuidePoi.value)
|
||||
if (!option) {
|
||||
const handleViewSelectedHall = async () => {
|
||||
const hall = await resolveSelectedPoiHall()
|
||||
if (!hall) {
|
||||
uni.showToast({
|
||||
title: '该点位暂无线路数据,可先查看三维位置',
|
||||
title: '该展厅详情暂未接入',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'start') {
|
||||
routeStartPoint.value = option
|
||||
} else {
|
||||
routeEndPoint.value = option
|
||||
navigateToHallDetail(hall.id)
|
||||
}
|
||||
|
||||
const handleSelectedPoiRelated = async () => {
|
||||
if (!selectedGuidePoi.value) return
|
||||
|
||||
const hall = await resolveSelectedPoiHall()
|
||||
if (hall) {
|
||||
navigateToHallDetail(hall.id)
|
||||
return
|
||||
}
|
||||
|
||||
clearRoutePreviewState()
|
||||
openRoutePlanner()
|
||||
}
|
||||
const keyword = selectedGuidePoi.value.hallName || selectedGuidePoi.value.name
|
||||
const results = await explainUseCase.searchExplain(keyword)
|
||||
const firstHall = results.find((item) => item.type === 'hall' && item.hallId)
|
||||
const firstExhibit = results.find((item) => item.type === 'exhibit' && item.exhibitId)
|
||||
|
||||
const handlePreviewSelectedPoi = () => {
|
||||
if (!selectedGuidePoi.value) return
|
||||
indoorView.value = 'floor'
|
||||
showRoutePlanner.value = false
|
||||
isSimulatingRoute.value = false
|
||||
}
|
||||
if (firstHall?.hallId) {
|
||||
navigateToHallDetail(firstHall.hallId)
|
||||
return
|
||||
}
|
||||
|
||||
const handleSetSelectedPoiAsStart = () => {
|
||||
void setSelectedPoiAsRoutePoint('start')
|
||||
}
|
||||
if (firstExhibit?.exhibitId) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/exhibit/detail?id=${encodeURIComponent(firstExhibit.exhibitId)}&tab=explain`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const handleSetSelectedPoiAsEnd = () => {
|
||||
void setSelectedPoiAsRoutePoint('end')
|
||||
}
|
||||
|
||||
const handleSelectedPoiRelated = () => {
|
||||
if (!selectedGuidePoi.value) return
|
||||
uni.navigateTo({
|
||||
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
|
||||
uni.showToast({
|
||||
title: '该展厅暂无对应讲解',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -769,15 +810,16 @@ const refreshRouteReadinessState = async () => {
|
||||
routeReady.value = readiness.ready
|
||||
routePreviewUnavailableMessage.value = readiness.ready
|
||||
? ''
|
||||
: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
|
||||
: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
|
||||
} catch (error) {
|
||||
console.warn('读取路线能力状态失败:', error)
|
||||
routeReady.value = false
|
||||
routePreviewUnavailableMessage.value = '当前暂不支持正式导航,可查看位置预览和路线示意'
|
||||
routePreviewUnavailableMessage.value = '当前可查看位置预览和位置关系,暂不提供正式室内导航'
|
||||
}
|
||||
}
|
||||
|
||||
const openRoutePlanner = () => {
|
||||
closeOutdoorNavPanel()
|
||||
showRoutePlanner.value = true
|
||||
is3DMode.value = true
|
||||
indoorView.value = indoorView.value === 'overview' ? 'floor' : indoorView.value
|
||||
@@ -898,6 +940,7 @@ const handleRouteSimulate = () => {
|
||||
// 进入 3D 馆内模式
|
||||
const handleEnter3DMode = () => {
|
||||
console.log('进入 3D 馆内模式')
|
||||
closeOutdoorNavPanel()
|
||||
indoorView.value = 'overview'
|
||||
is3DMode.value = true
|
||||
guideOutdoorState.value = 'home'
|
||||
@@ -919,6 +962,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
isSimulatingRoute.value = false
|
||||
clearRoutePreviewState()
|
||||
} else {
|
||||
closeOutdoorNavPanel()
|
||||
indoorView.value = 'overview'
|
||||
showIndoorHint()
|
||||
if (!routePointOptions.value.length) {
|
||||
@@ -970,18 +1014,21 @@ const clearOutdoorNavState = () => {
|
||||
outdoorNavManualMode.value = false
|
||||
}
|
||||
|
||||
const closeOutdoorNavPanel = () => {
|
||||
showOutdoorNavPanel.value = false
|
||||
clearOutdoorNavState()
|
||||
}
|
||||
|
||||
const handleMoreOutdoorNav = () => {
|
||||
openOutdoorNavPanel()
|
||||
}
|
||||
|
||||
const handleOutdoorNavClose = () => {
|
||||
showOutdoorNavPanel.value = false
|
||||
clearOutdoorNavState()
|
||||
closeOutdoorNavPanel()
|
||||
}
|
||||
|
||||
const handleOutdoorNavBack = () => {
|
||||
showOutdoorNavPanel.value = false
|
||||
clearOutdoorNavState()
|
||||
closeOutdoorNavPanel()
|
||||
}
|
||||
|
||||
const handleOutdoorNavGpsStart = () => {
|
||||
@@ -1291,7 +1338,7 @@ const handleExplainBack = () => {
|
||||
.poi-card-actions {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
@tap="handleStartIndoorGuide"
|
||||
>
|
||||
<text class="indoor-guide-btn-text">
|
||||
{{ routePlanning ? '规划中' : routeReady ? '开始导览' : '导航不可用' }}
|
||||
{{ routePlanning ? '生成中' : routeReady ? '查看位置关系' : '位置关系不可用' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -304,7 +304,7 @@ const shellConfig = computed(() => {
|
||||
}
|
||||
|
||||
return {
|
||||
searchText: routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`,
|
||||
searchText: `位置预览:${route.value.target}`,
|
||||
searchTop: '60px',
|
||||
activeMode: '3d' as GuideMode,
|
||||
activeFloor: activeFloor.value,
|
||||
@@ -323,17 +323,17 @@ const shellConfig = computed(() => {
|
||||
})
|
||||
|
||||
const indoorGuideTitle = computed(() => (
|
||||
routeReady.value ? `馆内导览:${route.value.target}` : `位置预览:${route.value.target}`
|
||||
`位置预览:${route.value.target}`
|
||||
))
|
||||
|
||||
const indoorGuideDesc = computed(() => {
|
||||
if (activeRoutePreview.value) {
|
||||
return `已规划 ${activeRoutePreview.value.start.name} 至 ${activeRoutePreview.value.end.name},约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
|
||||
return `已生成 ${activeRoutePreview.value.start.name} 至 ${activeRoutePreview.value.end.name} 的位置关系,约 ${Math.round(activeRoutePreview.value.distanceMeters)} 米。`
|
||||
}
|
||||
|
||||
return routeReady.value
|
||||
? 'SGS 路线数据已就绪,可从已选起点开始规划馆内路线。'
|
||||
: routeReadinessMessage.value || '当前暂不支持正式导航,可查看位置预览。'
|
||||
? '可选择起点查看馆内位置关系,当前不作为正式室内导航。'
|
||||
: routeReadinessMessage.value || '当前可查看位置预览,暂不提供正式室内导航。'
|
||||
})
|
||||
|
||||
const safeDecode = (value: string) => {
|
||||
@@ -635,7 +635,7 @@ const handleStartIndoorGuide = async () => {
|
||||
: null)
|
||||
|
||||
if (!fallbackStart || !endTarget) {
|
||||
routePlanError.value = '未找到可用于路线规划的起点或终点'
|
||||
routePlanError.value = '未找到可用于位置关系预览的起点或终点'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -645,7 +645,7 @@ const handleStartIndoorGuide = async () => {
|
||||
})
|
||||
|
||||
if (!result.route) {
|
||||
routePlanError.value = result.error || '馆内导览路线规划失败'
|
||||
routePlanError.value = result.error || '馆内位置关系生成失败'
|
||||
activeRoutePreview.value = null
|
||||
return
|
||||
}
|
||||
@@ -653,7 +653,7 @@ const handleStartIndoorGuide = async () => {
|
||||
activeRoutePreview.value = result.route
|
||||
activeFloor.value = result.route.start.floorLabel
|
||||
} catch (error) {
|
||||
routePlanError.value = error instanceof Error ? error.message : '馆内导览路线规划失败'
|
||||
routePlanError.value = error instanceof Error ? error.message : '馆内位置关系生成失败'
|
||||
activeRoutePreview.value = null
|
||||
} finally {
|
||||
routePlanning.value = false
|
||||
|
||||
@@ -36,12 +36,15 @@ import {
|
||||
toGuideMapDiagnostics,
|
||||
toLocationPreviewFromPoi,
|
||||
toMuseumFloorFromSgs,
|
||||
toMuseumHallPoisFromSgs,
|
||||
toMuseumPoiFromSgs,
|
||||
toRouteReadinessFromSgsDiagnostics
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
|
||||
const searchableCategories = new Set([
|
||||
'touring_poi',
|
||||
'exhibition_hall',
|
||||
'exhibition_hall_entrance',
|
||||
'basic_service_facility',
|
||||
'transport_circulation',
|
||||
'accessibility_special_service',
|
||||
@@ -50,6 +53,7 @@ const searchableCategories = new Set([
|
||||
|
||||
const toSearchText = (poi: MuseumPoi) => [
|
||||
poi.name,
|
||||
poi.hallName,
|
||||
poi.floorId,
|
||||
poi.floorLabel,
|
||||
poi.primaryCategory.label,
|
||||
@@ -71,7 +75,11 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
sourceObjectName: poi.sourceObjectName,
|
||||
kind: poi.kind,
|
||||
hallId: poi.hallId,
|
||||
hallName: poi.hallName,
|
||||
entrances: poi.entrances
|
||||
})
|
||||
|
||||
const isSearchableIndoorPoi = (poi: MuseumPoi) => (
|
||||
@@ -79,6 +87,15 @@ const isSearchableIndoorPoi = (poi: MuseumPoi) => (
|
||||
&& isPoiOnIndoorNavigableFloor(poi)
|
||||
)
|
||||
|
||||
const dedupePoisById = (pois: MuseumPoi[]) => {
|
||||
const seen = new Set<string>()
|
||||
return pois.filter((poi) => {
|
||||
if (!poi.id || seen.has(poi.id)) return false
|
||||
seen.add(poi.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export interface GuideRepository {
|
||||
getAssetBaseUrl(): string
|
||||
getFloors(): Promise<MuseumFloor[]>
|
||||
@@ -245,16 +262,38 @@ export class SgsSdkGuideRepository implements GuideRepository {
|
||||
const indoorFloors = manifest.floors.filter(isIndoorNavigableFloor)
|
||||
const indoorFloorIds = new Set(indoorFloors.map((floor) => String(floor.floorId)))
|
||||
this.floorAliases = buildSgsFloorAliases(indoorFloors)
|
||||
const poiGroups = await Promise.all(
|
||||
indoorFloors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
|
||||
const floorDataGroups = await Promise.all(
|
||||
indoorFloors.map(async (floor) => {
|
||||
const floorId = String(floor.floorId)
|
||||
const [pois, spaces, navigablePlaces] = await Promise.all([
|
||||
this.provider.getFloorPois(floorId).catch(() => []),
|
||||
this.provider.getFloorSpaces(floorId).catch(() => []),
|
||||
this.provider.getNavigablePlaces(floorId).catch(() => [])
|
||||
])
|
||||
|
||||
return {
|
||||
floorId,
|
||||
pois,
|
||||
hallPois: toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
this.poiCache = poiGroups
|
||||
.flat()
|
||||
const ordinaryPois = floorDataGroups
|
||||
.flatMap((group) => group.pois)
|
||||
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
||||
.filter((poi) => poi.id && poi.name)
|
||||
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
|
||||
|
||||
const hallPois = floorDataGroups
|
||||
.flatMap((group) => group.hallPois)
|
||||
.filter((poi) => indoorFloorIds.has(String(poi.floorId)) || isPoiOnIndoorNavigableFloor(poi))
|
||||
|
||||
this.poiCache = dedupePoisById([
|
||||
...hallPois,
|
||||
...ordinaryPois
|
||||
])
|
||||
|
||||
return this.poiCache
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,10 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||||
requiredData: ['route_graph', 'nav_data']
|
||||
})
|
||||
|
||||
const logRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
|
||||
console.error(`[RouteReadiness] ${message}`, payload)
|
||||
}
|
||||
|
||||
export class StaticGuideRouteRepository implements GuideRouteRepository {
|
||||
private datasetCache: NavRouteDataset | null = null
|
||||
private datasetRequest: Promise<NavRouteDataset> | null = null
|
||||
@@ -156,11 +160,17 @@ export class StaticGuideRouteRepository implements GuideRouteRepository {
|
||||
try {
|
||||
const dataset = await this.loadDataset()
|
||||
if (!dataset.nodes.size || !dataset.edges.length || !dataset.anchorsByPoiId.size) {
|
||||
return createUnavailableReadiness('路线示意数据不完整,当前暂不支持正式导航')
|
||||
logRouteReadinessIssue('路线数据不完整', {
|
||||
nodes: dataset.nodes.size,
|
||||
edges: dataset.edges.length,
|
||||
anchorsByPoiId: dataset.anchorsByPoiId.size
|
||||
})
|
||||
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
|
||||
}
|
||||
|
||||
return applyRouteReadinessGate(manifestReadiness)
|
||||
} catch (error) {
|
||||
console.error('[RouteReadiness] 静态路线数据加载失败', error)
|
||||
return createUnavailableReadiness(error instanceof Error ? error.message : '路线示意数据加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
toGuideRouteResultFromSgsPlan
|
||||
} from '@/data/adapters/sgsSdkRouteAdapter'
|
||||
import { stringifyId, formatSgsFloorLabel } from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
|
||||
|
||||
const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||||
ready: false,
|
||||
@@ -29,6 +28,10 @@ const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||||
requiredData: ['route_graph', 'nav_data']
|
||||
})
|
||||
|
||||
const logSdkRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
|
||||
console.error(`[SDKRouteReadiness] ${message}`, payload)
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||
)
|
||||
@@ -51,11 +54,6 @@ const positionFromPlace = (place: SgsNavigablePlacePayload): [number, number, nu
|
||||
]
|
||||
}
|
||||
|
||||
const numericId = (value: unknown) => {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined
|
||||
}
|
||||
|
||||
export class SdkGuideRouteRepository {
|
||||
private targetsCache: GuideRouteTarget[] | null = null
|
||||
|
||||
@@ -84,7 +82,19 @@ export class SdkGuideRouteRepository {
|
||||
|
||||
// 没有楼层有路线数据时不可用
|
||||
if (readyFloors.length === 0) {
|
||||
return createUnavailableReadiness('SDK 路线规划数据不完整,当前暂不支持正式导航')
|
||||
logSdkRouteReadinessIssue('SDK 路线数据不完整', {
|
||||
floorCount: floors.length,
|
||||
readyFloorCount: readyFloors.length,
|
||||
floors: floors.map((floor) => ({
|
||||
floorId: floor.floorId,
|
||||
floorName: floor.floorName,
|
||||
floorCode: floor.floorCode,
|
||||
routePlanningReady: floor.routePlanningReady,
|
||||
routeNodeCount: floor.routeNodeCount,
|
||||
routeEdgeCount: floor.routeEdgeCount
|
||||
}))
|
||||
})
|
||||
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
|
||||
}
|
||||
|
||||
// 收集未就绪的楼层
|
||||
@@ -97,19 +107,20 @@ export class SdkGuideRouteRepository {
|
||||
.join('、')
|
||||
return {
|
||||
ready: true,
|
||||
message: `部分楼层路线数据未就绪:${floorLabels},已就绪楼层可正常使用导览功能`,
|
||||
message: `部分楼层位置关系数据未就绪:${floorLabels},可查看已就绪楼层的位置关系`,
|
||||
requiredData: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
message: 'SDK 路线数据已就绪',
|
||||
message: 'SDK 位置关系数据已接入,可进行位置预览',
|
||||
requiredData: []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SDKRouteReadiness] 读取 SDK 路线状态失败', error)
|
||||
return createUnavailableReadiness(
|
||||
error instanceof Error ? error.message : 'SDK 路线数据加载失败'
|
||||
error instanceof Error ? error.message : 'SDK 位置关系数据加载失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -148,8 +159,8 @@ export class SdkGuideRouteRepository {
|
||||
targets.push({
|
||||
poiId: placeId,
|
||||
name: placeName,
|
||||
// 展示用标准化的 floorId(如 L1)
|
||||
floorId: toAppFloorId(place.floorId ?? rawFloorId),
|
||||
// 保留后端原始 floorId,确保与 SDK 模型楼层和 ThreeMap 当前楼层一致。
|
||||
floorId: stringifyId(place.floorId ?? rawFloorId),
|
||||
floorLabel,
|
||||
categoryLabel,
|
||||
positionGltf,
|
||||
@@ -188,7 +199,7 @@ export class SdkGuideRouteRepository {
|
||||
const endTarget = targets.find((target) => target.poiId === endPoiId)
|
||||
|
||||
if (!startTarget || !endTarget) {
|
||||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可导航目的地列表中')
|
||||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可预览目的地列表中')
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -196,24 +207,15 @@ export class SdkGuideRouteRepository {
|
||||
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '起点或终点缺少可规划坐标')
|
||||
}
|
||||
|
||||
// 获取原始 floorId 用于 API 调用
|
||||
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||||
const floorIdMap = new Map<string, string>()
|
||||
for (const floor of (diagnostics.floors || []).filter(isIndoorNavigableFloor)) {
|
||||
floorIdMap.set(toAppFloorId(floor.floorId), stringifyId(floor.floorId))
|
||||
}
|
||||
const rawStartFloorId = floorIdMap.get(startTarget.floorId) || startTarget.floorId
|
||||
const rawEndFloorId = floorIdMap.get(endTarget.floorId) || endTarget.floorId
|
||||
|
||||
const route = await this.sdkApiProvider.planRoute({
|
||||
startFloorId: rawStartFloorId,
|
||||
startFloorId: startTarget.floorId,
|
||||
startX: startTarget.positionGltf[0],
|
||||
startY: startTarget.positionGltf[2],
|
||||
startNodeId: numericId(startTarget.routeNodeId) || null,
|
||||
endFloorId: rawEndFloorId,
|
||||
startNodeId: startTarget.routeNodeId || null,
|
||||
endFloorId: endTarget.floorId,
|
||||
endX: endTarget.positionGltf[0],
|
||||
endY: endTarget.positionGltf[2],
|
||||
endNodeId: numericId(endTarget.routeNodeId) || null,
|
||||
endNodeId: endTarget.routeNodeId || null,
|
||||
wheelchair: 0
|
||||
})
|
||||
|
||||
@@ -222,7 +224,7 @@ export class SdkGuideRouteRepository {
|
||||
if (error instanceof GuideRouteError) throw error
|
||||
throw new GuideRouteError(
|
||||
'ROUTE_NOT_FOUND',
|
||||
error instanceof Error ? error.message : 'SGS 路线接口规划失败'
|
||||
error instanceof Error ? error.message : 'SGS 位置关系接口生成失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* CSS 变量定义 */
|
||||
:root {
|
||||
/* 字体 */
|
||||
--museum-font-family: 'HarmonyOS Sans SC', 'HarmonyOS Sans', '鸿蒙黑体', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', Arial, sans-serif;
|
||||
--museum-font-family: '鸿蒙黑体', 'HarmonyOS Sans SC', 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', Arial, sans-serif;
|
||||
|
||||
/* 主色调 */
|
||||
--museum-accent: #E0DF00;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class GuideRouteUseCase {
|
||||
if (!readiness.ready) {
|
||||
return {
|
||||
route: null,
|
||||
error: readiness.message || '当前暂不支持正式导航,可查看位置预览和路线示意'
|
||||
error: readiness.message || '当前可查看位置预览和位置关系,暂不提供正式室内导航'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export class GuideRouteUseCase {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '室内导览位置关系生成失败'
|
||||
return error instanceof Error ? error.message : '馆内位置关系生成失败'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export type GuideTopTab = 'guide' | 'explain'
|
||||
export const GUIDE_LOCATION_PREVIEW_STORAGE_KEY = 'museum-guide:last-location-preview-url'
|
||||
|
||||
export const GUIDE_TOP_TABS: Array<{ id: GuideTopTab; label: string }> = [
|
||||
{ id: 'guide', label: '导览' },
|
||||
{ id: 'guide', label: '馆内' },
|
||||
{ id: 'explain', label: '讲解' }
|
||||
]
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ExplainDetailPageViewModel {
|
||||
subtitle?: string
|
||||
contentType: ExplainContentType
|
||||
coverImages: string[]
|
||||
hallId?: string
|
||||
hallName?: string
|
||||
floorLabel?: string
|
||||
summary: string
|
||||
@@ -144,6 +145,7 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
contentType: contentTypeFor(exhibit),
|
||||
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
|
||||
hallId: exhibit.hallId,
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
summary: exhibit.guideText || exhibit.description || '该展项暂无讲解文稿。',
|
||||
|
||||
Reference in New Issue
Block a user