Compare commits

...

2 Commits

Author SHA1 Message Date
lyf
d199b4602f 完善馆内点位展示与微信地图桥接
Some checks failed
CI / verify (push) Has been cancelled
2026-07-19 16:01:32 +08:00
lyf
127461dd60 修复展厅列表嵌入返回入口 2026-07-19 15:46:51 +08:00
15 changed files with 889 additions and 340 deletions

View File

@@ -1,5 +1,5 @@
<template>
<view class="explain-hall-select" :class="{ 'host-navigation': shouldUseHostNavigation }">
<view class="explain-hall-select" :class="{ 'host-navigation': shouldUseHostNavigation && !showInternalHeader }">
<view v-if="showInternalHeader" class="explain-page-header">
<view class="header-back" @tap="handleBack">
<text class="header-back-icon"></text>
@@ -167,6 +167,7 @@ const props = withDefaults(defineProps<{
hasMore?: boolean
error?: string
loadMoreError?: string
forceInternalHeader?: boolean
}>(), {
halls: () => [],
guideStops: () => [],
@@ -176,7 +177,8 @@ const props = withDefaults(defineProps<{
loadingMore: false,
hasMore: false,
error: '',
loadMoreError: ''
loadMoreError: '',
forceInternalHeader: false
})
const emit = defineEmits<{
@@ -218,7 +220,7 @@ const hallCardThemeMap: Record<string, { color: string; englishName: string }> =
const filteredHalls = computed(() => props.halls)
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUseHostNavigation.value)
const headerTitle = computed(() => {
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
return '展厅讲解'

View File

@@ -1,6 +1,8 @@
<template>
<view class="three-map-container">
<view ref="containerRef" class="three-canvas-wrapper"></view>
<view ref="containerRef" class="three-canvas-wrapper">
<view ref="poiDomLabelLayerRef" class="three-poi-dom-label-layer"></view>
</view>
<view v-if="isLoading && !loadError" class="loading-overlay">
<view class="loading-content">
@@ -37,7 +39,7 @@
<view v-if="showControls && selectedPOI" class="poi-detail-popup">
<view class="poi-content">
<text class="poi-name">{{ selectedPOI.name }}</text>
<text class="poi-name">{{ presentVisitorPoi({ ...selectedPOI, floorLabel: formatFloorLabel(selectedPOI.floorId), primaryCategory: { label: selectedPOI.primaryCategoryZh } }).displayName }}</text>
<text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
</view>
</view>
@@ -51,6 +53,7 @@ import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import { presentVisitorPoi } from '@/view-models/visitorPoiPresentation'
import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
@@ -76,6 +79,14 @@ import {
type GuideAutoSwitchInputSource,
type GuideAutoSwitchRequest
} from './guideAutoSwitchStateMachine'
import {
domLabelBoundsOverlap,
getDomLabelBounds,
isDomLabelWithinViewport,
projectObjectToDom,
type PoiDomLabelAnchor,
type PoiDomLabelKind
} from './poiDomLabels'
type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
@@ -304,7 +315,7 @@ interface PoiSpriteUserData {
hitTargetBaseScale?: number
visibleMarker?: THREE.Sprite
hitTarget?: THREE.Sprite
labelSprite?: THREE.Sprite
labelHandle?: PoiDomLabelHandle
feedbackUntil?: number
labelBaseScaleX?: number
labelBaseScaleY?: number
@@ -315,6 +326,18 @@ interface PoiSpriteUserData {
isCorePoi?: boolean
}
interface PoiDomLabelHandle {
element: HTMLDivElement
anchor: THREE.Object3D
poi: RenderPoi
kind: PoiDomLabelKind
anchorMode: PoiDomLabelAnchor
floorId: string
size: { width: number; height: number }
active: boolean
ownsAnchor?: boolean
}
interface FocusHallMaterialState {
material: THREE.Material
color?: THREE.Color
@@ -361,6 +384,7 @@ interface PoiMarkerCacheEntry {
displayMode: PoiDisplayMode
pois: RenderPoi[]
group: THREE.Group
domLabelHandles: PoiDomLabelHandle[]
markerSize: number
rawPoiCount?: number
rawPositionedPoiCount?: number
@@ -430,6 +454,7 @@ const emit = defineEmits<{
}>()
const containerRef = ref<ContainerRef>(null)
const poiDomLabelLayerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const loadingProgress = ref(0)
@@ -485,7 +510,9 @@ const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
const poiCoordinateDiagnosticsKeys = new Set<string>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusDomLabel: PoiDomLabelHandle | null = null
let poiDomLabelResizeObserver: ResizeObserver | null = null
const poiDomLabelHandlesByElement = new Map<HTMLElement, PoiDomLabelHandle>()
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
let activeFocusHallGlowMesh: THREE.Mesh | null = null
@@ -1328,78 +1355,88 @@ const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
return getPoiPriority(poi)
}
const getAmbientLabelScaleBoost = (poi: RenderPoi) => {
if (!controls || !activeModel) return 1
const distanceRatio = controls.getDistance() / getActiveModelSpan()
const baseBoost = Math.min(1.55, Math.max(0.92, distanceRatio / 0.28))
return isHallPoi(poi) ? Math.max(1, baseBoost) : baseBoost
}
const updateAmbientLabelScale = (labelSprite: THREE.Sprite, poi: RenderPoi) => {
const userData = getPoiSpriteUserData(labelSprite)
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
? userData.labelBaseScaleX
: labelSprite.scale.x
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
? userData.labelBaseScaleY
: labelSprite.scale.y
const boost = getAmbientLabelScaleBoost(poi)
labelSprite.scale.set(baseScaleX * boost, baseScaleY * boost, 1)
}
const getProjectedScreenPosition = (object: THREE.Object3D) => {
if (!camera || !renderer) return null
const point = projectObjectToDom(
object,
camera,
renderer.domElement.clientWidth,
renderer.domElement.clientHeight
)
return point ? new THREE.Vector2(point.x, point.y) : null
}
const projected = object.position.clone().project(camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
const getPoiDomLabelLayerElement = () => {
const layer = poiDomLabelLayerRef.value
if (!layer || typeof HTMLElement === 'undefined') return null
if (layer instanceof HTMLElement) return layer
const element = '$el' in layer ? layer.$el : null
return element instanceof HTMLElement ? element : null
}
const updatePoiDomLabelSize = (handle: PoiDomLabelHandle) => {
const rect = handle.element.getBoundingClientRect()
if (rect.width && rect.height) {
handle.size = { width: rect.width, height: rect.height }
}
}
const setPoiDomLabelVisible = (handle: PoiDomLabelHandle, visible: boolean) => {
handle.element.style.visibility = visible ? 'visible' : 'hidden'
handle.element.style.opacity = visible ? '1' : '0'
}
const disposePoiDomLabel = (handle: PoiDomLabelHandle | null) => {
if (!handle) return
poiDomLabelResizeObserver?.unobserve(handle.element)
poiDomLabelHandlesByElement.delete(handle.element)
handle.element.remove()
if (handle.ownsAnchor) {
handle.anchor.parent?.remove(handle.anchor)
}
}
const updatePoiDomLabelPosition = (handle: PoiDomLabelHandle) => {
if (!camera || !renderer || !handle.active) {
setPoiDomLabelVisible(handle, false)
return null
}
return new THREE.Vector2(
(projected.x + 1) * renderer.domElement.clientWidth * 0.5,
(1 - projected.y) * renderer.domElement.clientHeight * 0.5
const point = projectObjectToDom(
handle.anchor,
camera,
renderer.domElement.clientWidth,
renderer.domElement.clientHeight
)
if (!point) {
setPoiDomLabelVisible(handle, false)
return null
}
const getAmbientLabelBounds = (center: THREE.Vector2, poi: RenderPoi) => {
const width = isHallPoi(poi) ? 138 : 112
const height = isHallPoi(poi) ? 44 : 34
return {
left: center.x - width * 0.5,
right: center.x + width * 0.5,
top: center.y - height * 0.5,
bottom: center.y + height * 0.5
}
const bounds = getDomLabelBounds(point, handle.size, handle.anchorMode)
if (!isDomLabelWithinViewport(bounds, renderer.domElement.clientWidth, renderer.domElement.clientHeight)) {
setPoiDomLabelVisible(handle, false)
return null
}
const labelBoundsOverlap = (
left: ReturnType<typeof getAmbientLabelBounds>,
right: ReturnType<typeof getAmbientLabelBounds>,
spacing: number
) => (
left.left - spacing < right.right
&& left.right + spacing > right.left
&& left.top - spacing < right.bottom
&& left.bottom + spacing > right.top
)
handle.element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -100%)`
return bounds
}
const updateAmbientPoiLabels = () => {
if (!poiGroup || !camera || !renderer) return
const densityTier = getPoiLabelDensityTier()
const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
const acceptedBounds: ReturnType<typeof getDomLabelBounds>[] = []
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
return poi && labelSprite
return poi && labelHandle
? {
marker: sprite,
labelSprite,
labelHandle,
poi,
priority: getPoiAmbientLabelPriority(poi)
}
@@ -1407,32 +1444,26 @@ const updateAmbientPoiLabels = () => {
})
.filter((candidate): candidate is {
marker: THREE.Sprite
labelSprite: THREE.Sprite
labelHandle: PoiDomLabelHandle
poi: RenderPoi
priority: number
} => Boolean(candidate))
.sort((left, right) => right.priority - left.priority)
candidates.forEach(({ marker, labelSprite, poi }) => {
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
labelSprite.visible = false
candidates.forEach(({ marker, labelHandle, poi }) => {
if (!shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
setPoiDomLabelVisible(labelHandle, false)
return
}
updateAmbientLabelScale(labelSprite, poi)
const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx
labelSprite.position.copy(displayPosition)
const screenPosition = getProjectedScreenPosition(labelSprite)
if (!screenPosition) {
labelSprite.visible = false
const bounds = updatePoiDomLabelPosition(labelHandle)
if (!bounds) {
return
}
const bounds = getAmbientLabelBounds(screenPosition, poi)
const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
labelSprite.visible = !overlaps
const overlaps = acceptedBounds.some((acceptedBound) => domLabelBoundsOverlap(bounds, acceptedBound, spacing))
setPoiDomLabelVisible(labelHandle, !overlaps)
if (!overlaps) {
acceptedBounds.push(bounds)
}
@@ -1468,12 +1499,12 @@ const updatePoiVisibilityByDistance = () => {
if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
if (hitTarget) {
hitTarget.visible = false
}
if (labelSprite) {
labelSprite.visible = false
if (labelHandle) {
setPoiDomLabelVisible(labelHandle, false)
}
return
}
@@ -1499,12 +1530,12 @@ const updatePoiVisibilityByDistance = () => {
if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
if (hitTarget) {
hitTarget.visible = false
}
if (labelSprite) {
labelSprite.visible = false
if (labelHandle) {
setPoiDomLabelVisible(labelHandle, false)
}
return
}
@@ -1956,6 +1987,12 @@ const initThree = async () => {
fillLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.fill.position)
scene.add(fillLight)
poiDomLabelResizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const handle = poiDomLabelHandlesByElement.get(entry.target as HTMLElement)
if (handle) updatePoiDomLabelSize(handle)
})
})
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(container)
container.addEventListener('pointerdown', handlePointerDown, true)
@@ -1988,6 +2025,7 @@ const startRenderLoop = () => {
controls?.update()
}
updatePoiTapFeedback(performance.now())
updateAmbientPoiLabels()
updateFocusLabelScale()
renderer.render(scene, camera)
animationId = window.requestAnimationFrame(render)
@@ -2011,6 +2049,7 @@ const handleResize = () => {
camera.aspect = nextAspect
camera.updateProjectionMatrix()
renderer.setSize(width, height)
updateAmbientPoiLabels()
}
const materialTextureKeys = [
@@ -2265,6 +2304,10 @@ const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) =
const detachPoiMarkerGroups = () => {
poiMarkerGroupCache.forEach((entry) => {
entry.group.parent?.remove(entry.group)
entry.domLabelHandles.forEach((handle) => {
handle.active = false
setPoiDomLabelVisible(handle, false)
})
})
if (poiGroup) {
@@ -2285,6 +2328,7 @@ const detachActivePoiLayer = () => {
const disposePoiMarkerCache = () => {
detachPoiMarkerGroups()
poiMarkerGroupCache.forEach((entry) => {
entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
disposeObject(entry.group)
})
poiMarkerGroupCache.clear()
@@ -4504,148 +4548,57 @@ const createPoiHitTargetMaterial = () => {
})
}
const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 560
canvas.height = 168
const context = canvas.getContext('2d')
const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}` : poi.name
const createPoiDomLabel = (
poi: RenderPoi,
anchor: THREE.Object3D,
kind: PoiDomLabelKind
) => {
const layer = getPoiDomLabelLayerElement()
if (!layer) return null
if (context) {
const color = getPoiColor(poi.primaryCategory)
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.2)'
context.shadowBlur = 18
context.shadowOffsetY = 8
context.fillStyle = 'rgba(255, 255, 255, 0.96)'
context.beginPath()
context.roundRect(40, 24, 480, 92, 28)
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = 3
context.strokeStyle = 'rgba(31, 35, 41, 0.08)'
context.stroke()
const element = document.createElement('div')
element.className = `three-poi-dom-label three-poi-dom-label--${kind} ${isHallPoi(poi) ? 'three-poi-dom-label--hall' : 'three-poi-dom-label--service'}`
element.dataset.poiLabelKind = kind
element.dataset.poiId = String(poi.id)
element.dataset.floorId = String(poi.floorId)
element.setAttribute('aria-hidden', 'true')
context.beginPath()
context.moveTo(258, 116)
context.lineTo(302, 116)
context.lineTo(280, 144)
context.closePath()
context.fill()
const title = document.createElement('span')
title.className = 'three-poi-dom-label__title'
const limit = kind === 'focus' ? 14 : (isHallPoi(poi) ? 10 : 7)
const presentation = presentVisitorPoi({
...poi,
floorLabel: formatFloorLabel(poi.floorId),
primaryCategory: { label: poi.primaryCategoryZh }
})
title.textContent = presentation.displayName.length > limit
? `${presentation.displayName.slice(0, limit)}`
: presentation.displayName
element.appendChild(title)
context.beginPath()
context.arc(86, 70, 20, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.font = `700 24px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'left'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(getPoiGlyph(poi), 74, 70, 28)
context.font = `700 34px ${CANVAS_FONT_FAMILY}`
context.fillStyle = '#1f2329'
context.fillText(label, 120, 61, 360)
context.font = `500 22px ${CANVAS_FONT_FAMILY}`
context.fillStyle = '#6b7178'
context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360)
if (kind === 'focus') {
const meta = document.createElement('span')
meta.className = 'three-poi-dom-label__meta'
meta.textContent = `${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`
element.appendChild(meta)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.userData.isPoiLabel = true
sprite.userData.labelBaseScaleX = markerSize * 5.6
sprite.userData.labelBaseScaleY = markerSize * 1.68
sprite.renderOrder = 30
sprite.frustumCulled = false
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
return sprite
layer.appendChild(element)
const handle: PoiDomLabelHandle = {
element,
anchor,
poi,
kind,
anchorMode: 'bottom',
floorId: poi.floorId,
size: { width: 1, height: 1 },
active: kind === 'focus'
}
const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
const canvas = document.createElement('canvas')
canvas.width = 360
canvas.height = 112
const context = canvas.getContext('2d')
const labelLimit = isHallPoi(poi) ? 10 : 7
const label = poi.name.length > labelLimit ? `${poi.name.slice(0, labelLimit)}` : poi.name
if (context) {
const color = getPoiColor(poi.primaryCategory)
const hallLabel = isHallPoi(poi)
const boxX = hallLabel ? 28 : 42
const boxY = hallLabel ? 18 : 26
const boxWidth = hallLabel ? 304 : 276
const boxHeight = hallLabel ? 60 : 48
const radius = hallLabel ? 22 : 18
context.clearRect(0, 0, canvas.width, canvas.height)
context.shadowColor = 'rgba(31, 35, 41, 0.18)'
context.shadowBlur = hallLabel ? 14 : 10
context.shadowOffsetY = hallLabel ? 7 : 5
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
context.beginPath()
context.roundRect(boxX, boxY, boxWidth, boxHeight, radius)
context.fill()
context.shadowColor = 'transparent'
context.lineWidth = hallLabel ? 3 : 2
context.strokeStyle = hallLabel ? `${color}66` : 'rgba(31, 35, 41, 0.1)'
context.stroke()
context.beginPath()
context.moveTo(166, boxY + boxHeight)
context.lineTo(194, boxY + boxHeight)
context.lineTo(180, boxY + boxHeight + 18)
context.closePath()
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
context.fill()
context.strokeStyle = hallLabel ? `${color}50` : 'rgba(31, 35, 41, 0.08)'
context.stroke()
context.beginPath()
context.arc(boxX + 30, boxY + boxHeight * 0.5, hallLabel ? 15 : 12, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.font = `700 ${hallLabel ? 18 : 15}px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
context.fillText(getPoiGlyph(poi), boxX + 30, boxY + boxHeight * 0.5, 24)
context.font = `700 ${hallLabel ? 24 : 20}px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'left'
context.fillStyle = '#1f2329'
context.fillText(label, boxX + 56, boxY + boxHeight * 0.5, boxWidth - 76)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false
}))
sprite.userData.isPoiLabel = true
sprite.userData.poi = poi
sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05)
sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95)
sprite.renderOrder = isHallPoi(poi) ? 18 : 15
sprite.frustumCulled = false
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
sprite.visible = false
return sprite
updatePoiDomLabelSize(handle)
poiDomLabelHandlesByElement.set(element, handle)
poiDomLabelResizeObserver?.observe(element)
setPoiDomLabelVisible(handle, false)
return handle
}
const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
@@ -4723,11 +4676,8 @@ const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
}
const disposeFocusLabel = () => {
if (!activeFocusLabelSprite) return
activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
disposeObject(activeFocusLabelSprite)
activeFocusLabelSprite = null
disposePoiDomLabel(activeFocusDomLabel)
activeFocusDomLabel = null
}
const disposeFocusPulse = () => {
@@ -4751,13 +4701,14 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
if (!poiGroup || !displayPosition) return
disposeFocusLabel()
const markerSize = getPoiMarkerSize()
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
activeFocusLabelSprite.frustumCulled = false
activeFocusLabelSprite.position.copy(displayPosition)
updateFocusLabelScale()
poiGroup.add(activeFocusLabelSprite)
const anchor = new THREE.Object3D()
anchor.position.copy(displayPosition)
poiGroup.add(anchor)
activeFocusDomLabel = createPoiDomLabel(poi, anchor, 'focus')
if (activeFocusDomLabel) {
activeFocusDomLabel.ownsAnchor = true
activeFocusDomLabel.element.dataset.focusLabel = 'active'
}
}
const showFocusPoiBase = (poi: RenderPoi) => {
@@ -4815,26 +4766,10 @@ const getPoiMarkerSize = () => (
: 3
)
const getFocusLabelScaleBoost = () => {
if (!controls || !activeModel) return 1
const distanceRatio = controls.getDistance() / getActiveModelSpan()
return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
}
const updateFocusLabelScale = () => {
if (!activeFocusLabelSprite) return
const userData = getPoiSpriteUserData(activeFocusLabelSprite)
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
? userData.labelBaseScaleX
: activeFocusLabelSprite.scale.x
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
? userData.labelBaseScaleY
: activeFocusLabelSprite.scale.y
const scaleBoost = getFocusLabelScaleBoost()
activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
if (!activeFocusDomLabel) return
const bounds = updatePoiDomLabelPosition(activeFocusDomLabel)
setPoiDomLabelVisible(activeFocusDomLabel, Boolean(bounds))
}
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
@@ -4878,9 +4813,6 @@ const findLoadedPoi = (poiId: string) => (
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
const sprite = new THREE.Sprite(createPoiMaterial(poi))
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
const labelSprite = shouldCreateAmbientPoiLabel(poi)
? createAmbientPoiLabelSprite(poi, markerSize)
: null
const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview'
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
@@ -4889,9 +4821,6 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
sprite.userData.isCorePoi = isCorePoi
sprite.userData.hitTarget = hitTarget
sprite.frustumCulled = false
if (labelSprite) {
sprite.userData.labelSprite = labelSprite
}
sprite.userData.hitTargetBaseScale = hitTargetScale
hitTarget.userData.baseScale = hitTargetScale
@@ -4904,7 +4833,7 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
return { sprite, hitTarget, labelSprite }
return { sprite, hitTarget }
}
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
@@ -4946,6 +4875,7 @@ const createPoiMarkerGroup = (
markerSize: number
) => {
const group = new THREE.Group()
const domLabelHandles: PoiDomLabelHandle[] = []
group.name = `GuideModelPOI_${floorId}_${displayMode}`
group.userData.floorId = floorId
group.userData.currentFloor = floorId
@@ -4955,12 +4885,15 @@ const createPoiMarkerGroup = (
const displayPosition = getPoiDisplayPosition(poi)
if (!displayPosition) return
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
sprite.position.copy(displayPosition)
hitTarget.position.copy(displayPosition)
if (labelSprite) {
labelSprite.position.copy(displayPosition)
labelSprite.visible = false
const labelHandle = shouldCreateAmbientPoiLabel(poi)
? createPoiDomLabel(poi, sprite, 'ambient')
: null
if (labelHandle) {
sprite.userData.labelHandle = labelHandle
domLabelHandles.push(labelHandle)
}
sprite.visible = isPoiIncludedByVisibleFilter(poi)
&& (
@@ -4970,12 +4903,9 @@ const createPoiMarkerGroup = (
hitTarget.visible = sprite.visible
group.add(sprite)
group.add(hitTarget)
if (labelSprite) {
group.add(labelSprite)
}
})
return group
return { group, domLabelHandles }
}
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
@@ -4985,6 +4915,9 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
if (entry.group.parent !== poiGroup) {
poiGroup.add(entry.group)
}
entry.domLabelHandles.forEach((handle) => {
handle.active = true
})
poiGroup.userData.floorId = entry.floorId
poiGroup.userData.currentFloor = entry.floorId
updatePoiMarkerFocus()
@@ -5079,7 +5012,8 @@ const prepareFloorPOIs = async (
floorId: floor.floorId,
displayMode,
pois: validPois,
group: markerGroup,
group: markerGroup.group,
domLabelHandles: markerGroup.domLabelHandles,
markerSize,
rawPoiCount: floorPois.length,
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
@@ -5087,7 +5021,8 @@ const prepareFloorPOIs = async (
}
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
disposeObject(markerGroup)
markerGroup.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
disposeObject(markerGroup.group)
return
}
@@ -5771,6 +5706,9 @@ const disposeScene = () => {
resizeObserver?.disconnect()
resizeObserver = null
poiDomLabelResizeObserver?.disconnect()
poiDomLabelResizeObserver = null
poiDomLabelHandlesByElement.clear()
// 清理自动切换相关资源
if (controls) {
@@ -5920,6 +5858,97 @@ onUnmounted(() => {
}
}
.three-poi-dom-label-layer {
position: absolute;
inset: 0;
z-index: 4;
overflow: hidden;
pointer-events: none;
}
:deep(.three-poi-dom-label) {
position: absolute;
display: flex;
align-items: center;
box-sizing: border-box;
max-width: min(190px, calc(100% - 20px));
pointer-events: none;
white-space: nowrap;
color: #1f2329;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(31, 35, 41, 0.12);
border-radius: 5px;
box-shadow: 0 4px 12px rgba(31, 35, 41, 0.2);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.86);
transition: opacity 120ms ease;
}
:deep(.three-poi-dom-label::after) {
position: absolute;
left: 50%;
bottom: -5px;
width: 8px;
height: 8px;
box-sizing: border-box;
content: '';
background: rgba(255, 255, 255, 0.96);
border-right: 1px solid rgba(31, 35, 41, 0.12);
border-bottom: 1px solid rgba(31, 35, 41, 0.12);
transform: translateX(-50%) rotate(45deg);
}
:deep(.three-poi-dom-label--ambient) {
min-height: 30px;
padding: 5px 9px;
}
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--hall) {
min-height: 34px;
padding: 6px 10px;
border-color: rgba(47, 111, 237, 0.42);
}
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--service) {
font-size: 13px;
line-height: 18px;
font-weight: 700;
}
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--hall) {
font-size: 14px;
line-height: 20px;
font-weight: 700;
}
:deep(.three-poi-dom-label--focus) {
flex-direction: column;
align-items: flex-start;
min-width: 156px;
padding: 8px 11px;
border-color: rgba(47, 111, 237, 0.5);
box-shadow: 0 6px 18px rgba(31, 35, 41, 0.24);
}
:deep(.three-poi-dom-label__title) {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
}
:deep(.three-poi-dom-label--focus .three-poi-dom-label__title) {
font-size: 16px;
line-height: 22px;
font-weight: 700;
}
:deep(.three-poi-dom-label__meta) {
margin-top: 1px;
color: #626b75;
font-size: 13px;
line-height: 18px;
font-weight: 500;
}
.loading-overlay {
position: absolute;
inset: 0;

View File

@@ -0,0 +1,59 @@
import * as THREE from 'three'
export type PoiDomLabelKind = 'ambient' | 'focus'
export type PoiDomLabelAnchor = 'bottom' | 'center'
export interface PoiDomLabelBounds {
left: number
right: number
top: number
bottom: number
}
export const projectObjectToDom = (
object: THREE.Object3D,
camera: THREE.Camera,
width: number,
height: number
) => {
const projected = object.getWorldPosition(new THREE.Vector3()).project(camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
return null
}
return {
x: (projected.x + 1) * width * 0.5,
y: (1 - projected.y) * height * 0.5
}
}
export const getDomLabelBounds = (
point: { x: number; y: number },
size: { width: number; height: number },
anchor: PoiDomLabelAnchor
): PoiDomLabelBounds => {
const top = anchor === 'bottom' ? point.y - size.height : point.y - size.height * 0.5
return {
left: point.x - size.width * 0.5,
right: point.x + size.width * 0.5,
top,
bottom: top + size.height
}
}
export const domLabelBoundsOverlap = (
left: PoiDomLabelBounds,
right: PoiDomLabelBounds,
spacing: number
) => (
left.left - spacing < right.right
&& left.right + spacing > right.left
&& left.top - spacing < right.bottom
&& left.bottom + spacing > right.top
)
export const isDomLabelWithinViewport = (
bounds: PoiDomLabelBounds,
width: number,
height: number
) => bounds.left >= 0 && bounds.right <= width && bounds.top >= 0 && bounds.bottom <= height

View File

@@ -102,8 +102,10 @@ import {
type ArrivalSearchTarget,
type ArrivalTargetType
} from '@/data/arrivalSearchTargets'
// #ifndef MP-WEIXIN
import { navigateToMuseum } from '@/services/MapNavigationService'
// #ifdef H5
import { openExternalNavigation } from '@/services/MapNavigationService'
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
// #endif
// #ifdef MP-WEIXIN
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
@@ -142,6 +144,12 @@ const isOpeningNavigation = ref(false)
let resizeObserver: ResizeObserver | null = null
const primaryActionText = computed(() => {
// #ifdef H5
if (isEmbeddedInWechatMiniProgram()) {
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
}
// #endif
// #ifdef MP-WEIXIN
if (navigationMode === 'native-location') {
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
@@ -274,11 +282,25 @@ const handleNavigateTap = async () => {
return
}
// #ifndef MP-WEIXIN
// #ifdef H5
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
isOpeningNavigation.value = true
try {
await navigateToMuseum()
const target = {
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
}
const bridgeResult = await openWechatMiniProgramLocation(target)
if (bridgeResult.status === 'not-embedded') {
await openExternalNavigation(target)
} else if (bridgeResult.status === 'failed') {
uni.showToast({
title: bridgeResult.reason === 'page-not-found' ? '宿主小程序未配置地图页' : '无法打开微信地图',
icon: 'none'
})
}
} finally {
isOpeningNavigation.value = false
}

View File

@@ -10,6 +10,7 @@
<ExplainHallSelect
:halls="explainHallItems"
stage="hall"
force-internal-header
:loading="explainLoading"
:error="explainError"
@hall-click="handleExplainHallClick"

View File

@@ -44,6 +44,9 @@ import type {
import {
guideRepository
} from '@/repositories/createGuideRepository'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
const SDK_FLOOR_BUNDLE_CONCURRENCY = 2
@@ -135,21 +138,7 @@ const resolveSgsAssetUrl = (url?: string | null) => {
if (!normalizedUrl) return ''
if (/^https?:\/\//i.test(normalizedUrl)) {
try {
const assetUrl = new URL(normalizedUrl)
if (
dataSourceConfig.publicSameOriginAssetHost
&& assetUrl.hostname === dataSourceConfig.publicSameOriginAssetHost
&& assetUrl.port === '9000'
&& assetUrl.pathname.startsWith('/museum-assets/')
) {
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`
}
} catch {
return normalizedUrl
}
return normalizedUrl
return normalizeSameOriginPublicUrl(normalizedUrl)
}
if (!normalizedUrl.startsWith('/')) return normalizedUrl

View File

@@ -0,0 +1,125 @@
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import {
buildWechatOpenLocationPageUrl,
validateWechatOpenLocationTarget,
type WechatOpenLocationTarget
} from '@/utils/wechatOpenLocationProtocol'
interface MiniProgramBridge {
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
navigateTo?: (options: { url: string; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void
}
export type WechatMiniProgramBridgeResult =
| { status: 'opened' }
| { status: 'not-embedded' }
| { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'page-not-found' | 'timeout' }
const SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js'
const BRIDGE_WAIT_TIMEOUT = 2500
const BRIDGE_POLL_INTERVAL = 50
const GET_ENV_TIMEOUT = 1500
const NAVIGATION_TIMEOUT = 3000
let pendingNavigation: Promise<WechatMiniProgramBridgeResult> | null = null
let sdkLoadPromise: Promise<void> | null = null
const getBridge = (): MiniProgramBridge | null => {
if (typeof window === 'undefined') return null
return (window as Window & { wx?: { miniProgram?: MiniProgramBridge } }).wx?.miniProgram || null
}
const wait = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
const loadWechatJsSdk = () => {
if (typeof document === 'undefined' || getBridge()) return Promise.resolve()
if (sdkLoadPromise) return sdkLoadPromise
sdkLoadPromise = new Promise<void>((resolve) => {
const existing = document.querySelector<HTMLScriptElement>('script[data-wechat-js-sdk="true"]')
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => resolve(), { once: true })
return
}
const script = document.createElement('script')
script.src = SDK_URL
script.async = true
script.dataset.wechatJsSdk = 'true'
script.onload = () => resolve()
script.onerror = () => { script.remove(); resolve() }
document.head.appendChild(script)
}).finally(() => { sdkLoadPromise = null })
return sdkLoadPromise
}
const waitForBridge = async (): Promise<MiniProgramBridge | null> => {
const immediate = getBridge()
if (immediate) return immediate
void loadWechatJsSdk()
const deadline = Date.now() + BRIDGE_WAIT_TIMEOUT
while (Date.now() < deadline) {
await wait(BRIDGE_POLL_INTERVAL)
const bridge = getBridge()
if (bridge) return bridge
}
return null
}
const isMiniProgramWebView = (bridge: MiniProgramBridge) => new Promise<boolean>((resolve) => {
if (!bridge.getEnv) return resolve(isEmbeddedInWechatMiniProgram())
let settled = false
const finish = (value: boolean) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(value)
}
const timeout = setTimeout(() => finish(isEmbeddedInWechatMiniProgram()), GET_ENV_TIMEOUT)
try {
bridge.getEnv((result) => finish(Boolean(result?.miniprogram)))
} catch (error) {
console.error('微信小程序环境识别失败:', error)
finish(false)
}
})
const navigateToHostLocationPage = (bridge: MiniProgramBridge, target: WechatOpenLocationTarget) => new Promise<WechatMiniProgramBridgeResult>((resolve) => {
if (!bridge.navigateTo) return resolve({ status: 'failed', reason: 'bridge-unavailable' })
let settled = false
const finish = (result: WechatMiniProgramBridgeResult) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
const timeout = setTimeout(() => finish({ status: 'failed', reason: 'timeout' }), NAVIGATION_TIMEOUT)
try {
bridge.navigateTo({
url: buildWechatOpenLocationPageUrl(target),
success: () => finish({ status: 'opened' }),
fail: (error) => {
console.error('跳转宿主地图页失败:', error)
finish({ status: 'failed', reason: /page.*not found|not found.*page/i.test(error?.errMsg || '') ? 'page-not-found' : 'navigation-failed' })
}
})
} catch (error) {
console.error('调用微信小程序导航桥接失败:', error)
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
const openOnce = async (target: WechatOpenLocationTarget): Promise<WechatMiniProgramBridgeResult> => {
const validation = validateWechatOpenLocationTarget(target)
if (!validation.ok) return { status: 'failed', reason: 'invalid-target' }
if (!isEmbeddedInWechatMiniProgram()) return { status: 'not-embedded' }
const bridge = await waitForBridge()
if (!bridge) return { status: 'failed', reason: 'bridge-unavailable' }
if (!await isMiniProgramWebView(bridge)) return { status: 'failed', reason: 'not-in-mini-program' }
return navigateToHostLocationPage(bridge, validation.value)
}
export const openWechatMiniProgramLocation = (target: WechatOpenLocationTarget) => {
if (pendingNavigation) return pendingNavigation
pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null })
return pendingNavigation
}

View File

@@ -4,14 +4,31 @@ import {
const isAbsoluteHttpUrl = (url: string) => /^https?:\/\//i.test(url)
const defaultSameOriginAssetHosts = [
['1', '92', '206', '90'].join('.')
]
const defaultLegacyAudioHosts = [
['47', '120', '48', '148'].join('.')
]
const configuredSameOriginAssetHosts = () => [
dataSourceConfig.publicSameOriginAssetHost,
...defaultSameOriginAssetHosts
].filter(Boolean)
const configuredLegacyAudioHosts = () => [
dataSourceConfig.publicLegacyAudioHost,
...defaultLegacyAudioHosts
].filter(Boolean)
export const normalizeSameOriginPublicUrl = (url: string | null | undefined) => {
const value = url?.trim()
if (!value || !isAbsoluteHttpUrl(value)) return value || ''
try {
const parsed = new URL(value)
const isSameServer = Boolean(dataSourceConfig.publicSameOriginAssetHost)
&& parsed.hostname === dataSourceConfig.publicSameOriginAssetHost
const isSameServer = configuredSameOriginAssetHosts().includes(parsed.hostname)
if (
isSameServer
@@ -33,8 +50,7 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
}
if (
dataSourceConfig.publicLegacyAudioHost
&& parsed.hostname === dataSourceConfig.publicLegacyAudioHost
configuredLegacyAudioHosts().includes(parsed.hostname)
&& parsed.port === '19000'
&& parsed.pathname.startsWith('/nhm/audio/')
) {

View File

@@ -65,3 +65,52 @@ test('hall goes directly to paged guide objects without outline requests', async
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
})
test('embedded hall list exposes an H5 return control that keeps host markers', async ({ page }) => {
await page.route('**/app-api/gis/guide/catalog/**', async (route) => {
const url = route.request().url()
if (url.includes('/catalog/halls?')) {
await route.fulfill({ json: { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'L1', stopCount: 1 }] } })
return
}
await route.fulfill({ json: { code: 0, data: {} } })
})
await page.goto('/#/pages/explain/list?embedded=wechat-mini-program&weapp=1')
const back = page.locator('.header-back')
await expect(back).toHaveCount(1)
await expect(back).toBeVisible()
await expect(page.locator('.hall-overview-card')).toHaveCount(1)
for (const width of [375, 390, 430]) {
await page.setViewportSize({ width, height: 844 })
const layout = await page.locator('.explain-hall-select').evaluate((container) => {
const header = container.querySelector<HTMLElement>('.explain-page-header')
const card = container.querySelector<HTMLElement>('.hall-overview-card')
if (!header || !card) throw new Error('missing header or hall card')
const headerRect = header.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
clientWidth: container.clientWidth,
scrollWidth: container.scrollWidth,
headerBottom: headerRect.bottom,
cardTop: cardRect.top
}
})
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.cardTop).toBeGreaterThanOrEqual(layout.headerBottom)
}
await back.click()
await expect(page).toHaveURL(/#\/\?tab=guide&embedded=wechat-mini-program&weapp=1$/)
})
test('ordinary H5 hall list return reaches the guide home', async ({ page }) => {
await page.route('**/app-api/gis/guide/catalog/**', (route) => (
route.fulfill({ json: { code: 0, data: [] } })
))
await page.goto('/#/pages/explain/list')
const back = page.locator('.header-back')
await expect(back).toHaveCount(1)
await back.click()
await expect(page).toHaveURL(/#\/\?tab=guide$/)
})

View File

@@ -0,0 +1,94 @@
import { expect, test, type Page } from '@playwright/test'
interface PoiCandidate {
poiId: string
floorId: string
name: string
primaryCategory: string
positionGltf: [number, number, number]
}
interface GuideDiagnostics {
isInitialModelReady: () => boolean
getFloors: () => Array<{ floorId: string }>
getReport: () => { floorId: string; activeFocusPoiId: string }
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
focusTargetPoi: (request: PoiCandidate & { requestId: string }) => Promise<unknown>
clearTargetFocus: () => void
}
const openGuide = async (page: Page) => {
await page.goto('/')
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
await expect.poll(async () => page.evaluate(() => (
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
)), { timeout: 180_000 }).toBe(true)
}
const resetToActiveFloor = async (page: Page) => page.evaluate(async () => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap diagnostics unavailable')
await api.resetToViewBaseline({ view: 'floor', floorId: api.getReport().floorId, reason: 'floor-reset' })
return api.getReport().floorId
})
for (const deviceScaleFactor of [1, 3]) {
test.describe(`POI DOM labels at DPR ${deviceScaleFactor}`, () => {
test.use({ viewport: { width: 390, height: 844 }, deviceScaleFactor })
test('renders measured native labels and clears focused labels without residue', async ({ page }, testInfo) => {
await openGuide(page)
const floorId = await resetToActiveFloor(page)
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
await expect(labels.first()).toBeVisible({ timeout: 30_000 })
const hall = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--hall:visible').first()
const facility = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--service').first()
await expect(hall).toBeVisible()
for (const locator of [hall, facility]) {
await expect(locator).toHaveCSS('pointer-events', 'none')
const fontSize = await locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))
expect(fontSize).toBeGreaterThanOrEqual(locator === hall ? 14 : 13)
}
for (const locator of [hall]) {
const box = await locator.boundingBox()
expect(box).not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y + box!.height).toBeLessThanOrEqual(844)
}
const poiId = await facility.getAttribute('data-poi-id')
expect(poiId).toBeTruthy()
await page.evaluate(async ({ floorId: targetFloorId, targetPoiId }) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap diagnostics unavailable')
const poi = (await api.getFloorPois(targetFloorId)).find((candidate) => candidate.poiId === targetPoiId)
if (!poi) throw new Error(`missing POI ${targetPoiId}`)
await api.focusTargetPoi({ ...poi, requestId: `poi-dom-label-${targetPoiId}` })
}, { floorId, targetPoiId: poiId! })
const focus = page.locator('[data-poi-label-kind="focus"][data-focus-label="active"]')
await expect(focus).toBeVisible({ timeout: 20_000 })
await expect(focus).toHaveCSS('pointer-events', 'none')
await expect(focus.locator('.three-poi-dom-label__title')).toHaveCSS('font-size', '16px')
await expect(focus.locator('.three-poi-dom-label__meta')).toHaveCSS('font-size', '13px')
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-focus.png`) })
await page.evaluate(() => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
.__GUIDE_3D_VISUAL_STABILITY__
api?.clearTargetFocus()
})
await expect(focus).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-ambient.png`) })
})
})
}

View File

@@ -4,14 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
import { ARRIVAL_TARGETS } from '@/data/arrivalSearchTargets'
import { openExternalNavigation } from '@/services/MapNavigationService'
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
const selectedTarget = ARRIVAL_TARGETS[0]
vi.mock('@/services/MapNavigationService', () => ({ openExternalNavigation: vi.fn() }))
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({ openWechatMiniProgramLocation: vi.fn() }))
const selectedTarget = ARRIVAL_TARGETS[1]
const defaultUserAgent = window.navigator.userAgent
const mountArrivalPanel = () => mount(ArrivalPanel, {
props: {
visible: true,
activeType: 'bus',
activeType: selectedTarget.type,
targets: [selectedTarget],
selectedTargetId: selectedTarget.id,
selectedTarget
@@ -19,72 +23,62 @@ const mountArrivalPanel = () => mount(ArrivalPanel, {
})
beforeEach(() => {
vi.mocked(openExternalNavigation).mockResolvedValue(true)
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'not-embedded' })
vi.stubGlobal('uni', { showToast: vi.fn(), showActionSheet: vi.fn() })
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 MicroMessenger/8.0.50 miniProgram'
})
Object.assign(window, {
__wxjs_environment: 'miniprogram',
wx: {
miniProgram: {
navigateTo: vi.fn()
}
}
})
vi.stubGlobal('uni', {
showToast: vi.fn(),
showActionSheet: vi.fn()
})
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Linux; Android 14)' })
})
afterEach(() => {
Object.defineProperty(window.navigator, 'userAgent', {
configurable: true,
value: defaultUserAgent
})
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: defaultUserAgent })
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
delete (window as Window & { wx?: unknown }).wx
vi.clearAllMocks()
vi.unstubAllGlobals()
})
describe('来馆面板 H5 实际渲染', () => {
it('主按钮显示第三方导航,点击后弹出百度和高德选择框', async () => {
describe('来馆面板 H5 导航分流', () => {
it('嵌入小程序 web-view 时只向宿主页面导航,且完整传递当前选中目标', async () => {
Object.assign(window, { __wxjs_environment: 'miniprogram' })
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 MicroMessenger/8.0.50' })
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'opened' })
const wrapper = mountArrivalPanel()
const showActionSheet = (uni as unknown as {
showActionSheet: ReturnType<typeof vi.fn>
}).showActionSheet
const navigateTo = (
window as Window & {
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
}
).wx?.miniProgram?.navigateTo
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
expect(wrapper.text()).not.toContain('打开地图导航')
expect(wrapper.get('.arrival-primary-text').text()).toBe('打开地图导航')
await wrapper.get('.arrival-primary').trigger('tap')
expect(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
itemList: ['百度地图', '高德地图']
}))
expect(wrapper.find('.provider-sheet').exists()).toBe(false)
expect(navigateTo).not.toHaveBeenCalled()
expect(openWechatMiniProgramLocation).toHaveBeenCalledTimes(1)
expect(openWechatMiniProgramLocation).toHaveBeenCalledWith({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
})
expect(openExternalNavigation).not.toHaveBeenCalled()
expect((uni as unknown as { showActionSheet: ReturnType<typeof vi.fn> }).showActionSheet).not.toHaveBeenCalled()
})
it('诊断信息默认隐藏,仅在 debug-build=1 时显示 H5 构建边界', () => {
it('普通 H5 仅回退至第三方地图,并使用当前选中目标', async () => {
const wrapper = mountArrivalPanel()
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
await wrapper.get('.arrival-primary').trigger('tap')
expect(openExternalNavigation).toHaveBeenCalledWith({
latitude: selectedTarget.latitude,
longitude: selectedTarget.longitude,
name: selectedTarget.title,
address: selectedTarget.subtitle
})
})
it('诊断信息默认隐藏,仅在 debug-build=1 时显示构建边界', () => {
const normalWrapper = mountArrivalPanel()
expect(normalWrapper.find('[data-testid="arrival-build-diagnostics"]').exists()).toBe(false)
normalWrapper.unmount()
window.location.hash = '#/pages/index/index?tab=guide&debug-build=1'
const debugWrapper = mountArrivalPanel()
const diagnostics = debugWrapper.get('[data-testid="arrival-build-diagnostics"]')
expect(diagnostics.text()).toContain('构建 IDtest-build')
expect(diagnostics.text()).toContain('Git committest-commit')
expect(diagnostics.text()).toContain('构建平台h5')
expect(diagnostics.text()).toContain('navigationModethird-party-providers')
expect(diagnostics.text()).toContain('location.href')
expect(diagnostics.text()).toContain('UA')
expect(debugWrapper.get('[data-testid="arrival-build-diagnostics"]').text()).toContain('构建平台h5')
})
})

View File

@@ -1,14 +1,46 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ExplainHallSelect from '@/components/explain/ExplainHallSelect.vue'
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => false
const hostEnvironmentMocks = vi.hoisted(() => ({
embeddedInWechatMiniProgram: false
}))
vi.mock('@/utils/hostEnvironment', () => ({
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
}))
afterEach(() => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
})
describe('讲解展厅选择列表', () => {
it('微信嵌入态独立页面可强制显示 H5 返回入口,且不使用宿主导航布局', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
const wrapper = mount(ExplainHallSelect, {
props: {
forceInternalHeader: true
}
})
expect(wrapper.findAll('.explain-page-header')).toHaveLength(1)
expect(wrapper.findAll('.header-back')).toHaveLength(1)
expect(wrapper.classes()).not.toContain('host-navigation')
await wrapper.get('.header-back').trigger('tap')
expect(wrapper.emitted('back')).toEqual([[]])
})
it('微信嵌入态未强制时保留宿主导航布局,保护首页讲解 Tab', () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
const wrapper = mount(ExplainHallSelect)
expect(wrapper.find('.explain-page-header').exists()).toBe(false)
expect(wrapper.classes()).toContain('host-navigation')
})
it('展厅卡片只显示讲解对象数量,不暴露业务单元契约', async () => {
const wrapper = mount(ExplainHallSelect, {
props: {

View File

@@ -42,6 +42,9 @@ const GuidePageFrameStub = defineComponent({
const ExplainHallSelectStub = defineComponent({
name: 'ExplainHallSelect',
props: {
forceInternalHeader: Boolean
},
emits: ['back'],
template: '<button data-testid="explain-list-back" @click="$emit(\'back\')">返回</button>'
})
@@ -103,6 +106,22 @@ describe('讲解展厅列表返回', () => {
wrapper.unmount()
})
it('独立页在微信嵌入态强制提供 H5 返回,并保留宿主识别参数', async () => {
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
window.history.replaceState({}, '', window.location.href)
const wrapper = mountExplainList()
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(true)
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
expect(uni.navigateBack).not.toHaveBeenCalled()
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
url: '/pages/index/index?tab=guide&embedded=wechat-mini-program&weapp=1'
}))
wrapper.unmount()
})
it('上一页不是首页时使用 reLaunch避免回到不相关页面', async () => {
vi.stubGlobal('getCurrentPages', vi.fn(() => [
{ route: 'pages/search/index', options: {} },

View File

@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' }
const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' })
const loadService = async () => {
vi.resetModules()
return import('@/services/WechatMiniProgramBridgeService')
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('uni', { showToast: vi.fn() })
delete (window as Window & { wx?: unknown }).wx
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
window.history.replaceState({}, '', '/')
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
document.querySelectorAll('script[data-wechat-js-sdk="true"]').forEach((node) => node.remove())
})
describe('微信小程序 web-view 桥接', () => {
it('普通浏览器立即返回 not-embedded不加载 SDK', async () => {
const { openWechatMiniProgramLocation } = await loadService()
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'not-embedded' })
expect(document.querySelector('[data-wechat-js-sdk="true"]')).toBeNull()
})
it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => {
setEmbeddedRuntime()
const { openWechatMiniProgramLocation } = await loadService()
const result = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(100)
const navigateTo = vi.fn(({ success }) => success())
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toEqual({ status: 'opened' })
expect(navigateTo).toHaveBeenCalledWith(expect.objectContaining({
url: expect.stringContaining('/pages/open-location/index?latitude=22.604321&longitude=114.058765&name=%E5%81%9C%E8%BD%A6%E5%9C%BA%20A%26B')
}))
})
it('handles getEnv false, navigate fail, page not found, and callback timeout', async () => {
setEmbeddedRuntime()
const { openWechatMiniProgramLocation } = await loadService()
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: false }), navigateTo: vi.fn() } } })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'not-in-mini-program' })
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }) } } })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail permission denied' })) } } })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'navigation-failed' })
const navigateTo = vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail page not found' }))
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'page-not-found' })
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn() } } })
const timeoutResult = openWechatMiniProgramLocation(target)
await vi.advanceTimersByTimeAsync(3001)
await expect(timeoutResult).resolves.toEqual({ status: 'failed', reason: 'timeout' })
})
it('deduplicates rapid calls while navigation is in flight', async () => {
setEmbeddedRuntime()
const { openWechatMiniProgramLocation } = await loadService()
let succeed: (() => void) | undefined
const navigateTo = vi.fn(({ success }) => { succeed = success })
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
const first = openWechatMiniProgramLocation(target)
expect(openWechatMiniProgramLocation(target)).toBe(first)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(navigateTo).toHaveBeenCalledTimes(1)
succeed?.()
await expect(first).resolves.toEqual({ status: 'opened' })
expect(navigateTo).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import * as THREE from 'three'
import {
domLabelBoundsOverlap,
getDomLabelBounds,
isDomLabelWithinViewport,
projectObjectToDom
} from '@/components/map/poiDomLabels'
describe('POI DOM label projection and collision', () => {
it('projects a world-space anchor to CSS pixels', () => {
const camera = new THREE.PerspectiveCamera(60, 2, 0.1, 100)
camera.position.set(0, 0, 10)
camera.lookAt(0, 0, 0)
camera.updateProjectionMatrix()
camera.updateMatrixWorld()
const anchor = new THREE.Object3D()
anchor.position.set(0, 0, 0)
expect(projectObjectToDom(anchor, camera, 390, 195)).toEqual({ x: 195, y: 97.5 })
})
it('uses measured DOM dimensions for bottom-anchored overlap checks', () => {
const hall = getDomLabelBounds({ x: 120, y: 100 }, { width: 146, height: 38 }, 'bottom')
const facility = getDomLabelBounds({ x: 240, y: 100 }, { width: 88, height: 32 }, 'bottom')
expect(domLabelBoundsOverlap(hall, facility, 6)).toBe(true)
expect(isDomLabelWithinViewport(hall, 390, 844)).toBe(true)
expect(isDomLabelWithinViewport(getDomLabelBounds({ x: 5, y: 10 }, { width: 88, height: 32 }, 'bottom'), 390, 844)).toBe(false)
})
})