提交室内导览交互与讲解优化

This commit is contained in:
lyf
2026-06-14 23:48:13 +08:00
parent a7c1879f60
commit feb7310a46
33 changed files with 3257 additions and 361 deletions

View File

@@ -2,10 +2,10 @@
<view class="explain-list">
<view class="spatial-stage">
<view class="stage-copy">
<text class="stage-kicker">空间讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解</text>
<text class="stage-kicker">SGS 场景讲解</text>
<text class="stage-title">{{ activeFloorLabel }} · {{ filteredCards.length }} 个讲解内容</text>
<text class="stage-desc">
{{ playableCount }} 可播放音频 · {{ hallSummaries.length }} 展厅
{{ hallSummaries.length }} 场景空间 · {{ unavailableAudioCount }} 音频待同步
</text>
</view>
<view class="stage-map">
@@ -28,7 +28,7 @@
<view class="summary-main">
<text class="summary-title">当前楼层 {{ activeFloorLabel }}</text>
<text class="summary-subtitle">
{{ filteredCards.length }} 个讲解 · {{ unavailableAudioCount }} 个音频待开放
{{ filteredCards.length }} 个讲解内容 · {{ unavailableAudioCount }} 个音频待同步
</text>
</view>
<view class="summary-chip">
@@ -44,7 +44,7 @@
<input
class="search-input"
type="text"
placeholder="搜索讲解、展厅、主题"
placeholder="搜索讲解、空间、标本"
v-model="searchInputValue"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
@@ -60,7 +60,7 @@
<scroll-view class="panel-scroll" :scroll-y="true" :show-scrollbar="false">
<view v-if="loading" class="state-block">
<text class="state-title">正在加载讲解内容</text>
<text class="state-desc">稍后将展示当前楼层和展厅讲解</text>
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解</text>
</view>
<view v-else-if="error" class="state-block error">
@@ -87,7 +87,7 @@
<view class="featured-body">
<text class="featured-title">{{ featuredCard.title }}</text>
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
<text class="featured-summary">{{ featuredCard.summary || '正式讲解文案待内容库接入后完善。' }}</text>
<text class="featured-summary">{{ featuredCard.summary || '讲解文案来自 SGS 场景设置数据,正式 CMS 接入后可继续替换。' }}</text>
<view class="featured-actions">
<view
v-if="featuredCard.audioStatus === 'playable'"
@@ -126,8 +126,8 @@
<view v-if="hallSummaries.length" class="hall-section">
<view class="section-heading">
<text class="section-title">展厅讲解</text>
<text class="section-count">{{ hallSummaries.length }} 展厅</text>
<text class="section-title">场景空间</text>
<text class="section-count">{{ hallSummaries.length }} 空间</text>
</view>
<view class="hall-grid">
<view
@@ -198,7 +198,7 @@
<view v-else class="state-block empty">
<text class="state-title">未找到相关讲解</text>
<text class="state-desc">试试展厅名称楼层或主题关键词</text>
<text class="state-desc">试试空间名称标本名称楼层或主题关键词</text>
</view>
</view>
</template>
@@ -326,9 +326,9 @@ const featuredCard = computed(() => cards.value.find((card) => card.audioStatus
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
hall: '展厅讲解',
hall: '空间讲解',
zone: '展区讲解',
exhibit: '讲解',
exhibit: '展品讲解',
theme: '主题讲解'
}
return textMap[type]

View File

@@ -0,0 +1,316 @@
<template>
<view class="sgs-map-renderer">
<view ref="containerRef" class="sgs-map-container"></view>
<view v-if="isLoading" class="sdk-state-overlay">
<view class="sdk-state-card">
<view class="loading-spinner"></view>
<text class="sdk-state-title">正在连接 SGS 三维地图</text>
<text class="sdk-state-desc">{{ statusMessage }}</text>
</view>
</view>
<view v-else-if="loadError" class="sdk-state-overlay error-overlay">
<view class="sdk-state-card error-card">
<text class="sdk-state-title">SGS 地图加载失败</text>
<text class="sdk-state-desc">{{ statusMessage }}</text>
<view class="retry-btn" @tap="retryLoad">
<text class="retry-text">重新连接</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
dataSourceConfig,
getSgsMapTargetOrigin
} from '@/config/dataSource'
import { SgsMapService } from '@/services/sgs/SgsMapService'
import {
toAppFloorId,
toErrorResult,
toFloorLabel,
toFocusedResult,
toFocusNodeName,
toNumericFloorId,
type SgsTargetFocusRequest,
type SgsTargetFocusResult
} from '@/services/sgs/SgsMapEventAdapter'
import type {
SgsMapEvents
} from '@/types/sgs-map-sdk'
interface FloorOption {
id: string
label: string
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
type ViewMode = 'overview' | 'floor' | 'multi'
const props = withDefaults(defineProps<{
initialFloorId?: string
initialView?: ViewMode
floors?: FloorOption[]
targetFocus?: SgsTargetFocusRequest | null
}>(), {
initialFloorId: 'L1',
initialView: 'floor',
floors: () => [] as FloorOption[],
targetFocus: null
})
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: SgsMapEvents['poiClick']]
targetFocus: [result: SgsTargetFocusResult]
}>()
const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const statusMessage = ref('正在加载 SDK 脚本与地图基座...')
let service: SgsMapService | null = null
let currentFocusRequestId: number | string | null = null
const getContainerElement = () => {
const container = containerRef.value
if (!container || typeof HTMLElement === 'undefined') return null
if (container instanceof HTMLElement) return container
const element = '$el' in container ? container.$el : null
return element instanceof HTMLElement ? element : null
}
const disposeService = () => {
service?.destroy()
service = null
}
const registerEvents = () => {
service?.on('floorChanged', (payload) => {
emit('floorChange', toAppFloorId(payload.floorId))
})
service?.on('poiClick', (poi) => {
emit('poiClick', poi)
})
service?.on('loadError', (error) => {
loadError.value = true
isLoading.value = false
statusMessage.value = error.detail || '地图基座 iframe 加载失败,请检查 VITE_SGS_H5_ENGINE_URL。'
})
service?.on('error', (error) => {
loadError.value = true
isLoading.value = false
statusMessage.value = error.message || error.code || 'SGS Map SDK 运行异常'
})
}
const initSdk = async () => {
disposeService()
isLoading.value = true
loadError.value = false
statusMessage.value = '正在加载 SDK 脚本与地图基座...'
await nextTick()
const container = getContainerElement()
if (!container) {
isLoading.value = false
loadError.value = true
statusMessage.value = '未找到 SGS 地图容器。'
return
}
service = new SgsMapService({
container,
scriptUrl: dataSourceConfig.sgsSdkScriptUrl,
sdkUrl: dataSourceConfig.sgsMapEngineUrl,
targetOrigin: getSgsMapTargetOrigin(),
floorId: toNumericFloorId(props.initialFloorId),
timeout: dataSourceConfig.sgsSdkTimeoutMs
})
registerEvents()
try {
await service.init()
isLoading.value = false
loadError.value = false
statusMessage.value = 'SGS 三维地图已连接。'
if (props.initialView === 'overview' || props.initialView === 'multi') {
await service.resetView().catch(() => undefined)
}
if (props.targetFocus) {
await focusTargetPoi(props.targetFocus)
}
} catch (error) {
isLoading.value = false
loadError.value = true
statusMessage.value = error instanceof Error ? error.message : 'SGS Map SDK 初始化失败。'
}
}
const switchFloor = async (floorId: string) => {
try {
await service?.changeFloor(toNumericFloorId(floorId))
} catch (error) {
loadError.value = true
statusMessage.value = error instanceof Error ? error.message : `楼层切换失败:${toFloorLabel(floorId)}`
}
}
const focusTargetPoi = async (request: SgsTargetFocusRequest) => {
if (currentFocusRequestId === request.requestId) return
currentFocusRequestId = request.requestId
try {
await switchFloor(request.floorId)
await service?.focusTo(toFocusNodeName(request))
emit('targetFocus', toFocusedResult(request))
} catch (error) {
emit('targetFocus', toErrorResult(request, error))
}
}
const showOverview = async () => {
await service?.resetView().catch(() => undefined)
}
const showMultiFloor = async () => {
await service?.resetView().catch(() => undefined)
}
const disableAutoSwitchTemporarily = () => {
// SGS SDK does not expose the local zoom auto-switch contract.
}
const retryLoad = () => {
void initSdk()
}
watch(() => props.targetFocus, (request) => {
if (request && !isLoading.value && !loadError.value) {
void focusTargetPoi(request)
}
})
onMounted(() => {
void initSdk()
})
onUnmounted(() => {
disposeService()
})
defineExpose({
switchFloor,
showOverview,
showMultiFloor,
disableAutoSwitchTemporarily
})
</script>
<style scoped lang="scss">
.sgs-map-renderer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #eef2f0;
}
.sgs-map-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.sdk-state-overlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
background: rgba(245, 247, 244, 0.82);
backdrop-filter: blur(10px);
}
.sdk-state-card {
width: min(280px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 18px;
box-sizing: border-box;
border-radius: 14px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.8);
box-shadow: 0 12px 30px rgba(24, 32, 21, 0.12);
text-align: center;
}
.error-card {
border-color: rgba(255, 122, 89, 0.28);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(31, 35, 41, 0.12);
border-top-color: #1f2329;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.sdk-state-title {
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #1f2329;
}
.sdk-state-desc {
font-size: 12px;
line-height: 18px;
color: #626a73;
}
.retry-btn {
min-width: 96px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px;
border-radius: 17px;
background: #1f2329;
}
.retry-text {
font-size: 13px;
line-height: 18px;
color: #ffffff;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@@ -23,8 +23,8 @@
</view>
<view v-if="showControls && floors.length" class="map-toolbar">
<view class="overview-btn" :class="{ active: activeView === 'overview' }" @tap="showOverview">
<text class="overview-text">全馆</text>
<view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
<text class="overview-text">多层</text>
</view>
</view>
@@ -57,7 +57,8 @@ import type {
GuideRenderPoi
} from '@/domain/guideModel'
type ViewMode = 'overview' | 'floor'
type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
type FloorIndexItem = GuideModelFloorAsset
@@ -86,6 +87,13 @@ interface TargetPoiFocusResult {
message?: string
}
interface MultiFloorModelItem {
floor: FloorIndexItem
label: string
model: THREE.Object3D
size: THREE.Vector3
}
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
const props = withDefaults(defineProps<{
@@ -96,19 +104,30 @@ const props = withDefaults(defineProps<{
showControls?: boolean
showPoi?: boolean
targetFocus?: TargetPoiFocusRequest | null
autoSwitch?: boolean
autoSwitchThresholdLow?: number
autoSwitchThresholdHigh?: number
autoSwitchCooldown?: number
}>(), {
assetBaseUrl: '',
initialFloorId: 'L1',
initialView: 'overview',
showControls: true,
showPoi: false,
targetFocus: null
targetFocus: null,
autoSwitch: true,
autoSwitchThresholdLow: 1.0,
autoSwitchThresholdHigh: 1.3,
autoSwitchCooldown: 2000
})
const emit = defineEmits<{
floorChange: [floorId: string]
poiClick: [poi: RenderPoi]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
}>()
const containerRef = ref<ContainerRef>(null)
@@ -146,6 +165,17 @@ let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
let modelLoadVersion = 0
let pointerDownState: { x: number; y: number } | null = null
let cachedOverviewModel: THREE.Object3D | null = null
// 自动切换状态
let isAutoSwitchLocked = false
let lastAutoSwitchTime = 0
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
const getContainerElement = () => {
const container = containerRef.value
@@ -170,6 +200,37 @@ const setProgress = (progress: number, message: string) => {
loadingMessage.value = message
}
const staleModelLoadMessage = 'STALE_MODEL_LOAD'
const startModelLoad = () => {
modelLoadVersion += 1
return modelLoadVersion
}
const invalidateModelLoads = () => {
modelLoadVersion += 1
}
const isCurrentModelLoad = (loadToken: number) => (
loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
)
const createStaleModelLoadError = () => new Error(staleModelLoadMessage)
const isStaleModelLoadError = (error: unknown) => (
error instanceof Error && error.message === staleModelLoadMessage
)
const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
if (isCurrentModelLoad(loadToken)) return
if (staleObject) {
disposeObject(staleObject)
}
throw createStaleModelLoadError()
}
const waitForContainer = async () => {
await nextTick()
@@ -218,6 +279,8 @@ const initThree = async () => {
controls.enableZoom = true
controls.minDistance = 6
controls.maxDistance = 1200
controls.minPolarAngle = 0.08
controls.maxPolarAngle = Math.PI * 0.48
loader = new GLTFLoader()
poiGroup = new THREE.Group()
@@ -238,6 +301,12 @@ const initThree = async () => {
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(container)
container.addEventListener('pointerdown', handlePointerDown)
container.addEventListener('pointerup', handlePointerUp)
// 监听控制器变化,用于自动切换
if (props.autoSwitch && controls) {
controls.addEventListener('change', checkAutoSwitch)
}
startRenderLoop()
}
@@ -280,10 +349,21 @@ const disposeObject = (object: THREE.Object3D) => {
})
}
const disposeCachedOverviewModel = () => {
if (!cachedOverviewModel) return
disposeObject(cachedOverviewModel)
cachedOverviewModel = null
}
const clearSceneData = () => {
if (activeModel && scene) {
scene.remove(activeModel)
disposeObject(activeModel)
if (activeModel === cachedOverviewModel) {
cachedOverviewModel.visible = false
} else {
disposeObject(activeModel)
}
}
activeModel = null
@@ -299,7 +379,7 @@ const clearSceneData = () => {
}
}
const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, reject) => {
const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
if (!loader) {
reject(new Error('GLTF 加载器未初始化'))
return
@@ -309,6 +389,8 @@ const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, re
url,
resolve,
(event) => {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
if (event.total > 0) {
const modelProgress = Math.round((event.loaded / event.total) * 70)
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
@@ -330,41 +412,224 @@ const prepareModel = (model: THREE.Object3D) => {
})
}
const fitCameraToObject = (object: THREE.Object3D) => {
const getObjectSize = (object: THREE.Object3D) => (
new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3())
)
const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1)
return Math.max(maxFloorHeight * 3.2, maxFootprint * 0.2, 42)
}
const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
if (!items.length) return
const verticalGap = getMultiFloorVerticalGap(items)
const centerIndex = (items.length - 1) / 2
items.forEach((item, index) => {
const offsetY = (index - centerIndex) * verticalGap
item.model.position.y += offsetY
item.model.userData.multiFloorOffsetY = offsetY
item.model.userData.multiFloorVerticalGap = verticalGap
})
}
const checkAutoSwitch = () => {
// 检查是否启用自动切换
if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
return
}
// 检查加载锁
if (isAutoSwitchLocked) {
emit('autoSwitchBlocked', { reason: 'loading-locked' })
return
}
// 检查冷却时间
const now = Date.now()
if (now - lastAutoSwitchTime < props.autoSwitchCooldown) {
emit('autoSwitchBlocked', { reason: 'cooldown' })
return
}
// 检查必要条件
if (!controls || !activeModel || activeView.value === 'multi') return
// 获取当前距离
const distance = controls.getDistance()
// 计算模型尺寸和阈值
const box = new THREE.Box3().setFromObject(activeModel)
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const thresholdLow = maxDim * props.autoSwitchThresholdLow
const thresholdHigh = maxDim * props.autoSwitchThresholdHigh
// 判断是否需要切换
if (activeView.value === 'overview' && distance < thresholdLow) {
// 从建筑外观自动切换到单楼层
isAutoSwitchLocked = true
lastAutoSwitchTime = now
void runAutoSwitchLoad(
{
from: 'overview',
to: 'floor',
trigger: 'zoom-in',
distance
},
() => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
'单楼层模型加载失败'
)
} else if (activeView.value === 'floor' && distance > thresholdHigh) {
// 从单楼层自动切换到完整外围模型
isAutoSwitchLocked = true
lastAutoSwitchTime = now
void runAutoSwitchLoad(
{
from: 'floor',
to: 'overview',
trigger: 'zoom-out',
distance
},
loadOverview,
'建筑外观模型加载失败'
)
}
}
const runAutoSwitchLoad = async (
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
loadTask: () => Promise<void>,
fallbackMessage: string
) => {
try {
isLoading.value = true
loadError.value = false
await loadTask()
isLoading.value = false
emit('autoSwitch', event)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('室内 3D 自动视角切换失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : fallbackMessage)
} finally {
isAutoSwitchLocked = false
}
}
const disableAutoSwitchTemporarily = (durationMs: number) => {
autoSwitchTemporarilyDisabled = true
if (autoSwitchDisableTimer) {
clearTimeout(autoSwitchDisableTimer)
}
autoSwitchDisableTimer = setTimeout(() => {
autoSwitchTemporarilyDisabled = false
autoSwitchDisableTimer = null
}, durationMs)
}
const setCameraView = (center: THREE.Vector3, maxDim: number, direction: THREE.Vector3, distanceFactor = 0.58) => {
if (!camera || !controls) return
isProgrammaticCameraChange = true
if (programmaticCameraTimer) {
clearTimeout(programmaticCameraTimer)
}
const fov = camera.fov * (Math.PI / 180)
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor
try {
camera.near = Math.max(0.1, maxDim / 1000)
camera.far = Math.max(10000, maxDim * 20)
camera.position.copy(center).add(direction.normalize().multiplyScalar(distance))
camera.updateProjectionMatrix()
controls.target.copy(center)
controls.minDistance = Math.max(2, maxDim * 0.08)
controls.maxDistance = Math.max(20, maxDim * 8)
controls.update()
} finally {
programmaticCameraTimer = window.setTimeout(() => {
isProgrammaticCameraChange = false
programmaticCameraTimer = null
}, 120)
}
}
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
if (!camera || !controls) return
const box = new THREE.Box3().setFromObject(object)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z, 1)
const fov = camera.fov * (Math.PI / 180)
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.58
const direction = new THREE.Vector3(0.85, 0.62, 1).normalize()
const direction = preset === 'top'
? new THREE.Vector3(0.02, 1, 0.02)
: new THREE.Vector3(0.85, 0.62, 1)
camera.near = Math.max(0.1, maxDim / 1000)
camera.far = Math.max(10000, maxDim * 20)
camera.position.copy(center).add(direction.multiplyScalar(distance))
camera.updateProjectionMatrix()
setCameraView(center, maxDim, direction)
}
controls.target.copy(center)
controls.minDistance = Math.max(2, maxDim * 0.08)
controls.maxDistance = Math.max(20, maxDim * 8)
controls.update()
const resetCamera = () => {
if (activeModel) {
fitCameraToObject(activeModel)
}
}
const setCameraPreset = (preset: CameraPreset) => {
if (activeModel) {
fitCameraToObject(activeModel, preset)
}
}
const loadOverview = async () => {
const packageData = renderPackage.value
if (!packageData || !scene) return
const loadToken = startModelLoad()
activeView.value = 'overview'
clearSceneData()
setProgress(18, '正在加载全馆三维模型...')
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载全馆三维模型')
if (cachedOverviewModel) {
const targetScene = scene
if (!targetScene) throw createStaleModelLoadError()
activeModel = cachedOverviewModel
activeModel.visible = true
targetScene.add(activeModel)
fitCameraToObject(activeModel)
return
}
setProgress(18, '正在加载建筑外观模型...')
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', loadToken)
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
if (!targetScene) {
disposeObject(gltf.scene)
throw createStaleModelLoadError()
}
activeModel = gltf.scene
activeModel.name = 'GuideOverviewModel'
prepareModel(activeModel)
scene.add(activeModel)
cachedOverviewModel = activeModel
targetScene.add(activeModel)
fitCameraToObject(activeModel)
}
@@ -372,23 +637,90 @@ const loadFloor = async (floorId: string) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor || !scene) return
const loadToken = startModelLoad()
activeView.value = 'floor'
currentFloor.value = floor.floorId
clearSceneData()
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken)
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
if (!targetScene) {
disposeObject(gltf.scene)
throw createStaleModelLoadError()
}
activeModel = gltf.scene
activeModel.name = `GuideFloorModel_${floor.floorId}`
activeModel.userData.floorId = floor.floorId
prepareModel(activeModel)
scene.add(activeModel)
targetScene.add(activeModel)
fitCameraToObject(activeModel)
if (shouldRenderPoiMarkers.value) {
await loadFloorPOIs(floor)
await loadFloorPOIs(floor, loadToken)
assertCurrentModelLoad(loadToken)
}
}
const loadMultiFloor = async () => {
if (!scene || !floorIndex.value.length) return
const loadToken = startModelLoad()
activeView.value = 'multi'
clearSceneData()
setProgress(18, '正在加载多层展示模型...')
const group = new THREE.Group()
group.name = 'GuideMultiFloorModel'
const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order)
const loadedFloors: MultiFloorModelItem[] = []
for (let index = 0; index < orderedFloors.length; index += 1) {
assertCurrentModelLoad(loadToken, group)
const floor = orderedFloors[index]
const label = formatFloorLabel(floor.floorId)
const gltf = await loadModel(floor.modelUrl, `正在加载 ${label} 多层模型`, loadToken)
if (!isCurrentModelLoad(loadToken)) {
disposeObject(gltf.scene)
disposeObject(group)
throw createStaleModelLoadError()
}
const floorModel = gltf.scene
floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
floorModel.userData.floorId = floor.floorId
prepareModel(floorModel)
loadedFloors.push({
floor,
label,
model: floorModel,
size: getObjectSize(floorModel)
})
setProgress(
18 + Math.round(((index + 1) / orderedFloors.length) * 72),
`已加载 ${label}`
)
}
assertCurrentModelLoad(loadToken, group)
applyMultiFloorLayout(loadedFloors)
loadedFloors.forEach((item) => group.add(item.model))
const targetScene = scene
if (!targetScene) {
disposeObject(group)
throw createStaleModelLoadError()
}
activeModel = group
targetScene.add(activeModel)
fitCameraToObject(activeModel)
}
const createPoiMaterial = (poi: RenderPoi) => {
const canvas = document.createElement('canvas')
canvas.width = 96
@@ -570,10 +902,15 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
return poi
}
const loadFloorPOIs = async (floor: FloorIndexItem) => {
const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
if (!poiGroup) return
const validPois = await props.modelSource.loadFloorPois(floor.floorId)
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
const targetPoiGroup = poiGroup
if (!targetPoiGroup) return
const markerSize = getPoiMarkerSize()
validPois.forEach((poi) => {
@@ -583,12 +920,37 @@ const loadFloorPOIs = async (floor: FloorIndexItem) => {
sprite.userData.baseScale = markerSize
sprite.userData.poi = poi
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
poiGroup!.add(sprite)
targetPoiGroup.add(sprite)
})
}
const handlePointerDown = (event: PointerEvent) => {
if (!props.showControls || !camera || !renderer || !poiGroup || !getContainerElement()) return
const clearMapSelection = (shouldEmit = true) => {
activeFocusPoiId.value = ''
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
if (shouldEmit) {
emit('selectionClear')
}
}
const findFloorObject = (object: THREE.Object3D) => {
let current: THREE.Object3D | null = object
while (current) {
if (typeof current.userData.floorId === 'string') {
return current
}
current = current.parent
}
return null
}
const handleSceneTap = (event: PointerEvent) => {
if (!camera || !renderer || !getContainerElement()) return
const rect = renderer.domElement.getBoundingClientRect()
const pointer = new THREE.Vector2(
@@ -597,16 +959,55 @@ const handlePointerDown = (event: PointerEvent) => {
)
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(poiGroup.children, false)
const hit = hits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi)
if (activeView.value === 'floor' && poiGroup) {
const poiHits = raycaster.intersectObjects(poiGroup.children, false)
const hit = poiHits.find((item) => !item.object.userData.isPoiLabel && item.object.userData.poi)
if (hit?.object.userData.poi) {
selectedPOI.value = hit.object.userData.poi as RenderPoi
activeFocusPoiId.value = selectedPOI.value.id
updatePoiMarkerFocus()
emit('poiClick', selectedPOI.value)
if (hit?.object.userData.poi) {
const poi = hit.object.userData.poi as RenderPoi
selectedPOI.value = poi
activeFocusPoiId.value = poi.id
updatePoiMarkerFocus()
showFocusPoiLabel(poi)
focusCameraOnPoi(poi)
emit('poiClick', poi)
return
}
}
if (activeView.value === 'multi' && activeModel) {
const floorHits = raycaster.intersectObjects(activeModel.children, true)
const floorObject = floorHits
.map((hit) => findFloorObject(hit.object))
.find((object): object is THREE.Object3D => Boolean(object))
if (floorObject?.userData.floorId) {
disableAutoSwitchTemporarily(10000)
void handleFloorChange(floorObject.userData.floorId as string)
return
}
}
clearMapSelection()
}
const handlePointerDown = (event: PointerEvent) => {
pointerDownState = {
x: event.clientX,
y: event.clientY
}
}
const handlePointerUp = (event: PointerEvent) => {
if (!pointerDownState) return
const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y)
pointerDownState = null
if (moveDistance > 8) return
handleSceneTap(event)
}
const emitTargetFocus = (
@@ -625,10 +1026,7 @@ const emitTargetFocus = (
const clearTargetFocus = () => {
pendingTargetFocus = null
activeFocusPoiId.value = ''
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
clearMapSelection(false)
}
const isSceneReadyForTargetFocus = () => (
@@ -682,7 +1080,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
isLoading.value = false
emit('floorChange', request.floorId)
} else if (shouldRenderPoiMarkers.value && poiGroup && poiGroup.children.length === 0) {
await loadFloorPOIs(floor)
await loadFloorPOIs(floor, modelLoadVersion)
}
const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)
@@ -702,6 +1100,8 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
emitTargetFocus(request, 'focused')
return true
} catch (error) {
if (isStaleModelLoadError(error)) return false
console.error('目标 POI 聚焦失败:', error)
loadError.value = true
isLoading.value = false
@@ -751,6 +1151,11 @@ const loadModelPackage = async () => {
return
}
if (props.initialView === 'multi') {
await loadMultiFloor()
return
}
await loadOverview()
}
@@ -770,6 +1175,8 @@ const init3DScene = async () => {
isLoading.value = false
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('室内 3D 模型资源加载失败:', error)
loadError.value = true
isLoading.value = false
@@ -785,6 +1192,8 @@ const handleFloorChange = async (floorId: string) => {
isLoading.value = false
emit('floorChange', floorId)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('楼层模型加载失败:', error)
loadError.value = true
isLoading.value = false
@@ -793,17 +1202,26 @@ const handleFloorChange = async (floorId: string) => {
}
const showOverview = async () => {
// 建筑外观不再作为手动切换入口;只在初始加载与缩放自动切换时展示。
if (activeView.value === 'overview') {
resetCamera()
}
}
const showMultiFloor = async () => {
try {
isLoading.value = true
loadError.value = false
activeFocusPoiId.value = ''
await loadOverview()
await loadMultiFloor()
isLoading.value = false
} catch (error) {
console.error('全馆模型加载失败:', error)
if (isStaleModelLoadError(error)) return
console.error('多层展示模型加载失败:', error)
loadError.value = true
isLoading.value = false
setProgress(0, error instanceof Error ? error.message : '全馆模型加载失败')
setProgress(0, error instanceof Error ? error.message : '多层展示模型加载失败')
}
}
@@ -813,6 +1231,7 @@ const retryLoad = () => {
const disposeScene = () => {
isDisposed = true
invalidateModelLoads()
if (animationId) {
window.cancelAnimationFrame(animationId)
@@ -821,13 +1240,31 @@ const disposeScene = () => {
const container = getContainerElement()
container?.removeEventListener('pointerdown', handlePointerDown)
container?.removeEventListener('pointerup', handlePointerUp)
resizeObserver?.disconnect()
resizeObserver = null
controls?.dispose()
// 清理自动切换相关资源
if (controls) {
controls.removeEventListener('change', checkAutoSwitch)
controls.dispose()
}
controls = null
if (autoSwitchDisableTimer) {
clearTimeout(autoSwitchDisableTimer)
autoSwitchDisableTimer = null
}
if (programmaticCameraTimer) {
clearTimeout(programmaticCameraTimer)
programmaticCameraTimer = null
}
isProgrammaticCameraChange = false
clearSceneData()
disposeCachedOverviewModel()
scene?.clear()
scene = null
camera = null
@@ -846,15 +1283,15 @@ const disposeScene = () => {
defineExpose({
switchFloor: handleFloorChange,
showOverview,
showMultiFloor,
resetCamera,
setCameraPreset,
focusTargetPoi: (request: TargetPoiFocusRequest) => {
queueTargetFocus(request)
},
clearNavigation: () => {
activeFocusPoiId.value = ''
selectedPOI.value = null
disposeFocusLabel()
updatePoiMarkerFocus()
}
clearSelection: clearMapSelection,
clearNavigation: () => clearMapSelection(false),
disableAutoSwitchTemporarily
})
watch(() => props.modelSource, () => {

View File

@@ -3,9 +3,20 @@
<view class="map-layer" :class="`map-${mapType}`">
<template v-if="mapType === 'indoor'">
<!-- #ifdef H5 -->
<SgsMapRenderer
v-if="useSgsMapRenderer"
ref="indoorRendererRef"
class="indoor-three-map"
:initial-floor-id="activeFloorId"
:initial-view="indoorInitialView"
:floors="props.floors"
:target-focus="targetFocusRequest"
@target-focus="handleTargetFocus"
@floor-change="handleSdkFloorChange"
/>
<ThreeMap
v-if="indoorModelSource"
ref="threeMapRef"
v-else-if="indoorModelSource"
ref="indoorRendererRef"
class="indoor-three-map"
:asset-base-url="indoorAssetBaseUrl"
:model-source="indoorModelSource"
@@ -14,7 +25,11 @@
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:target-focus="targetFocusRequest"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
@selection-clear="handleSelectionClear"
@target-focus="handleTargetFocus"
@auto-switch="handleAutoSwitch"
/>
<!-- #endif -->
<!-- #ifndef H5 -->
@@ -89,27 +104,15 @@
<slot name="overlay"></slot>
<view
v-if="mapType === 'indoor' && showIndoorViewToggle"
class="indoor-view-toggle"
:style="indoorViewToggleStyle"
v-if="showIndoorRightControls && showLayerModeToggle"
class="layer-mode-toggle"
:style="layerModeToggleStyle"
@tap="handleLayerModeToggle"
>
<view
class="indoor-view-item"
:class="{ active: indoorView === 'overview' }"
@tap="handleIndoorViewChange('overview')"
>
<text class="indoor-view-label">全馆</text>
</view>
<view
class="indoor-view-item"
:class="{ active: indoorView === 'floor' }"
@tap="handleIndoorViewChange('floor')"
>
<text class="indoor-view-label">楼层</text>
</view>
<text class="layer-mode-label">{{ layerModeActionLabel }}</text>
</view>
<view v-if="showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view v-if="showIndoorRightControls && showFloor" class="floor-switcher" :style="floorSwitcherStyle">
<view
v-for="floor in floorLabels"
:key="floor"
@@ -141,9 +144,14 @@ import { computed, ref } from 'vue'
import TencentMap from '@/components/map/TencentMap.vue'
// #ifdef H5
import ThreeMap from '@/components/map/ThreeMap.vue'
import SgsMapRenderer from '@/components/map/SgsMapRenderer.vue'
import {
isSgsSdkMode
} from '@/config/dataSource'
// #endif
import type {
GuideModelSource
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
interface GuideFloorOption {
@@ -169,7 +177,8 @@ interface TargetPoiFocusResult {
message?: string
}
type IndoorViewMode = 'overview' | 'floor'
type IndoorViewMode = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
const props = withDefaults(defineProps<{
searchText?: string
@@ -177,14 +186,15 @@ const props = withDefaults(defineProps<{
activeFloor?: string
indoorView?: IndoorViewMode
indoorInitialView?: IndoorViewMode
layerMode?: LayerDisplayMode
searchTop?: string
floorTop?: string
indoorViewToggleTop?: string
layerModeToggleTop?: string
toolsTop?: string
tools?: string[]
showSearch?: boolean
showFloor?: boolean
showIndoorViewToggle?: boolean
showLayerModeToggle?: boolean
modeTop?: string
modeLayout?: 'full' | 'status'
modeStatus?: string
@@ -203,14 +213,15 @@ const props = withDefaults(defineProps<{
activeFloor: '1F',
indoorView: 'floor',
indoorInitialView: 'floor',
layerMode: 'single',
searchTop: '16px',
floorTop: '164px',
indoorViewToggleTop: '154px',
layerModeToggleTop: '154px',
toolsTop: '406px',
tools: () => [] as string[],
showSearch: true,
showFloor: true,
showIndoorViewToggle: false,
showLayerModeToggle: false,
modeTop: '60px',
modeLayout: 'full',
modeStatus: '',
@@ -231,16 +242,31 @@ const emit = defineEmits<{
floorChange: [floor: string]
toolClick: [tool: string]
indoorViewChange: [view: IndoorViewMode]
layerModeChange: [mode: LayerDisplayMode]
poiClick: [poi: GuideRenderPoi]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
}>()
const threeMapRef = ref<{
const indoorRendererRef = ref<{
switchFloor?: (floorId: string) => Promise<void> | void
showOverview?: () => Promise<void> | void
showMultiFloor?: () => Promise<void> | void
resetCamera?: () => void
setCameraPreset?: (preset: 'top' | 'oblique') => void
clearSelection?: (shouldEmit?: boolean) => void
disableAutoSwitchTemporarily?: (durationMs: number) => void
} | null>(null)
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
const indoorModelSource = computed(() => props.indoorModelSource)
// #ifdef H5
const useSgsMapRenderer = computed(() => isSgsSdkMode())
// #endif
const showIndoorRightControls = computed(() => (
props.mapType === 'indoor' && props.indoorView !== 'overview'
))
const activeFloorId = computed(() => props.normalizeFloorId(props.activeFloor))
@@ -252,8 +278,8 @@ const floorSwitcherStyle = computed(() => ({
top: props.floorTop
}))
const indoorViewToggleStyle = computed(() => ({
top: props.indoorViewToggleTop
const layerModeToggleStyle = computed(() => ({
top: props.layerModeToggleTop
}))
const modeRowStyle = computed(() => ({
@@ -264,7 +290,15 @@ const toolStackStyle = computed(() => ({
top: props.toolsTop
}))
const shouldShowIndoorPois = computed(() => Boolean(props.targetFocusRequest))
const shouldShowIndoorPois = computed(() => (
props.mapType === 'indoor'
) || Boolean(props.targetFocusRequest))
const nextLayerMode = computed<LayerDisplayMode>(() => (
props.layerMode === 'multi' ? 'single' : 'multi'
))
const layerModeActionLabel = computed(() => (
nextLayerMode.value === 'multi' ? '多层' : '单层'
))
const handleSearchTap = () => {
emit('searchTap')
@@ -276,28 +310,79 @@ const handleModeChange = (mode: '2d' | '3d') => {
const handleFloorChange = (floor: string) => {
const floorId = props.normalizeFloorId(floor)
void threeMapRef.value?.switchFloor?.(floorId)
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
void indoorRendererRef.value?.switchFloor?.(floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
emit('floorChange', floor)
}
const handleIndoorViewChange = (view: IndoorViewMode) => {
if (view === 'overview') {
void threeMapRef.value?.showOverview?.()
const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (mode === 'multi') {
void indoorRendererRef.value?.showMultiFloor?.()
emit('indoorViewChange', 'multi')
} else {
void threeMapRef.value?.switchFloor?.(activeFloorId.value)
void indoorRendererRef.value?.switchFloor?.(activeFloorId.value)
emit('indoorViewChange', 'floor')
}
emit('indoorViewChange', view)
emit('layerModeChange', mode)
}
const handleLayerModeToggle = () => {
handleLayerModeChange(nextLayerMode.value)
}
const handleToolClick = (tool: string) => {
if (props.mapType === 'indoor') {
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
if (tool === '重置') {
indoorRendererRef.value?.resetCamera?.()
} else if (tool === '俯视') {
indoorRendererRef.value?.setCameraPreset?.('top')
} else if (tool === '斜视') {
indoorRendererRef.value?.setCameraPreset?.('oblique')
} else if (tool === '清除') {
indoorRendererRef.value?.clearSelection?.()
}
}
emit('toolClick', tool)
}
const handleThreeFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
emit('floorChange', floor?.label || floorId)
emit('indoorViewChange', 'floor')
emit('layerModeChange', 'single')
}
const handlePoiClick = (poi: GuideRenderPoi) => {
emit('poiClick', poi)
}
const handleSelectionClear = () => {
emit('selectionClear')
}
const handleTargetFocus = (result: TargetPoiFocusResult) => {
emit('targetFocus', result)
}
const handleSdkFloorChange = (floorId: string) => {
const floor = props.floors.find((item) => item.id === floorId)
if (floor) {
emit('floorChange', floor.label)
}
}
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
emit('autoSwitch', event)
}
</script>
<style scoped lang="scss">
@@ -536,40 +621,31 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
z-index: 35;
}
.indoor-view-toggle {
.layer-mode-toggle {
position: absolute;
right: 16px;
width: 58px;
padding: 5px;
width: 46px;
height: 36px;
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(255, 255, 255, 0.94);
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #dde5df;
border-radius: 10px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
z-index: 36;
}
.indoor-view-item {
min-height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
color: #59645d;
.layer-mode-toggle:active {
background: #f5f7f2;
}
.indoor-view-item.active {
background: var(--museum-accent);
color: #ffffff;
box-shadow: 0 6px 12px rgba(82, 107, 91, 0.18);
}
.indoor-view-label {
.layer-mode-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
line-height: 16px;
font-weight: 500;
color: #545861;
}
.floor-item {
@@ -616,7 +692,7 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
.tool-stack {
position: absolute;
right: 18px;
left: 16px;
display: flex;
flex-direction: column;
gap: 8px;