提交室内导览交互与讲解优化
This commit is contained in:
@@ -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]
|
||||
|
||||
316
src/components/map/SgsMapRenderer.vue
Normal file
316
src/components/map/SgsMapRenderer.vue
Normal 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>
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
48
src/config/dataSource.ts
Normal file
48
src/config/dataSource.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type DataSourceMode = 'static' | 'api' | 'sdk'
|
||||
|
||||
const allowedModes = new Set<DataSourceMode>(['static', 'api', 'sdk'])
|
||||
const defaultSdkScriptUrl = '/static/sgs-map-sdk/index.global.js'
|
||||
const defaultSgsEngineUrl = '/h5-sdk'
|
||||
const defaultSdkTimeoutMs = 5000
|
||||
|
||||
const normalizeMode = (mode: string | undefined): DataSourceMode => {
|
||||
if (mode && allowedModes.has(mode as DataSourceMode)) {
|
||||
return mode as DataSourceMode
|
||||
}
|
||||
|
||||
return 'static'
|
||||
}
|
||||
|
||||
const normalizeUrl = (url: string | undefined, fallback: string) => {
|
||||
const normalized = url?.trim()
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
const normalizeTimeout = (timeoutValue: string | undefined) => {
|
||||
const timeout = Number(timeoutValue)
|
||||
return Number.isFinite(timeout) && timeout > 0 ? timeout : defaultSdkTimeoutMs
|
||||
}
|
||||
|
||||
const inferOrigin = (url: string) => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
|
||||
try {
|
||||
return new URL(url, window.location.origin).origin
|
||||
} catch {
|
||||
return window.location.origin
|
||||
}
|
||||
}
|
||||
|
||||
export const dataSourceConfig = {
|
||||
mode: normalizeMode(import.meta.env.VITE_DATA_SOURCE_MODE),
|
||||
sgsSdkScriptUrl: normalizeUrl(import.meta.env.VITE_SGS_SDK_SCRIPT_URL, defaultSdkScriptUrl),
|
||||
sgsMapEngineUrl: normalizeUrl(import.meta.env.VITE_SGS_H5_ENGINE_URL, defaultSgsEngineUrl),
|
||||
sgsSdkTargetOrigin: import.meta.env.VITE_SGS_SDK_ORIGIN?.trim() || '',
|
||||
sgsSdkTimeoutMs: normalizeTimeout(import.meta.env.VITE_SGS_SDK_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
export const isSgsSdkMode = () => dataSourceConfig.mode === 'sdk'
|
||||
|
||||
export const getSgsMapTargetOrigin = () => (
|
||||
dataSourceConfig.sgsSdkTargetOrigin || inferOrigin(dataSourceConfig.sgsMapEngineUrl)
|
||||
)
|
||||
202
src/data/mock/sgsSceneExplainData.ts
Normal file
202
src/data/mock/sgsSceneExplainData.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
export interface SgsSceneSpaceMock {
|
||||
id: number
|
||||
name: string
|
||||
spaceCategory: 'EXHIBITION' | 'EXPERIENCE' | 'SERVICE' | 'COMMERCIAL' | 'EDUCATION' | 'CIRCULATION' | 'BACK_OFFICE'
|
||||
isPublic: boolean
|
||||
area?: number
|
||||
exhibitCount?: number
|
||||
boundaryStatus?: 'DEFINED' | 'UNDEFINED'
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
poiId?: string
|
||||
}
|
||||
|
||||
export interface SgsSceneExhibitMock {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
category?: string
|
||||
era?: string
|
||||
origin?: string
|
||||
description: string
|
||||
descriptionEn?: string
|
||||
audioUrl?: string
|
||||
videoUrl?: string
|
||||
coverImageUrl?: string
|
||||
modelUrl?: string
|
||||
spaceId: number
|
||||
spaceName: string
|
||||
zoneId?: number
|
||||
zoneName?: string
|
||||
poiId?: string
|
||||
}
|
||||
|
||||
export interface SgsSceneExplainDatasetMock {
|
||||
schemaVersion: string
|
||||
sourceProject: string
|
||||
sourceFiles: string[]
|
||||
floorId: string
|
||||
floorLabel: string
|
||||
sourceFloorId: number
|
||||
spaces: SgsSceneSpaceMock[]
|
||||
exhibits: SgsSceneExhibitMock[]
|
||||
}
|
||||
|
||||
export const SGS_SCENE_EXPLAIN_DATASET: SgsSceneExplainDatasetMock = {
|
||||
schemaVersion: 'sgs-scene-explain-mock/v1',
|
||||
sourceProject: 'smart-navigation-system/sgs-frontend-map',
|
||||
sourceFiles: [
|
||||
'public/mocks/v2/space-by-floor-1.json',
|
||||
'public/mocks/v2/exhibit-list-by-space-1.json',
|
||||
'src/types/map.ts:SpatialNodeVO, ExhibitItemVO, GuideContentVO'
|
||||
],
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2',
|
||||
sourceFloorId: 1,
|
||||
spaces: [
|
||||
{
|
||||
id: 101,
|
||||
name: '地球厅',
|
||||
spaceCategory: 'EXHIBITION',
|
||||
isPublic: true,
|
||||
area: 1200,
|
||||
exhibitCount: 156,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2',
|
||||
poiId: 'poi_navpoi_0010'
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
name: '宝石矿物展区',
|
||||
spaceCategory: 'EXHIBITION',
|
||||
isPublic: true,
|
||||
area: 400,
|
||||
exhibitCount: 80,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2',
|
||||
poiId: 'modelpoi_L-2_e2ec8ce8ba'
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
name: '生命演化展区',
|
||||
spaceCategory: 'EXHIBITION',
|
||||
isPublic: true,
|
||||
area: 600,
|
||||
exhibitCount: 76,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
name: '4D影院',
|
||||
spaceCategory: 'EXPERIENCE',
|
||||
isPublic: true,
|
||||
area: 220,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
name: '中央服务台',
|
||||
spaceCategory: 'SERVICE',
|
||||
isPublic: true,
|
||||
area: 60,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
name: '文创商店',
|
||||
spaceCategory: 'COMMERCIAL',
|
||||
isPublic: true,
|
||||
area: 150,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
name: '科普工坊',
|
||||
spaceCategory: 'EDUCATION',
|
||||
isPublic: true,
|
||||
area: 180,
|
||||
boundaryStatus: 'DEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
name: '中央大厅',
|
||||
spaceCategory: 'CIRCULATION',
|
||||
isPublic: true,
|
||||
area: 800,
|
||||
boundaryStatus: 'UNDEFINED',
|
||||
floorId: 'L-2',
|
||||
floorLabel: 'B2'
|
||||
}
|
||||
],
|
||||
exhibits: [
|
||||
{
|
||||
id: 3001,
|
||||
name: '橄榄岩',
|
||||
code: 'EX-F1-001',
|
||||
category: '岩石标本',
|
||||
era: '前寒武纪',
|
||||
origin: '中国内蒙古',
|
||||
description: '橄榄岩是一种超基性深成岩,主要由橄榄石和辉石组成。它是地球上地幔的主要岩石类型,也是研究地球深部物质的重要窗口。',
|
||||
descriptionEn: "Peridotite is an ultramafic igneous rock consisting primarily of olivine and pyroxene. It is the dominant rock of the Earth's upper mantle.",
|
||||
audioUrl: '/audio/exhibits/peridotite.mp3',
|
||||
videoUrl: '/video/exhibits/peridotite.mp4',
|
||||
coverImageUrl: '/images/exhibits/peridotite.jpg',
|
||||
modelUrl: '/models/exhibits/peridotite.glb',
|
||||
spaceId: 101,
|
||||
spaceName: '地球厅',
|
||||
zoneId: 1001,
|
||||
zoneName: '宝石矿物单元',
|
||||
poiId: 'modelpoi_L-2_e2ec8ce8ba'
|
||||
},
|
||||
{
|
||||
id: 3002,
|
||||
name: '辉石',
|
||||
code: 'EX-F1-002',
|
||||
category: '矿物标本',
|
||||
era: '古生代',
|
||||
origin: '中国辽宁',
|
||||
description: '辉石是主要的造岩矿物之一,属链状硅酸盐矿物。它的晶体通常呈短柱状,具有两组近于垂直的解理,是火成岩和变质岩的常见组分。',
|
||||
descriptionEn: 'Pyroxene is a major group of rock-forming silicate minerals. They are common components of both igneous and metamorphic rocks.',
|
||||
audioUrl: '',
|
||||
videoUrl: '',
|
||||
coverImageUrl: '/images/exhibits/pyroxene.jpg',
|
||||
modelUrl: '/models/exhibits/pyroxene.glb',
|
||||
spaceId: 101,
|
||||
spaceName: '地球厅',
|
||||
zoneId: 1001,
|
||||
zoneName: '宝石矿物单元',
|
||||
poiId: 'modelpoi_L-2_e2ec8ce8ba'
|
||||
},
|
||||
{
|
||||
id: 3003,
|
||||
name: '石英',
|
||||
code: 'EX-F1-003',
|
||||
category: '矿物标本',
|
||||
era: '中生代',
|
||||
origin: '中国江苏',
|
||||
description: '石英是地球表面分布最广的矿物之一,化学成分为二氧化硅。它硬度高,化学性质稳定,晶体形态多样,是许多工业和工艺品的重要原料。',
|
||||
descriptionEn: 'Quartz is a mineral composed of silicon and oxygen atoms in a continuous framework of SiO4 silicon–oxygen tetrahedra.',
|
||||
audioUrl: '',
|
||||
videoUrl: '',
|
||||
coverImageUrl: '/images/exhibits/quartz.jpg',
|
||||
modelUrl: '',
|
||||
spaceId: 101,
|
||||
spaceName: '地球厅',
|
||||
zoneId: 1001,
|
||||
zoneName: '宝石矿物单元',
|
||||
poiId: 'modelpoi_L-2_e2ec8ce8ba'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,172 +3,109 @@ import type {
|
||||
MuseumHall
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
formatNavFloorLabel
|
||||
} from '@/data/adapters/navAssetsAdapter'
|
||||
import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider,
|
||||
type StaticNavPoiPayload
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
SGS_SCENE_EXPLAIN_DATASET,
|
||||
type SgsSceneExhibitMock,
|
||||
type SgsSceneSpaceMock
|
||||
} from '@/data/mock/sgsSceneExplainData'
|
||||
|
||||
export interface MuseumContentProvider {
|
||||
listExhibits(): Promise<MuseumExhibit[]>
|
||||
listHalls(): Promise<MuseumHall[]>
|
||||
}
|
||||
|
||||
const contentImage = '/static/exhibit-placeholder.jpg'
|
||||
const hallImage = '/static/hall-placeholder.jpg'
|
||||
const normalizeContentId = (prefix: string, value: string | number) => `${prefix}-sgs-${value}`
|
||||
|
||||
const cleanPoiName = (poi: StaticNavPoiPayload) => poi.name
|
||||
.replace(new RegExp(`^${poi.floorId}\\s*`), '')
|
||||
.replace(/^L\d+(?:\.\d+)?\s+/, '')
|
||||
.replace(/^L-\d+(?:\.\d+)?\s+/, '')
|
||||
.replace(/^L\d+(?:\.\d+)?\s+/, '')
|
||||
.trim()
|
||||
|
||||
const normalizeContentId = (prefix: string, value: string) => `${prefix}-${value}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9一-龥]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
const isTouringPoi = (poi: StaticNavPoiPayload) => (
|
||||
poi.primaryCategory === 'touring_poi'
|
||||
|| poi.categories?.some((category) => category.topCategory === 'touring_poi') === true
|
||||
)
|
||||
|
||||
const isHallPoi = (poi: StaticNavPoiPayload) => {
|
||||
const name = cleanPoiName(poi)
|
||||
return /展厅|影院|剧场|报告厅|活动室/.test(name)
|
||||
&& !/展柜|装饰/.test(name)
|
||||
const categoryLabelMap: Record<SgsSceneSpaceMock['spaceCategory'], string> = {
|
||||
EXHIBITION: '展陈空间',
|
||||
EXPERIENCE: '体验空间',
|
||||
SERVICE: '服务空间',
|
||||
COMMERCIAL: '商业空间',
|
||||
EDUCATION: '教育空间',
|
||||
CIRCULATION: '公共交通空间',
|
||||
BACK_OFFICE: '后勤空间'
|
||||
}
|
||||
|
||||
const isExhibitCandidate = (poi: StaticNavPoiPayload) => /展柜|装饰/.test(cleanPoiName(poi))
|
||||
|
||||
const hallKeyFromName = (name: string) => {
|
||||
const match = name.match(/(展厅\s*\d+\s*[^\s展柜装饰]+|临展厅\d+|[^\s]+厅|[^\s]+影院|自然剧场|学术报告厅|博物馆之友活动室)/)
|
||||
return (match?.[1] || name)
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/L\d+\s*/g, '')
|
||||
const boundaryLabelMap: Record<NonNullable<SgsSceneSpaceMock['boundaryStatus']>, string> = {
|
||||
DEFINED: '已定义空间边界',
|
||||
UNDEFINED: '空间边界待完善'
|
||||
}
|
||||
|
||||
const exhibitNameFromPoi = (poi: StaticNavPoiPayload) => cleanPoiName(poi)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^(展厅\s*\d+\s*)/, '')
|
||||
.trim()
|
||||
const publicSceneSpaces = SGS_SCENE_EXPLAIN_DATASET.spaces.filter((space) => space.isPublic)
|
||||
const publicSpaceById = new Map(publicSceneSpaces.map((space) => [space.id, space]))
|
||||
|
||||
const hallDescription = (hallName: string, floorLabel: string) => (
|
||||
`${hallName}位于${floorLabel},来源于当前导览资源包中的游览 POI。当前内容用于讲解业务结构占位,正式展陈文案、音频和图文媒体待 CMS/API 接入后替换。`
|
||||
)
|
||||
const formatArea = (area?: number) => (typeof area === 'number' ? `${area}㎡` : undefined)
|
||||
|
||||
const exhibitDescription = (exhibitName: string, hallName: string, floorLabel: string) => (
|
||||
`${exhibitName}是${hallName}的讲解点,位置关联到${floorLabel}的导览 POI。当前仅展示内容结构和位置关联,不代表正式展陈文案或可播放音频。`
|
||||
)
|
||||
const hallDescription = (space: SgsSceneSpaceMock) => {
|
||||
const category = categoryLabelMap[space.spaceCategory]
|
||||
const area = formatArea(space.area)
|
||||
const boundary = space.boundaryStatus ? boundaryLabelMap[space.boundaryStatus] : undefined
|
||||
const facts = [space.floorLabel, category, area, boundary].filter(Boolean).join(' · ')
|
||||
|
||||
return `${space.name}来自 SGS 前端地图项目“场景设置”的空间管理数据。${facts ? `当前空间信息:${facts}。` : ''}讲解业务据此展示展厅/展区内容,并通过稳定空间、展品与 POI ID 关联到位置预览。`
|
||||
}
|
||||
|
||||
const exhibitDescription = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => {
|
||||
const facts = [
|
||||
exhibit.category,
|
||||
exhibit.era,
|
||||
exhibit.origin,
|
||||
exhibit.zoneName
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
return [
|
||||
exhibit.description,
|
||||
facts ? `展陈信息:${facts}。` : '',
|
||||
hall ? `所属空间:${hall.name}(${hall.floorLabel})。` : ''
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
const tagsForExhibit = (exhibit: SgsSceneExhibitMock, hall: SgsSceneSpaceMock | undefined) => [
|
||||
'SGS场景设置',
|
||||
exhibit.category,
|
||||
exhibit.zoneName,
|
||||
exhibit.audioUrl ? '源数据含音频地址' : '图文讲解',
|
||||
hall ? categoryLabelMap[hall.spaceCategory] : undefined
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
const toMuseumHall = (space: SgsSceneSpaceMock): MuseumHall => ({
|
||||
id: normalizeContentId('hall', space.id),
|
||||
name: space.name,
|
||||
floorId: space.floorId,
|
||||
floorLabel: space.floorLabel,
|
||||
description: hallDescription(space),
|
||||
image: '',
|
||||
exhibitCount: SGS_SCENE_EXPLAIN_DATASET.exhibits.filter((exhibit) => exhibit.spaceId === space.id).length || space.exhibitCount || 0,
|
||||
area: formatArea(space.area),
|
||||
poiId: space.poiId
|
||||
})
|
||||
|
||||
const toMuseumExhibit = (exhibit: SgsSceneExhibitMock): MuseumExhibit => {
|
||||
const hall = publicSpaceById.get(exhibit.spaceId)
|
||||
|
||||
return {
|
||||
id: normalizeContentId('exhibit', exhibit.id),
|
||||
name: exhibit.name,
|
||||
hallId: hall ? normalizeContentId('hall', hall.id) : undefined,
|
||||
hallName: hall?.name || exhibit.spaceName,
|
||||
floorId: hall?.floorId || SGS_SCENE_EXPLAIN_DATASET.floorId,
|
||||
floorLabel: hall?.floorLabel || SGS_SCENE_EXPLAIN_DATASET.floorLabel,
|
||||
image: '',
|
||||
description: exhibitDescription(exhibit, hall),
|
||||
poiId: exhibit.poiId || hall?.poiId,
|
||||
year: exhibit.era,
|
||||
material: exhibit.origin,
|
||||
size: exhibit.code,
|
||||
tags: tagsForExhibit(exhibit, hall)
|
||||
}
|
||||
}
|
||||
|
||||
export class StaticMuseumContentProvider implements MuseumContentProvider {
|
||||
private hallCache: MuseumHall[] | null = null
|
||||
private exhibitCache: MuseumExhibit[] | null = null
|
||||
private loading: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly navAssets: StaticNavAssetsProvider = defaultStaticNavAssetsProvider
|
||||
) {}
|
||||
|
||||
async listExhibits() {
|
||||
await this.ensureLoaded()
|
||||
return this.exhibitCache || []
|
||||
return SGS_SCENE_EXPLAIN_DATASET.exhibits.map(toMuseumExhibit)
|
||||
}
|
||||
|
||||
async listHalls() {
|
||||
await this.ensureLoaded()
|
||||
return this.hallCache || []
|
||||
}
|
||||
|
||||
private async ensureLoaded() {
|
||||
if (this.hallCache && this.exhibitCache) return
|
||||
if (this.loading) return this.loading
|
||||
|
||||
this.loading = this.navAssets.loadPoiIndex()
|
||||
.then((pois) => {
|
||||
const touringPois = pois.filter(isTouringPoi)
|
||||
const hallPois = touringPois.filter(isHallPoi)
|
||||
const hallByKey = new Map<string, MuseumHall>()
|
||||
|
||||
hallPois.forEach((poi) => {
|
||||
const hallName = cleanPoiName(poi)
|
||||
const key = `${poi.floorId}:${hallKeyFromName(hallName)}`
|
||||
const floorLabel = formatNavFloorLabel(poi.floorId)
|
||||
hallByKey.set(key, {
|
||||
id: normalizeContentId('hall', poi.id),
|
||||
name: hallName,
|
||||
floorId: poi.floorId,
|
||||
floorLabel,
|
||||
description: hallDescription(hallName, floorLabel),
|
||||
image: hallImage,
|
||||
exhibitCount: 0,
|
||||
area: '以导览资源包 POI 为准',
|
||||
poiId: poi.id
|
||||
})
|
||||
})
|
||||
|
||||
const fallbackHallFor = (poi: StaticNavPoiPayload) => {
|
||||
const poiName = cleanPoiName(poi)
|
||||
const key = `${poi.floorId}:${hallKeyFromName(poiName)}`
|
||||
const floorLabel = formatNavFloorLabel(poi.floorId)
|
||||
const hallName = hallKeyFromName(poiName)
|
||||
const existing = hallByKey.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const hall: MuseumHall = {
|
||||
id: normalizeContentId('hall', `${poi.floorId}-${hallName}`),
|
||||
name: hallName,
|
||||
floorId: poi.floorId,
|
||||
floorLabel,
|
||||
description: hallDescription(hallName, floorLabel),
|
||||
image: hallImage,
|
||||
exhibitCount: 0,
|
||||
area: '以导览资源包 POI 为准'
|
||||
}
|
||||
hallByKey.set(key, hall)
|
||||
return hall
|
||||
}
|
||||
|
||||
const exhibits = touringPois
|
||||
.filter(isExhibitCandidate)
|
||||
.map((poi): MuseumExhibit => {
|
||||
const hall = fallbackHallFor(poi)
|
||||
const exhibitName = exhibitNameFromPoi(poi)
|
||||
return {
|
||||
id: normalizeContentId('exhibit', poi.id),
|
||||
name: exhibitName,
|
||||
hallId: hall.id,
|
||||
hallName: hall.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: formatNavFloorLabel(poi.floorId),
|
||||
image: contentImage,
|
||||
description: exhibitDescription(exhibitName, hall.name, formatNavFloorLabel(poi.floorId)),
|
||||
poiId: poi.id,
|
||||
tags: ['资源包POI', poi.primaryCategoryZh, poi.iconType || '展项'].filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
const exhibitCountByHallId = exhibits.reduce((result, exhibit) => {
|
||||
if (exhibit.hallId) {
|
||||
result.set(exhibit.hallId, (result.get(exhibit.hallId) || 0) + 1)
|
||||
}
|
||||
return result
|
||||
}, new Map<string, number>())
|
||||
|
||||
this.hallCache = Array.from(hallByKey.values()).map((hall) => ({
|
||||
...hall,
|
||||
exhibitCount: exhibitCountByHallId.get(hall.id) || hall.exhibitCount || 0
|
||||
}))
|
||||
this.exhibitCache = exhibits
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = null
|
||||
})
|
||||
|
||||
return this.loading
|
||||
return publicSceneSpaces.map(toMuseumHall)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,3 +24,17 @@ export interface GuideModelSource {
|
||||
loadPackage(): Promise<GuideModelRenderPackage>
|
||||
loadFloorPois(floorId: string): Promise<GuideRenderPoi[]>
|
||||
}
|
||||
|
||||
export interface AutoSwitchConfig {
|
||||
enabled: boolean
|
||||
thresholdLowFactor: number
|
||||
thresholdHighFactor: number
|
||||
cooldownMs: number
|
||||
}
|
||||
|
||||
export interface AutoSwitchEvent {
|
||||
from: 'overview' | 'floor'
|
||||
to: 'overview' | 'floor'
|
||||
trigger: 'zoom-in' | 'zoom-out'
|
||||
distance: number
|
||||
}
|
||||
|
||||
13
src/env.d.ts
vendored
Normal file
13
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DATA_SOURCE_MODE?: 'static' | 'api' | 'sdk'
|
||||
readonly VITE_SGS_SDK_SCRIPT_URL?: string
|
||||
readonly VITE_SGS_H5_ENGINE_URL?: string
|
||||
readonly VITE_SGS_SDK_ORIGIN?: string
|
||||
readonly VITE_SGS_SDK_TIMEOUT_MS?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
</view>
|
||||
|
||||
<view v-if="exhibit.audio.status !== 'playable'" class="audio-note">
|
||||
<text class="audio-note-title">音频待开放</text>
|
||||
<text class="audio-note-title">音频待同步</text>
|
||||
<text class="audio-note-desc">
|
||||
{{ exhibit.audio.unavailableReason || '当前仅提供图文讲解,正式音频接入后将显示播放入口。' }}
|
||||
</text>
|
||||
@@ -154,6 +154,9 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import {
|
||||
guideUseCase
|
||||
} from '@/usecases/guideUseCase'
|
||||
import {
|
||||
toExplainDetailPageViewModel,
|
||||
type ExplainContentType,
|
||||
@@ -171,11 +174,11 @@ const defaultDetail: ExplainDetailPageViewModel = {
|
||||
subtitle: '位置待补充',
|
||||
contentType: 'exhibit',
|
||||
coverImages: ['/static/exhibit-placeholder.jpg'],
|
||||
summary: '该讲解内容待正式内容库补充。',
|
||||
body: '该讲解内容待正式内容库补充。',
|
||||
summary: '该讲解内容待 SGS 场景设置或 CMS 内容补充。',
|
||||
body: '该讲解内容待 SGS 场景设置或 CMS 内容补充。',
|
||||
audio: {
|
||||
status: 'unavailable',
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
unavailableReason: 'SGS 场景设置音频资源尚未同步到当前 H5 项目'
|
||||
},
|
||||
chapters: [],
|
||||
relatedItems: []
|
||||
@@ -194,14 +197,14 @@ const audioStatusText = computed(() => {
|
||||
if (exhibit.value.audio.status === 'none') {
|
||||
return '图文讲解'
|
||||
}
|
||||
return '音频待开放'
|
||||
return exhibit.value.audio.unavailableReason?.includes('包含音频地址') ? '音频待同步' : '图文讲解'
|
||||
})
|
||||
|
||||
const contentTypeText = computed(() => {
|
||||
const map: Record<ExplainContentType, string> = {
|
||||
hall: '展厅讲解',
|
||||
hall: '空间讲解',
|
||||
zone: '展区讲解',
|
||||
exhibit: '讲解点',
|
||||
exhibit: '展品讲解',
|
||||
theme: '主题讲解'
|
||||
}
|
||||
return map[exhibit.value.contentType]
|
||||
@@ -243,7 +246,7 @@ const handlePlayAudio = async () => {
|
||||
isPlaying.value = !isPlaying.value
|
||||
}
|
||||
|
||||
const handleNavigate = () => {
|
||||
const handleNavigate = async () => {
|
||||
if (!exhibit.value.location?.poiId) {
|
||||
uni.showToast({
|
||||
title: '该讲解暂无三维位置数据',
|
||||
@@ -252,6 +255,15 @@ const handleNavigate = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPoi = await guideUseCase.getPoiById(exhibit.value.location.poiId)
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该讲解位置尚未映射到当前三维导览资源',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.value.location.poiId)}&target=${encodeURIComponent(exhibit.value.title)}&state=preview`
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<view class="stats-row">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.exhibitCount }}</text>
|
||||
<text class="stat-label">讲解点</text>
|
||||
<text class="stat-label">讲解内容</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ hall.floorLabel }}</text>
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<!-- 讲解列表 -->
|
||||
<view class="exhibits-section">
|
||||
<text class="section-title">展厅讲解内容</text>
|
||||
<text class="section-title">空间讲解内容</text>
|
||||
<view class="exhibits-grid">
|
||||
<ExhibitCard
|
||||
v-for="exhibit in exhibits"
|
||||
@@ -63,6 +63,9 @@ import ExhibitCard from '@/components/content/ExhibitCard.vue'
|
||||
import {
|
||||
explainUseCase
|
||||
} from '@/usecases/explainUseCase'
|
||||
import {
|
||||
guideUseCase
|
||||
} from '@/usecases/guideUseCase'
|
||||
import {
|
||||
toExplainExhibitViewModel,
|
||||
toHallDetailViewModel,
|
||||
@@ -81,7 +84,7 @@ const hall = ref<HallDetailViewModel>({
|
||||
id: '',
|
||||
name: '展厅内容',
|
||||
floorLabel: '楼层待补充',
|
||||
description: '该展厅介绍待正式内容库补充。',
|
||||
description: '该空间介绍待 SGS 场景设置或 CMS 内容补充。',
|
||||
image: '/static/hall-placeholder.jpg',
|
||||
exhibitCount: 0,
|
||||
area: '待补充'
|
||||
@@ -111,7 +114,7 @@ const handleExhibitClick = (exhibit: any) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleNavigate = () => {
|
||||
const handleNavigate = async () => {
|
||||
if (!hall.value.poiId) {
|
||||
uni.showToast({
|
||||
title: '该展厅暂无三维位置数据',
|
||||
@@ -120,6 +123,15 @@ const handleNavigate = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPoi = await guideUseCase.getPoiById(hall.value.poiId)
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该空间位置尚未映射到当前三维导览资源',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(hall.value.poiId)}&target=${encodeURIComponent(hall.value.name)}&state=preview`
|
||||
})
|
||||
|
||||
@@ -21,16 +21,21 @@
|
||||
:normalize-floor-id="normalizeGuideFloorId"
|
||||
indoor-initial-view="overview"
|
||||
:indoor-view="indoorView"
|
||||
:layer-mode="indoorLayerMode"
|
||||
:active-floor="activeGuideFloor"
|
||||
indoor-view-toggle-top="154px"
|
||||
layer-mode-toggle-top="154px"
|
||||
floor-top="244px"
|
||||
:show-indoor-view-toggle="is3DMode"
|
||||
:show-floor="is3DMode && indoorView === 'floor'"
|
||||
:tools="[]"
|
||||
:show-layer-mode-toggle="is3DMode"
|
||||
:show-floor="is3DMode"
|
||||
:tools="guide3DTools"
|
||||
@search-tap="handleGuideSearchTap"
|
||||
@mode-change="handleModeChange"
|
||||
@floor-change="handleFloorChange"
|
||||
@indoor-view-change="handleIndoorViewChange"
|
||||
@poi-click="handleGuidePoiClick"
|
||||
@selection-clear="handleGuideSelectionClear"
|
||||
@tool-click="handleGuideToolClick"
|
||||
@auto-switch="handleAutoSwitch"
|
||||
>
|
||||
<template #overlay>
|
||||
<view v-if="guideOutdoorState === 'entrance' && !is3DMode" class="entrance-tip">
|
||||
@@ -135,15 +140,22 @@ import {
|
||||
import {
|
||||
toExplainCardViewModel
|
||||
} from '@/view-models/explainViewModels'
|
||||
import type {
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import type {
|
||||
MuseumFloor
|
||||
} from '@/domain/museum'
|
||||
|
||||
type GuideIndoorView = 'overview' | 'floor' | 'multi'
|
||||
type LayerDisplayMode = 'single' | 'multi'
|
||||
|
||||
// 3D 模式状态
|
||||
const is3DMode = ref(false)
|
||||
const guideOutdoorState = ref<'home' | 'entrance'>('home')
|
||||
const indoorView = ref<'overview' | 'floor'>('overview')
|
||||
const indoorView = ref<GuideIndoorView>('overview')
|
||||
const activeGuideFloor = ref('1F')
|
||||
const selectedGuidePoi = ref<GuideRenderPoi | null>(null)
|
||||
|
||||
// 状态
|
||||
const searchKeyword = ref('')
|
||||
@@ -157,6 +169,16 @@ const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl()
|
||||
const indoorModelSource = guideUseCase.getModelSource()
|
||||
const guideFloors = ref<MuseumFloor[]>([])
|
||||
const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId)
|
||||
const indoorLayerMode = computed<LayerDisplayMode>(() => (
|
||||
indoorView.value === 'multi' ? 'multi' : 'single'
|
||||
))
|
||||
const guide3DTools = computed(() => {
|
||||
if (!is3DMode.value) return []
|
||||
|
||||
return selectedGuidePoi.value
|
||||
? ['重置', '俯视', '斜视', '清除']
|
||||
: ['重置', '俯视', '斜视']
|
||||
})
|
||||
|
||||
const shortcutItems = [
|
||||
{ id: 'toilet', name: '卫生间', abbr: '卫' },
|
||||
@@ -351,11 +373,40 @@ const handleCollectMarker = (marker: MarkerDetail) => {
|
||||
const handleFloorChange = (floor: string) => {
|
||||
activeGuideFloor.value = floor
|
||||
indoorView.value = 'floor'
|
||||
selectedGuidePoi.value = null
|
||||
console.log('切换楼层:', floor)
|
||||
}
|
||||
|
||||
const handleIndoorViewChange = (view: 'overview' | 'floor') => {
|
||||
const handleIndoorViewChange = (view: GuideIndoorView) => {
|
||||
indoorView.value = view
|
||||
if (view !== 'floor') {
|
||||
selectedGuidePoi.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
|
||||
console.log('自动切换视图:', event)
|
||||
indoorView.value = event.to
|
||||
if (event.to !== 'floor') {
|
||||
selectedGuidePoi.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleGuidePoiClick = (poi: GuideRenderPoi) => {
|
||||
selectedGuidePoi.value = poi
|
||||
indoorView.value = 'floor'
|
||||
const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId)
|
||||
activeGuideFloor.value = matchedFloor?.label || poi.floorId
|
||||
}
|
||||
|
||||
const handleGuideSelectionClear = () => {
|
||||
selectedGuidePoi.value = null
|
||||
}
|
||||
|
||||
const handleGuideToolClick = (tool: string) => {
|
||||
if (tool === '清除') {
|
||||
selectedGuidePoi.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 进入 3D 室内模式
|
||||
@@ -364,6 +415,7 @@ const handleEnter3DMode = () => {
|
||||
indoorView.value = 'overview'
|
||||
is3DMode.value = true
|
||||
guideOutdoorState.value = 'home'
|
||||
selectedGuidePoi.value = null
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: '2d' | '3d') => {
|
||||
@@ -372,6 +424,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
if (mode === '2d') {
|
||||
indoorView.value = 'overview'
|
||||
guideOutdoorState.value = 'home'
|
||||
selectedGuidePoi.value = null
|
||||
} else {
|
||||
indoorView.value = 'overview'
|
||||
}
|
||||
@@ -379,7 +432,9 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
|
||||
const guideStatusLabel = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return indoorView.value === 'overview' ? '室内全馆' : '室内楼层'
|
||||
if (indoorView.value === 'overview') return '建筑外观'
|
||||
if (indoorView.value === 'multi') return '室内多层'
|
||||
return '室内单层'
|
||||
}
|
||||
|
||||
if (guideOutdoorState.value === 'entrance') {
|
||||
@@ -391,16 +446,26 @@ const guideStatusLabel = computed(() => {
|
||||
|
||||
const guideTaskTitle = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return indoorView.value === 'overview' ? '查看完整建筑' : '查看楼层位置'
|
||||
if (selectedGuidePoi.value) return selectedGuidePoi.value.name
|
||||
if (indoorView.value === 'overview') return '查看建筑外观'
|
||||
if (indoorView.value === 'multi') return '查看多层关系'
|
||||
return '查看单层位置'
|
||||
}
|
||||
return '从室外进入馆内'
|
||||
})
|
||||
|
||||
const guideTaskSubtitle = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
return indoorView.value === 'overview'
|
||||
? '切换到楼层后,可查看 POI 与三维位置预览'
|
||||
: '选择目标设施后,可查看楼层与三维位置预览'
|
||||
if (selectedGuidePoi.value) {
|
||||
return `${activeGuideFloor.value} · ${selectedGuidePoi.value.primaryCategoryZh || '位置预览'} · 已高亮`
|
||||
}
|
||||
if (indoorView.value === 'overview') {
|
||||
return '放大模型可自动进入当前楼层'
|
||||
}
|
||||
if (indoorView.value === 'multi') {
|
||||
return '用于比较楼层关系,选择具体楼层可回到单层'
|
||||
}
|
||||
return '缩小模型可自动回到建筑外观,也可切换多层展示'
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -408,6 +473,7 @@ const guideTaskSubtitle = computed(() => {
|
||||
|
||||
const guidePrimaryAction = computed(() => {
|
||||
if (is3DMode.value) {
|
||||
if (selectedGuidePoi.value) return '查看相关内容'
|
||||
return '选择目标地点'
|
||||
}
|
||||
|
||||
@@ -416,6 +482,13 @@ const guidePrimaryAction = computed(() => {
|
||||
|
||||
const handleGuidePrimaryAction = () => {
|
||||
if (is3DMode.value) {
|
||||
if (selectedGuidePoi.value) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/search/index?keyword=${encodeURIComponent(selectedGuidePoi.value.name)}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: '/pages/search/index'
|
||||
})
|
||||
@@ -507,7 +580,7 @@ const handleExplainFeaturedClick = async (exhibit: Exhibit) => {
|
||||
await handleAudioClick(exhibit)
|
||||
}
|
||||
|
||||
const handleExplainLocationClick = (exhibit: Exhibit) => {
|
||||
const handleExplainLocationClick = async (exhibit: Exhibit) => {
|
||||
if (!exhibit.poiId) {
|
||||
uni.showToast({
|
||||
title: '该讲解暂无三维位置数据',
|
||||
@@ -516,6 +589,15 @@ const handleExplainLocationClick = (exhibit: Exhibit) => {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedPoi = await guideUseCase.getPoiById(exhibit.poiId)
|
||||
if (!matchedPoi) {
|
||||
uni.showToast({
|
||||
title: '该讲解位置尚未映射到当前三维导览资源',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: `/pages/route/detail?facilityId=${encodeURIComponent(exhibit.poiId)}&target=${encodeURIComponent(exhibit.title)}&state=preview`
|
||||
})
|
||||
|
||||
@@ -89,7 +89,7 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '讲解点'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
desc: `${exhibit.hallName || '展品讲解'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
hallId: exhibit.hallId,
|
||||
@@ -106,7 +106,7 @@ export class DefaultExplainRepository implements ExplainRepository {
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解点`.trim(),
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解内容`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
import type {
|
||||
MediaAsset
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
SGS_SCENE_EXPLAIN_DATASET
|
||||
} from '@/data/mock/sgsSceneExplainData'
|
||||
|
||||
export interface MediaRepository {
|
||||
getMediaById(id: string): Promise<MediaAsset | null>
|
||||
getMediaForExplainTrack(trackId: string): Promise<MediaAsset | null>
|
||||
}
|
||||
|
||||
const unavailableAudio = (id: string): MediaAsset => ({
|
||||
const normalizeExhibitIdFromTrack = (trackId: string) => trackId
|
||||
.replace(/^track-/, '')
|
||||
.replace(/^exhibit-sgs-/, '')
|
||||
|
||||
const unavailableAudio = (id: string, reason = 'SGS 场景设置音频资源尚未同步到当前 H5 项目'): MediaAsset => ({
|
||||
id,
|
||||
type: 'audio',
|
||||
available: false,
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
unavailableReason: reason
|
||||
})
|
||||
|
||||
const findSourceAudioById = (id: string) => {
|
||||
const normalizedId = id.replace(/^media-/, '')
|
||||
const exhibitSourceId = normalizeExhibitIdFromTrack(normalizedId)
|
||||
return SGS_SCENE_EXPLAIN_DATASET.exhibits.find((exhibit) => String(exhibit.id) === exhibitSourceId)
|
||||
}
|
||||
|
||||
export class DefaultMediaRepository implements MediaRepository {
|
||||
async getMediaById(id: string) {
|
||||
return unavailableAudio(id)
|
||||
const source = findSourceAudioById(id)
|
||||
|
||||
if (source?.audioUrl) {
|
||||
return unavailableAudio(id, `SGS 场景设置源数据包含音频地址 ${source.audioUrl},但该静态音频尚未同步到当前 H5 项目。`)
|
||||
}
|
||||
|
||||
return unavailableAudio(id, '该讲解在 SGS 场景设置源数据中暂无音频地址')
|
||||
}
|
||||
|
||||
async getMediaForExplainTrack(trackId: string) {
|
||||
return unavailableAudio(`media-${trackId}`)
|
||||
return this.getMediaById(`media-${trackId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
||||
.map<SearchIndexItem>((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: `${exhibit.hallName || '讲解点'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
desc: `${exhibit.hallName || '展品讲解'} · ${exhibit.floorLabel || ''}`.trim(),
|
||||
type: 'exhibit',
|
||||
exhibitId: exhibit.id,
|
||||
poiId: exhibit.poiId,
|
||||
@@ -97,7 +97,7 @@ export class DefaultMuseumContentRepository implements MuseumContentRepository {
|
||||
.map<SearchIndexItem>((hall) => ({
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解点`.trim(),
|
||||
desc: `${hall.floorLabel || ''} · ${hall.exhibitCount || 0}个讲解内容`.trim(),
|
||||
type: 'hall',
|
||||
hallId: hall.id,
|
||||
poiId: hall.poiId,
|
||||
|
||||
55
src/services/sgs/SgsMapEventAdapter.ts
Normal file
55
src/services/sgs/SgsMapEventAdapter.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
SgsMapEvents
|
||||
} from '@/types/sgs-map-sdk'
|
||||
|
||||
export interface SgsTargetFocusRequest {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
name?: string
|
||||
floorId: string
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
export interface SgsTargetFocusResult {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
floorId: string
|
||||
status: 'focused' | 'missing' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const toNumericFloorId = (floorId: string | number | undefined) => {
|
||||
if (typeof floorId === 'number') return floorId
|
||||
if (!floorId) return 1
|
||||
|
||||
const normalized = floorId.toString().trim().toUpperCase()
|
||||
const numeric = Number(normalized.replace(/^L/, '').replace(/F$/, ''))
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : 1
|
||||
}
|
||||
|
||||
export const toAppFloorId = (floorId: string | number | undefined) => `L${toNumericFloorId(floorId)}`
|
||||
|
||||
export const toFloorLabel = (floorId: string | number | undefined) => `${toNumericFloorId(floorId)}F`
|
||||
|
||||
export const toFocusNodeName = (request: SgsTargetFocusRequest) => (
|
||||
request.name || request.poiId
|
||||
)
|
||||
|
||||
export const toFocusedResult = (request: SgsTargetFocusRequest): SgsTargetFocusResult => ({
|
||||
requestId: request.requestId,
|
||||
poiId: request.poiId,
|
||||
floorId: request.floorId,
|
||||
status: 'focused'
|
||||
})
|
||||
|
||||
export const toErrorResult = (request: SgsTargetFocusRequest, error: unknown): SgsTargetFocusResult => ({
|
||||
requestId: request.requestId,
|
||||
poiId: request.poiId,
|
||||
floorId: request.floorId,
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : 'SGS Map SDK 聚焦失败'
|
||||
})
|
||||
|
||||
export const toPoiId = (poi: SgsMapEvents['poiClick']) => String(poi.id ?? poi.poiId ?? '')
|
||||
60
src/services/sgs/SgsMapScriptLoader.ts
Normal file
60
src/services/sgs/SgsMapScriptLoader.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
SgsMapSDKConstructor
|
||||
} from '@/types/sgs-map-sdk'
|
||||
|
||||
let loadingScriptUrl = ''
|
||||
let loadRequest: Promise<SgsMapSDKConstructor> | null = null
|
||||
|
||||
const findExistingScript = (scriptUrl: string) => (
|
||||
Array.from(document.scripts).find((script) => script.src.endsWith(scriptUrl) || script.getAttribute('src') === scriptUrl)
|
||||
)
|
||||
|
||||
export const loadSgsMapScript = (scriptUrl: string): Promise<SgsMapSDKConstructor> => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return Promise.reject(new Error('SGS Map SDK 只能在 H5 浏览器环境中加载'))
|
||||
}
|
||||
|
||||
if (window.SGSMapSDK) {
|
||||
return Promise.resolve(window.SGSMapSDK)
|
||||
}
|
||||
|
||||
if (loadRequest && loadingScriptUrl === scriptUrl) {
|
||||
return loadRequest
|
||||
}
|
||||
|
||||
loadingScriptUrl = scriptUrl
|
||||
const request = new Promise<SgsMapSDKConstructor>((resolve, reject) => {
|
||||
const existingScript = findExistingScript(scriptUrl)
|
||||
const script = existingScript || document.createElement('script')
|
||||
|
||||
const resolveLoadedSdk = () => {
|
||||
if (!window.SGSMapSDK) {
|
||||
reject(new Error('SGS Map SDK 脚本已加载,但未发现 window.SGSMapSDK'))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(window.SGSMapSDK)
|
||||
}
|
||||
|
||||
script.addEventListener('load', resolveLoadedSdk, { once: true })
|
||||
script.addEventListener('error', () => {
|
||||
reject(new Error(`SGS Map SDK 脚本加载失败: ${scriptUrl}`))
|
||||
}, { once: true })
|
||||
|
||||
if (!existingScript) {
|
||||
script.src = scriptUrl
|
||||
script.async = true
|
||||
document.head.appendChild(script)
|
||||
return
|
||||
}
|
||||
|
||||
if (window.SGSMapSDK) {
|
||||
resolveLoadedSdk()
|
||||
}
|
||||
}).finally(() => {
|
||||
loadRequest = null
|
||||
})
|
||||
|
||||
loadRequest = request
|
||||
return request
|
||||
}
|
||||
164
src/services/sgs/SgsMapService.ts
Normal file
164
src/services/sgs/SgsMapService.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
SgsMapEvents,
|
||||
SgsMapSDKInstance,
|
||||
SgsMarkerConfig,
|
||||
SgsMapNode,
|
||||
SgsRouteResult
|
||||
} from '@/types/sgs-map-sdk'
|
||||
import {
|
||||
loadSgsMapScript
|
||||
} from './SgsMapScriptLoader'
|
||||
|
||||
interface SgsMapServiceOptions {
|
||||
container: HTMLElement
|
||||
scriptUrl: string
|
||||
sdkUrl: string
|
||||
targetOrigin: string
|
||||
floorId: number
|
||||
timeout: number
|
||||
}
|
||||
|
||||
type SgsMapListener<K extends keyof SgsMapEvents> = (payload: SgsMapEvents[K]) => void
|
||||
|
||||
export class SgsMapService {
|
||||
private sdk: SgsMapSDKInstance | null = null
|
||||
private readyPromise: Promise<SgsMapEvents['ready']> | null = null
|
||||
private resolveReady: ((payload: SgsMapEvents['ready']) => void) | null = null
|
||||
private rejectReady: ((error: Error) => void) | null = null
|
||||
private listeners: Array<() => void> = []
|
||||
private externalListeners: Array<{
|
||||
event: keyof SgsMapEvents
|
||||
listener: (payload: SgsMapEvents[keyof SgsMapEvents]) => void
|
||||
}> = []
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: SgsMapServiceOptions) {}
|
||||
|
||||
async init() {
|
||||
if (this.readyPromise) return this.readyPromise
|
||||
|
||||
this.readyPromise = new Promise<SgsMapEvents['ready']>((resolve, reject) => {
|
||||
this.resolveReady = resolve
|
||||
this.rejectReady = reject
|
||||
})
|
||||
|
||||
loadSgsMapScript(this.options.scriptUrl)
|
||||
.then((SDKConstructor) => {
|
||||
if (this.disposed) {
|
||||
this.rejectReady?.(new Error('SGS Map SDK 初始化已取消'))
|
||||
return
|
||||
}
|
||||
|
||||
this.sdk = new SDKConstructor({
|
||||
container: this.options.container,
|
||||
sdkUrl: this.options.sdkUrl,
|
||||
targetOrigin: this.options.targetOrigin,
|
||||
floorId: this.options.floorId,
|
||||
timeout: this.options.timeout
|
||||
})
|
||||
|
||||
this.externalListeners.forEach(({ event, listener }) => {
|
||||
this.registerSdkListener(event, listener)
|
||||
})
|
||||
|
||||
this.registerSdkListener('ready', (payload) => {
|
||||
this.resolveReady?.(payload)
|
||||
})
|
||||
|
||||
this.registerSdkListener('loadError', (payload) => {
|
||||
this.rejectReady?.(new Error(payload.detail || 'SGS Map SDK 地图基座加载失败'))
|
||||
})
|
||||
|
||||
this.registerSdkListener('error', (payload) => {
|
||||
if (!this.sdk?.getIsReady()) {
|
||||
this.rejectReady?.(new Error(payload.message || payload.code || 'SGS Map SDK 初始化失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.rejectReady?.(error instanceof Error ? error : new Error('SGS Map SDK 初始化失败'))
|
||||
})
|
||||
|
||||
return this.readyPromise
|
||||
}
|
||||
|
||||
on<K extends keyof SgsMapEvents>(event: K, listener: SgsMapListener<K>) {
|
||||
this.externalListeners.push({
|
||||
event,
|
||||
listener: listener as (payload: SgsMapEvents[keyof SgsMapEvents]) => void
|
||||
})
|
||||
|
||||
if (this.sdk) {
|
||||
this.registerSdkListener(event, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private registerSdkListener<K extends keyof SgsMapEvents>(event: K, listener: SgsMapListener<K>) {
|
||||
if (!this.sdk) return
|
||||
|
||||
this.sdk.on(event, listener)
|
||||
this.listeners.push(() => {
|
||||
this.sdk?.off(event, listener)
|
||||
})
|
||||
}
|
||||
|
||||
private async getReadySdk() {
|
||||
if (!this.sdk) {
|
||||
await this.init()
|
||||
}
|
||||
|
||||
await this.readyPromise
|
||||
|
||||
if (!this.sdk || this.disposed) {
|
||||
throw new Error('SGS Map SDK 已销毁')
|
||||
}
|
||||
|
||||
return this.sdk
|
||||
}
|
||||
|
||||
async changeFloor(floorId: number) {
|
||||
const sdk = await this.getReadySdk()
|
||||
return sdk.changeFloor(floorId, this.options.timeout)
|
||||
}
|
||||
|
||||
async focusTo(node: SgsMapNode | string) {
|
||||
const sdk = await this.getReadySdk()
|
||||
sdk.focusTo(node)
|
||||
}
|
||||
|
||||
async addMarker(config: SgsMarkerConfig) {
|
||||
const sdk = await this.getReadySdk()
|
||||
sdk.addMarker(config)
|
||||
}
|
||||
|
||||
async clearMarkers() {
|
||||
const sdk = await this.getReadySdk()
|
||||
sdk.clearMarkers()
|
||||
}
|
||||
|
||||
async resetView() {
|
||||
const sdk = await this.getReadySdk()
|
||||
sdk.resetView()
|
||||
}
|
||||
|
||||
async setViewMode(mode: '2d' | '3d') {
|
||||
const sdk = await this.getReadySdk()
|
||||
sdk.setViewMode(mode)
|
||||
}
|
||||
|
||||
async planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: unknown): Promise<SgsRouteResult> {
|
||||
const sdk = await this.getReadySdk()
|
||||
return sdk.planRoute(from, to, options, this.options.timeout)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disposed = true
|
||||
this.listeners.splice(0).forEach((cleanup) => cleanup())
|
||||
this.sdk?.destroy()
|
||||
this.sdk = null
|
||||
this.rejectReady?.(new Error('SGS Map SDK 已销毁'))
|
||||
this.resolveReady = null
|
||||
this.rejectReady = null
|
||||
this.readyPromise = null
|
||||
}
|
||||
}
|
||||
88
src/types/sgs-map-sdk.ts
Normal file
88
src/types/sgs-map-sdk.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type SgsMapNode =
|
||||
| { type: 'coord'; floorId: number; x: number; z: number; y?: number }
|
||||
| { type: 'poiId'; value: number }
|
||||
| { type: 'nodeName'; value: string }
|
||||
|
||||
export interface SgsSnapDetail {
|
||||
original: { x: number; z: number }
|
||||
nearest: { x: number; z: number }
|
||||
distance: number
|
||||
}
|
||||
|
||||
export interface SgsRouteResult {
|
||||
success: boolean
|
||||
distance: number
|
||||
duration: number
|
||||
pathPoints: Array<{ x: number; y: number; z: number }>
|
||||
startSnap: SgsSnapDetail
|
||||
endSnap: SgsSnapDetail
|
||||
computeTimeMs: number
|
||||
polyCount: number
|
||||
warnings: string[]
|
||||
errorCode?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SgsMapSDKOptions {
|
||||
container: string | HTMLElement
|
||||
sdkUrl?: string
|
||||
targetOrigin?: string
|
||||
floorId?: number
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface SgsMapEvents {
|
||||
ready: { engineVersion: string; protocolVersion: number }
|
||||
poiClick: { id?: number; poiId?: number; name: string; type: string; floorId?: number; description?: string }
|
||||
floorChanged: { floorId: number }
|
||||
error: { code: string; message: string }
|
||||
loadError: { type: string; detail: string }
|
||||
}
|
||||
|
||||
export type SgsErrorCode =
|
||||
| 'ERR_TIMEOUT'
|
||||
| 'ERR_NO_PATH'
|
||||
| 'ERR_FLOOR_NOT_FOUND'
|
||||
| 'ERR_NOT_READY'
|
||||
| 'ERR_POI_NOT_FOUND'
|
||||
| 'ERR_NETWORK'
|
||||
| 'ERR_IFRAME_DEAD'
|
||||
| 'ERR_UNAUTHORIZED'
|
||||
| 'ERR_UNSUPPORTED'
|
||||
| 'ERR_FAILED'
|
||||
|
||||
export interface SgsMarkerConfig {
|
||||
id: string
|
||||
name?: string
|
||||
iconUrl?: string
|
||||
anchorNodeName?: string
|
||||
position?: [number, number, number] | { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
export interface SgsMapSDKInstance {
|
||||
on<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
|
||||
off<K extends keyof SgsMapEvents>(event: K, listener: (payload: SgsMapEvents[K]) => void): void
|
||||
getIsReady(): boolean
|
||||
getCurrentFloor(): number
|
||||
focusTo(node: SgsMapNode | string): void
|
||||
changeFloor(floorId: number, timeout?: number): Promise<{ success: boolean; floorId: number }>
|
||||
addMarker(config: SgsMarkerConfig): void
|
||||
removeMarker(markerId: string): void
|
||||
clearMarkers(): void
|
||||
highlightPolygon(nodeName: string): void
|
||||
planRoute(from: SgsMapNode | string, to: SgsMapNode | string, options?: unknown, timeout?: number): Promise<SgsRouteResult>
|
||||
clearRoute(): void
|
||||
getState(): Promise<unknown>
|
||||
resetView(): void
|
||||
setViewMode(mode: '2d' | '3d'): void
|
||||
toggleLayer(types: string[], visible: boolean, timeout?: number): Promise<{ success: boolean; visibleTypes: string[] }>
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
export type SgsMapSDKConstructor = new (options: SgsMapSDKOptions) => SgsMapSDKInstance
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SGSMapSDK?: SgsMapSDKConstructor
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,9 @@ export interface HallDetailViewModel {
|
||||
}
|
||||
|
||||
const contentTypeFor = (exhibit: MuseumExhibit): ExplainContentType => {
|
||||
if (exhibit.tags?.some((tag) => /体验|教育|商业|公共/.test(tag))) return 'zone'
|
||||
if (exhibit.tags?.some((tag) => /主题/.test(tag))) return 'theme'
|
||||
if (exhibit.hallName && exhibit.tags?.some((tag) => /展厅/.test(tag))) return 'hall'
|
||||
if (exhibit.hallName && exhibit.tags?.some((tag) => /展厅|空间/.test(tag))) return 'hall'
|
||||
return 'exhibit'
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ const buildSubtitle = (exhibit: MuseumExhibit) => [
|
||||
|
||||
const buildBadges = (exhibit: MuseumExhibit) => {
|
||||
const badges = [
|
||||
exhibit.hallName ? '展厅讲解' : '讲解点',
|
||||
exhibit.hallName ? '空间讲解' : '展品讲解',
|
||||
exhibit.floorLabel || '',
|
||||
...(exhibit.tags || [])
|
||||
].filter(Boolean)
|
||||
@@ -92,52 +93,77 @@ const buildBadges = (exhibit: MuseumExhibit) => {
|
||||
return Array.from(new Set(badges)).slice(0, 3)
|
||||
}
|
||||
|
||||
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => ({
|
||||
id: exhibit.id,
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
summary: exhibit.description || '讲解内容待正式内容库补充。',
|
||||
coverImage: exhibit.image,
|
||||
contentType: contentTypeFor(exhibit),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
durationLabel: undefined,
|
||||
languageLabel: undefined,
|
||||
audioStatus: 'unavailable',
|
||||
audioStatusText: '图文讲解',
|
||||
badges: buildBadges(exhibit),
|
||||
progress: undefined,
|
||||
poiId: exhibit.poiId,
|
||||
locationActionText: '查看位置'
|
||||
})
|
||||
export const toExplainCardViewModel = (exhibit: MuseumExhibit): ExplainCardViewModel => {
|
||||
const sourceHasAudio = exhibit.tags?.includes('源数据含音频地址') === true
|
||||
|
||||
return {
|
||||
id: exhibit.id,
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
summary: exhibit.description || '讲解内容来自 SGS 场景设置数据,正式 CMS 文案接入后可继续替换。',
|
||||
coverImage: exhibit.image,
|
||||
contentType: contentTypeFor(exhibit),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
durationLabel: undefined,
|
||||
languageLabel: '中文',
|
||||
audioStatus: 'unavailable',
|
||||
audioStatusText: sourceHasAudio ? '音频待同步' : '图文讲解',
|
||||
badges: buildBadges(exhibit),
|
||||
progress: undefined,
|
||||
poiId: exhibit.poiId,
|
||||
locationActionText: exhibit.poiId ? '查看位置' : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const toExplainExhibitViewModel = (exhibit: MuseumExhibit): ExplainExhibitViewModel => toExplainCardViewModel(exhibit)
|
||||
|
||||
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => ({
|
||||
id: exhibit.id,
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
contentType: contentTypeFor(exhibit),
|
||||
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
summary: exhibit.description || '该讲解内容待正式内容库补充。',
|
||||
body: exhibit.description || '该讲解内容待正式内容库补充。',
|
||||
audio: {
|
||||
status: 'unavailable',
|
||||
unavailableReason: '正式讲解音频尚未接入媒体仓储'
|
||||
},
|
||||
chapters: [],
|
||||
transcript: undefined,
|
||||
location: exhibit.poiId
|
||||
? {
|
||||
poiId: exhibit.poiId,
|
||||
actionText: '查看三维位置',
|
||||
previewOnly: true
|
||||
}
|
||||
: undefined,
|
||||
relatedItems: []
|
||||
})
|
||||
export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDetailPageViewModel => {
|
||||
const sourceHasAudio = exhibit.tags?.includes('源数据含音频地址') === true
|
||||
const metadataLines = [
|
||||
exhibit.size ? `展品编号:${exhibit.size}` : '',
|
||||
exhibit.year ? `年代:${exhibit.year}` : '',
|
||||
exhibit.material ? `来源:${exhibit.material}` : ''
|
||||
].filter(Boolean)
|
||||
const body = [
|
||||
exhibit.description || '该讲解内容来自 SGS 场景设置数据,正式 CMS 文案接入后可继续替换。',
|
||||
metadataLines.join('\n')
|
||||
].filter(Boolean).join('\n\n')
|
||||
|
||||
return {
|
||||
id: exhibit.id,
|
||||
title: exhibit.name,
|
||||
subtitle: buildSubtitle(exhibit),
|
||||
contentType: contentTypeFor(exhibit),
|
||||
coverImages: [exhibit.image || '/static/exhibit-placeholder.jpg'].filter(Boolean),
|
||||
hallName: exhibit.hallName,
|
||||
floorLabel: exhibit.floorLabel,
|
||||
summary: exhibit.description || '该讲解内容来自 SGS 场景设置数据。',
|
||||
body,
|
||||
audio: {
|
||||
status: 'unavailable',
|
||||
language: 'zh-CN',
|
||||
unavailableReason: sourceHasAudio
|
||||
? 'SGS 场景设置源数据包含音频地址,但静态音频文件尚未同步到当前 H5 项目。'
|
||||
: '该讲解在 SGS 场景设置源数据中暂无音频地址。'
|
||||
},
|
||||
chapters: metadataLines.length
|
||||
? metadataLines.map((line, index) => ({
|
||||
id: `${exhibit.id}-metadata-${index}`,
|
||||
title: line
|
||||
}))
|
||||
: [],
|
||||
transcript: exhibit.description,
|
||||
location: exhibit.poiId
|
||||
? {
|
||||
poiId: exhibit.poiId,
|
||||
actionText: '查看三维位置',
|
||||
previewOnly: true
|
||||
}
|
||||
: undefined,
|
||||
relatedItems: []
|
||||
}
|
||||
}
|
||||
|
||||
export const toExplainDetailViewModel = (exhibit: MuseumExhibit): ExplainDetailViewModel => toExplainDetailPageViewModel(exhibit)
|
||||
|
||||
@@ -145,7 +171,7 @@ export const toHallDetailViewModel = (hall: MuseumHall): HallDetailViewModel =>
|
||||
id: hall.id,
|
||||
name: hall.name,
|
||||
floorLabel: hall.floorLabel || '楼层待补充',
|
||||
description: hall.description || '该展厅介绍待正式内容库补充。',
|
||||
description: hall.description || '该空间介绍待 SGS 场景设置或 CMS 内容补充。',
|
||||
image: hall.image || '/static/hall-placeholder.jpg',
|
||||
exhibitCount: hall.exhibitCount || 0,
|
||||
area: hall.area,
|
||||
|
||||
Reference in New Issue
Block a user