feat: wire guide data source and POI focus UI
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,15 +206,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type {
|
||||
ExplainExhibitViewModel
|
||||
} from '@/view-models/explainViewModels'
|
||||
|
||||
export interface Exhibit {
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
hall?: string
|
||||
floor?: string
|
||||
image?: string
|
||||
hasAudio?: boolean
|
||||
export interface Exhibit extends ExplainExhibitViewModel {
|
||||
audioUrl?: string
|
||||
}
|
||||
|
||||
@@ -225,6 +221,16 @@ interface SearchResult {
|
||||
type: 'exhibit' | 'hall' | 'facility'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
exhibits?: Exhibit[]
|
||||
hotSearches?: string[]
|
||||
initialSearchHistory?: string[]
|
||||
}>(), {
|
||||
exhibits: () => [],
|
||||
hotSearches: () => ['恐龙化石', '银杏', '海洋生物', '霸王龙', '古植物', '矿石标本', '恐龙', '化石'],
|
||||
initialSearchHistory: () => ['霸王龙', '化石', '恐龙']
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
exhibitClick: [exhibit: Exhibit]
|
||||
audioClick: [exhibit: Exhibit]
|
||||
@@ -259,46 +265,31 @@ let touchStartY = 0
|
||||
let touchCurrentY = 0
|
||||
|
||||
// 热门搜索
|
||||
const hotSearches = ['恐龙化石', '银杏', '海洋生物', '霸王龙', '古植物', '矿石标本', '恐龙', '化石']
|
||||
const hotSearches = computed(() => props.hotSearches)
|
||||
|
||||
// 搜索历史
|
||||
const searchHistory = ref<string[]>(['霸王龙', '化石', '恐龙'])
|
||||
const searchHistory = ref<string[]>([...props.initialSearchHistory])
|
||||
|
||||
// 搜索数据
|
||||
const allData: SearchResult[] = [
|
||||
{ id: '1', name: '霸王龙化石骨架', desc: '恐龙厅 · 2F', type: 'exhibit' },
|
||||
{ id: '2', name: '银杏化石', desc: '演化厅 · B1', type: 'exhibit' },
|
||||
{ id: '3', name: '三叶虫化石', desc: '演化厅 · B1', type: 'exhibit' },
|
||||
{ id: '4', name: '腕龙骨骼', desc: '恐龙厅 · 2F', type: 'exhibit' },
|
||||
{ id: '5', name: '猛犸象象牙', desc: '人类厅 · 3F', type: 'exhibit' },
|
||||
{ id: 'h1', name: '恐龙厅', desc: '2F · 25件展品', type: 'hall' },
|
||||
{ id: 'h2', name: '演化厅', desc: 'B1 · 30件展品', type: 'hall' },
|
||||
{ id: 'h3', name: '生物厅', desc: '3F · 18件展品', type: 'hall' },
|
||||
{ id: 'f1', name: '洗手间', desc: '1F', type: 'facility' },
|
||||
{ id: 'f2', name: '咖啡厅', desc: '1F', type: 'facility' },
|
||||
]
|
||||
const allData = computed<SearchResult[]>(() => props.exhibits.map((exhibit) => ({
|
||||
id: exhibit.id,
|
||||
name: exhibit.name,
|
||||
desc: exhibit.hall || exhibit.floor || '展项内容',
|
||||
type: 'exhibit'
|
||||
})))
|
||||
|
||||
// 搜索结果计算
|
||||
const searchResults = computed(() => {
|
||||
if (!searchInputValue.value.trim()) return []
|
||||
const kw = searchInputValue.value.toLowerCase()
|
||||
return allData.filter(item =>
|
||||
item.name.toLowerCase().includes(kw) ||
|
||||
return allData.value.filter(item =>
|
||||
item.name.toLowerCase().includes(kw) ||
|
||||
item.desc.toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
|
||||
// 展品列表
|
||||
const exhibits = ref<Exhibit[]>([
|
||||
{ id: '1', name: '银杏化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' },
|
||||
{ id: '2', name: '三叶虫化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' },
|
||||
{ id: '3', name: '腕龙骨骼', hall: '恐龙厅 2F', hasAudio: true, audioUrl: '' },
|
||||
{ id: '4', name: '始祖鸟标本', hall: '演化厅 B1', hasAudio: false, audioUrl: '' },
|
||||
{ id: '5', name: '猛犸象象牙', hall: '人类厅 3F', hasAudio: true, audioUrl: '' },
|
||||
{ id: '6', name: '珊瑚礁标本', hall: '生物厅 3F', hasAudio: false, audioUrl: '' },
|
||||
{ id: '7', name: '剑龙化石', hall: '恐龙厅 2F', hasAudio: true, audioUrl: '' },
|
||||
{ id: '8', name: '鹦鹉螺化石', hall: '演化厅 B1', hasAudio: true, audioUrl: '' }
|
||||
])
|
||||
const exhibits = computed(() => props.exhibits)
|
||||
|
||||
// 过滤展品
|
||||
const filteredExhibits = computed(() => {
|
||||
@@ -306,7 +297,7 @@ const filteredExhibits = computed(() => {
|
||||
return exhibits.value
|
||||
}
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return exhibits.value.filter(exhibit =>
|
||||
return exhibits.value.filter(exhibit =>
|
||||
exhibit.name.toLowerCase().includes(keyword) ||
|
||||
(exhibit.hall && exhibit.hall.toLowerCase().includes(keyword))
|
||||
)
|
||||
|
||||
@@ -30,16 +30,9 @@ const emit = defineEmits<{
|
||||
change: [floorId: string]
|
||||
}>()
|
||||
|
||||
const currentFloor = ref(props.current || '1F')
|
||||
const currentFloor = ref(props.current || props.floors?.[0]?.id || '')
|
||||
|
||||
const defaultFloors: Floor[] = [
|
||||
{ id: '3F', label: '3F' },
|
||||
{ id: '2F', label: '2F' },
|
||||
{ id: '1F', label: '1F' },
|
||||
{ id: 'B1', label: 'B1' }
|
||||
]
|
||||
|
||||
const floors = props.floors || defaultFloors
|
||||
const floors = props.floors || []
|
||||
|
||||
const handleFloorClick = (floor: Floor) => {
|
||||
if (floor.disabled) return
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
|
||||
const isH5 = typeof window !== 'undefined'
|
||||
|
||||
@@ -94,6 +94,19 @@ interface Polygon {
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
interface MapFloor {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
activeFloor?: string
|
||||
floors?: MapFloor[]
|
||||
}>(), {
|
||||
activeFloor: '1F',
|
||||
floors: () => [] as MapFloor[]
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
markerClick: [markerId: number]
|
||||
floorChange: [floor: string]
|
||||
@@ -111,15 +124,10 @@ const mapCenter = ref<MapCenter>({
|
||||
const mapScale = ref(17)
|
||||
|
||||
// 当前楼层
|
||||
const currentFloor = ref('1F')
|
||||
const currentFloor = ref(props.activeFloor)
|
||||
|
||||
// 楼层列表
|
||||
const floors = [
|
||||
{ id: '3F', label: '3F' },
|
||||
{ id: '2F', label: '2F' },
|
||||
{ id: '1F', label: '1F' },
|
||||
{ id: 'B1', label: 'B1' }
|
||||
]
|
||||
const floors = computed(() => props.floors)
|
||||
|
||||
// 博物馆建筑围栏(多边形)
|
||||
// 深圳自然博物馆实际建筑轮廓坐标
|
||||
|
||||
@@ -50,56 +50,23 @@ import * as THREE from 'three'
|
||||
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import FloorSwitcher from './FloorSwitcher.vue'
|
||||
import { NAV_ASSET_BASE_URL } from '@/services/navAssets'
|
||||
import type {
|
||||
GuideModelFloorAsset,
|
||||
GuideModelRenderPackage,
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
|
||||
type ViewMode = 'overview' | 'floor'
|
||||
|
||||
interface ManifestFloorModel {
|
||||
floorId: string
|
||||
order: number
|
||||
asset: string
|
||||
}
|
||||
|
||||
interface CleanNavManifest {
|
||||
status: string
|
||||
assets: {
|
||||
overviewModel: {
|
||||
asset: string
|
||||
}
|
||||
floorModels: ManifestFloorModel[]
|
||||
}
|
||||
data: {
|
||||
floorIndex: string
|
||||
}
|
||||
}
|
||||
|
||||
interface FloorIndexItem {
|
||||
floorId: string
|
||||
order: number
|
||||
modelAsset: string
|
||||
poiDataAsset: string
|
||||
poiCount: number
|
||||
}
|
||||
|
||||
interface FloorIndexData {
|
||||
status: string
|
||||
floors: FloorIndexItem[]
|
||||
}
|
||||
type FloorIndexItem = GuideModelFloorAsset
|
||||
|
||||
interface FloorOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CleanPoi {
|
||||
id: string
|
||||
name: string
|
||||
floorId: string
|
||||
primaryCategory: string
|
||||
primaryCategoryZh: string
|
||||
iconType: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
type RenderPoi = GuideRenderPoi
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
requestId: number | string
|
||||
@@ -119,23 +86,18 @@ interface TargetPoiFocusResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface FloorPoiData {
|
||||
status: string
|
||||
floorId: string
|
||||
pois: CleanPoi[]
|
||||
}
|
||||
|
||||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
assetBaseUrl?: string
|
||||
modelSource: GuideModelSource
|
||||
initialFloorId?: string
|
||||
initialView?: ViewMode
|
||||
showControls?: boolean
|
||||
showPoi?: boolean
|
||||
targetFocus?: TargetPoiFocusRequest | null
|
||||
}>(), {
|
||||
assetBaseUrl: NAV_ASSET_BASE_URL,
|
||||
assetBaseUrl: '',
|
||||
initialFloorId: 'L1',
|
||||
initialView: 'overview',
|
||||
showControls: true,
|
||||
@@ -145,7 +107,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
floorChange: [floorId: string]
|
||||
poiClick: [poi: CleanPoi]
|
||||
poiClick: [poi: RenderPoi]
|
||||
targetFocus: [result: TargetPoiFocusResult]
|
||||
}>()
|
||||
|
||||
@@ -156,9 +118,10 @@ const loadingProgress = ref(0)
|
||||
const loadingMessage = ref('正在读取室内导览资源...')
|
||||
const activeView = ref<ViewMode>(props.initialView)
|
||||
const currentFloor = ref(props.initialFloorId)
|
||||
const selectedPOI = ref<CleanPoi | null>(null)
|
||||
const selectedPOI = ref<RenderPoi | null>(null)
|
||||
const activeFocusPoiId = ref('')
|
||||
const floorIndex = ref<FloorIndexItem[]>([])
|
||||
const renderPackage = ref<GuideModelRenderPackage | null>(null)
|
||||
|
||||
const floors = computed<FloorOption[]>(() => (
|
||||
[...floorIndex.value]
|
||||
@@ -170,7 +133,6 @@ const floors = computed<FloorOption[]>(() => (
|
||||
))
|
||||
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))
|
||||
|
||||
let manifest: CleanNavManifest | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
@@ -178,16 +140,13 @@ let controls: OrbitControls | null = null
|
||||
let loader: GLTFLoader | null = null
|
||||
let activeModel: THREE.Object3D | null = null
|
||||
let poiGroup: THREE.Group | null = null
|
||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||
let animationId = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let isDisposed = false
|
||||
let pendingTargetFocus: TargetPoiFocusRequest | null = null
|
||||
let targetFocusQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const assetUrl = (relativePath: string) => `${normalizeBaseUrl(props.assetBaseUrl)}/${relativePath.replace(/^\/+/, '')}`
|
||||
|
||||
const getContainerElement = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return null
|
||||
@@ -202,27 +161,15 @@ const getContainerElement = () => {
|
||||
return element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
const formatFloorLabel = (floorId: string) => {
|
||||
if (floorId === 'L-2') return 'B2'
|
||||
if (floorId === 'L-1') return 'B1'
|
||||
if (floorId === 'L1.5') return '1.5F'
|
||||
if (floorId.startsWith('L')) return `${floorId.slice(1)}F`
|
||||
return floorId
|
||||
}
|
||||
const formatFloorLabel = (floorId: string) => (
|
||||
floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
|
||||
)
|
||||
|
||||
const setProgress = (progress: number, message: string) => {
|
||||
loadingProgress.value = Math.min(100, Math.max(0, progress))
|
||||
loadingMessage.value = message
|
||||
}
|
||||
|
||||
const fetchJson = async <T,>(url: string): Promise<T> => {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`资源读取失败: ${response.status} ${url}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
const waitForContainer = async () => {
|
||||
await nextTick()
|
||||
|
||||
@@ -274,7 +221,7 @@ const initThree = async () => {
|
||||
|
||||
loader = new GLTFLoader()
|
||||
poiGroup = new THREE.Group()
|
||||
poiGroup.name = 'CleanPackagePOI'
|
||||
poiGroup.name = 'GuideModelPOI'
|
||||
scene.add(poiGroup)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 1.6)
|
||||
@@ -340,6 +287,7 @@ const clearSceneData = () => {
|
||||
}
|
||||
|
||||
activeModel = null
|
||||
disposeFocusLabel()
|
||||
selectedPOI.value = null
|
||||
|
||||
if (poiGroup) {
|
||||
@@ -405,15 +353,16 @@ const fitCameraToObject = (object: THREE.Object3D) => {
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
if (!manifest || !scene) return
|
||||
const packageData = renderPackage.value
|
||||
if (!packageData || !scene) return
|
||||
|
||||
activeView.value = 'overview'
|
||||
clearSceneData()
|
||||
setProgress(18, '正在加载全馆三维模型...')
|
||||
|
||||
const gltf = await loadModel(assetUrl(manifest.assets.overviewModel.asset), '正在加载全馆三维模型')
|
||||
const gltf = await loadModel(packageData.overviewModelUrl, '正在加载全馆三维模型')
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = 'CleanOverviewModel'
|
||||
activeModel.name = 'GuideOverviewModel'
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
@@ -428,9 +377,9 @@ const loadFloor = async (floorId: string) => {
|
||||
clearSceneData()
|
||||
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
|
||||
|
||||
const gltf = await loadModel(assetUrl(floor.modelAsset), `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
|
||||
const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = `CleanFloorModel_${floor.floorId}`
|
||||
activeModel.name = `GuideFloorModel_${floor.floorId}`
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
@@ -440,7 +389,7 @@ const loadFloor = async (floorId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createPoiMaterial = (poi: CleanPoi) => {
|
||||
const createPoiMaterial = (poi: RenderPoi) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 96
|
||||
canvas.height = 96
|
||||
@@ -476,6 +425,69 @@ const createPoiMaterial = (poi: CleanPoi) => {
|
||||
})
|
||||
}
|
||||
|
||||
const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 512
|
||||
canvas.height = 144
|
||||
const context = canvas.getContext('2d')
|
||||
const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}…` : poi.name
|
||||
|
||||
if (context) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.82)'
|
||||
context.beginPath()
|
||||
context.roundRect(36, 18, 440, 82, 30)
|
||||
context.fill()
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(238, 100)
|
||||
context.lineTo(274, 100)
|
||||
context.lineTo(256, 124)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
|
||||
context.font = '600 38px sans-serif'
|
||||
context.textAlign = 'center'
|
||||
context.textBaseline = 'middle'
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillText(label, 256, 59, 390)
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false
|
||||
}))
|
||||
sprite.userData.isPoiLabel = true
|
||||
sprite.renderOrder = 30
|
||||
sprite.scale.set(markerSize * 4.8, markerSize * 1.35, 1)
|
||||
|
||||
return sprite
|
||||
}
|
||||
|
||||
const disposeFocusLabel = () => {
|
||||
if (!activeFocusLabelSprite) return
|
||||
|
||||
activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
|
||||
disposeObject(activeFocusLabelSprite)
|
||||
activeFocusLabelSprite = null
|
||||
}
|
||||
|
||||
const showFocusPoiLabel = (poi: RenderPoi) => {
|
||||
if (!poiGroup || !poi.positionGltf) return
|
||||
|
||||
disposeFocusLabel()
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
const [x, y, z] = poi.positionGltf
|
||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
|
||||
poiGroup.add(activeFocusLabelSprite)
|
||||
}
|
||||
|
||||
const getPoiColor = (category: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
touring_poi: '#2f6fed',
|
||||
@@ -509,8 +521,8 @@ const updatePoiMarkerFocus = () => {
|
||||
if (!poiGroup) return
|
||||
|
||||
poiGroup.children.forEach((child) => {
|
||||
if (child instanceof THREE.Sprite) {
|
||||
const poi = child.userData.poi as CleanPoi | undefined
|
||||
if (child instanceof THREE.Sprite && !child.userData.isPoiLabel) {
|
||||
const poi = child.userData.poi as RenderPoi | undefined
|
||||
setPoiSpriteFocusStyle(child, Boolean(poi && poi.id === activeFocusPoiId.value))
|
||||
}
|
||||
})
|
||||
@@ -518,15 +530,15 @@ const updatePoiMarkerFocus = () => {
|
||||
|
||||
const findPoiSprite = (poiId: string) => (
|
||||
poiGroup?.children.find((child): child is THREE.Sprite => (
|
||||
child instanceof THREE.Sprite && (child.userData.poi as CleanPoi | undefined)?.id === poiId
|
||||
child instanceof THREE.Sprite && (child.userData.poi as RenderPoi | undefined)?.id === poiId
|
||||
))
|
||||
)
|
||||
|
||||
const findLoadedPoi = (poiId: string) => (
|
||||
findPoiSprite(poiId)?.userData.poi as CleanPoi | undefined
|
||||
findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined
|
||||
)
|
||||
|
||||
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): CleanPoi | null => {
|
||||
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
|
||||
if (!request.positionGltf) return null
|
||||
|
||||
return {
|
||||
@@ -561,8 +573,7 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
const data = await fetchJson<FloorPoiData>(assetUrl(floor.poiDataAsset))
|
||||
const validPois = data.pois.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
const validPois = await props.modelSource.loadFloorPois(floor.floorId)
|
||||
const markerSize = getPoiMarkerSize()
|
||||
|
||||
validPois.forEach((poi) => {
|
||||
@@ -588,8 +599,10 @@ const handlePointerDown = (event: PointerEvent) => {
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
const hits = raycaster.intersectObjects(poiGroup.children, false)
|
||||
|
||||
if (hits[0]?.object.userData.poi) {
|
||||
selectedPOI.value = hits[0].object.userData.poi as CleanPoi
|
||||
const hit = hits.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)
|
||||
@@ -614,6 +627,7 @@ const clearTargetFocus = () => {
|
||||
pendingTargetFocus = null
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
|
||||
@@ -623,7 +637,7 @@ const isSceneReadyForTargetFocus = () => (
|
||||
&& !loadError.value
|
||||
)
|
||||
|
||||
const focusCameraOnPoi = (poi: CleanPoi) => {
|
||||
const focusCameraOnPoi = (poi: RenderPoi) => {
|
||||
if (!camera || !controls || !poi.positionGltf) return
|
||||
|
||||
const [x, y, z] = poi.positionGltf
|
||||
@@ -675,6 +689,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
|
||||
if (!poi?.positionGltf) {
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
emitTargetFocus(request, 'missing', '目标暂无三维坐标')
|
||||
return false
|
||||
@@ -682,6 +697,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
|
||||
|
||||
selectedPOI.value = poi
|
||||
updatePoiMarkerFocus()
|
||||
showFocusPoiLabel(poi)
|
||||
focusCameraOnPoi(poi)
|
||||
emitTargetFocus(request, 'focused')
|
||||
return true
|
||||
@@ -718,17 +734,13 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
|
||||
})
|
||||
}
|
||||
|
||||
const loadCleanPackage = async () => {
|
||||
const loadModelPackage = async () => {
|
||||
setProgress(8, '正在读取室内导览资源...')
|
||||
manifest = await fetchJson<CleanNavManifest>(assetUrl('app_nav_manifest.json'))
|
||||
|
||||
if (manifest.status !== 'pass') {
|
||||
throw new Error('室内导览资源包状态不是 pass')
|
||||
}
|
||||
const packageData = await props.modelSource.loadPackage()
|
||||
renderPackage.value = packageData
|
||||
|
||||
setProgress(14, '正在读取楼层索引...')
|
||||
const floorData = await fetchJson<FloorIndexData>(assetUrl(manifest.data.floorIndex))
|
||||
floorIndex.value = [...floorData.floors].sort((a, b) => a.order - b.order)
|
||||
floorIndex.value = [...packageData.floors].sort((a, b) => a.order - b.order)
|
||||
|
||||
if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
|
||||
currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
|
||||
@@ -752,13 +764,13 @@ const init3DScene = async () => {
|
||||
setProgress(0, '正在初始化三维场景...')
|
||||
|
||||
await initThree()
|
||||
await loadCleanPackage()
|
||||
await loadModelPackage()
|
||||
|
||||
setProgress(100, '室内三维模型加载完成')
|
||||
isLoading.value = false
|
||||
queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
|
||||
} catch (error) {
|
||||
console.error('室内 3D clean package 加载失败:', error)
|
||||
console.error('室内 3D 模型资源加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
|
||||
@@ -840,11 +852,12 @@ defineExpose({
|
||||
clearNavigation: () => {
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
disposeFocusLabel()
|
||||
updatePoiMarkerFocus()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetBaseUrl, () => {
|
||||
watch(() => props.modelSource, () => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
<template v-if="mapType === 'indoor'">
|
||||
<!-- #ifdef H5 -->
|
||||
<ThreeMap
|
||||
v-if="indoorModelSource"
|
||||
ref="threeMapRef"
|
||||
class="indoor-three-map"
|
||||
:asset-base-url="indoorAssetBaseUrl"
|
||||
:model-source="indoorModelSource"
|
||||
:initial-floor-id="activeFloorId"
|
||||
:initial-view="indoorInitialView"
|
||||
:show-controls="false"
|
||||
@@ -23,7 +25,11 @@
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
<view v-else class="outdoor-map-bg">
|
||||
<TencentMap class="outdoor-map" />
|
||||
<TencentMap
|
||||
class="outdoor-map"
|
||||
:active-floor="activeFloor"
|
||||
:floors="props.floors"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="dimmed" class="dim-layer"></view>
|
||||
</view>
|
||||
@@ -105,7 +111,7 @@
|
||||
|
||||
<view v-if="showFloor" class="floor-switcher" :style="floorSwitcherStyle">
|
||||
<view
|
||||
v-for="floor in floors"
|
||||
v-for="floor in floorLabels"
|
||||
:key="floor"
|
||||
class="floor-item"
|
||||
:class="{ active: activeFloor === floor }"
|
||||
@@ -136,11 +142,14 @@ import TencentMap from '@/components/map/TencentMap.vue'
|
||||
// #ifdef H5
|
||||
import ThreeMap from '@/components/map/ThreeMap.vue'
|
||||
// #endif
|
||||
import {
|
||||
NAV_ASSET_BASE_URL,
|
||||
NAV_FLOOR_OPTIONS,
|
||||
navFloorIdFromLabel
|
||||
} from '@/services/navAssets'
|
||||
import type {
|
||||
GuideModelSource
|
||||
} from '@/domain/guideModel'
|
||||
|
||||
interface GuideFloorOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
requestId: number | string
|
||||
@@ -183,6 +192,9 @@ const props = withDefaults(defineProps<{
|
||||
mapType?: 'indoor' | 'outdoor'
|
||||
outdoorVariant?: 'home' | 'entrance'
|
||||
indoorAssetBaseUrl?: string
|
||||
indoorModelSource?: GuideModelSource
|
||||
floors?: GuideFloorOption[]
|
||||
normalizeFloorId?: (labelOrId: string) => string
|
||||
dimmed?: boolean
|
||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||
}>(), {
|
||||
@@ -205,7 +217,10 @@ const props = withDefaults(defineProps<{
|
||||
modeStatusTone: 'solid',
|
||||
mapType: 'indoor',
|
||||
outdoorVariant: 'home',
|
||||
indoorAssetBaseUrl: NAV_ASSET_BASE_URL,
|
||||
indoorAssetBaseUrl: '',
|
||||
indoorModelSource: undefined,
|
||||
floors: () => [] as GuideFloorOption[],
|
||||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||||
dimmed: false,
|
||||
targetFocusRequest: null
|
||||
})
|
||||
@@ -223,9 +238,11 @@ const threeMapRef = ref<{
|
||||
switchFloor?: (floorId: string) => Promise<void> | void
|
||||
showOverview?: () => Promise<void> | void
|
||||
} | null>(null)
|
||||
const floors = NAV_FLOOR_OPTIONS.map((floor) => floor.label)
|
||||
|
||||
const activeFloorId = computed(() => navFloorIdFromLabel(props.activeFloor))
|
||||
const floorLabels = computed(() => props.floors.map((floor) => floor.label))
|
||||
const indoorModelSource = computed(() => props.indoorModelSource)
|
||||
|
||||
const activeFloorId = computed(() => props.normalizeFloorId(props.activeFloor))
|
||||
|
||||
const searchFieldStyle = computed(() => ({
|
||||
top: props.searchTop
|
||||
@@ -258,7 +275,7 @@ const handleModeChange = (mode: '2d' | '3d') => {
|
||||
}
|
||||
|
||||
const handleFloorChange = (floor: string) => {
|
||||
const floorId = navFloorIdFromLabel(floor)
|
||||
const floorId = props.normalizeFloorId(floor)
|
||||
void threeMapRef.value?.switchFloor?.(floorId)
|
||||
emit('indoorViewChange', 'floor')
|
||||
emit('floorChange', floor)
|
||||
@@ -507,10 +524,11 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 46px;
|
||||
height: 218px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
padding: 1px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid #dde5df;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<text class="action-text">查看室外地图</text>
|
||||
</view>
|
||||
<view class="action-btn primary" @tap="handleShowTargetLocation">
|
||||
<text class="action-text">查看目标位置</text>
|
||||
<text class="action-text">重新定位目标</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -107,14 +107,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import {
|
||||
formatNavFloorLabel,
|
||||
searchCleanNavPois,
|
||||
type CleanNavPoi
|
||||
} from '@/services/navAssets'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
|
||||
interface SearchResult {
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
@@ -126,9 +121,11 @@ const props = withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
/** 嵌入模式:隐藏背景遮罩和取消按钮,直接嵌入父容器 */
|
||||
embedded?: boolean
|
||||
results?: SearchResult[]
|
||||
}>(), {
|
||||
placeholder: '搜索展品、展厅、设施...',
|
||||
embedded: false
|
||||
embedded: false,
|
||||
results: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -144,14 +141,7 @@ const hotSearches = ref(['卫生间', '电梯', '服务台', '无障碍', '入
|
||||
|
||||
const searchHistory = ref<string[]>([])
|
||||
|
||||
const allData = ref<SearchResult[]>([])
|
||||
|
||||
const toSearchResult = (poi: CleanNavPoi): SearchResult => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
desc: `${formatNavFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`,
|
||||
type: poi.primaryCategory === 'touring_poi' ? 'hall' : 'facility'
|
||||
})
|
||||
const allData = computed(() => props.results)
|
||||
|
||||
// 搜索结果计算
|
||||
const allResults = computed(() => {
|
||||
@@ -218,20 +208,6 @@ const getResultIcon = (type: string): string => {
|
||||
return icons[type] || '📍'
|
||||
}
|
||||
|
||||
const loadCleanResults = async () => {
|
||||
try {
|
||||
const pois = await searchCleanNavPois()
|
||||
allData.value = pois.slice(0, 120).map(toSearchResult)
|
||||
} catch (error) {
|
||||
console.error('加载 clean POI 搜索数据失败:', error)
|
||||
allData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCleanResults()
|
||||
})
|
||||
|
||||
// 打开时聚焦输入框
|
||||
watch(() => props.isOpen, async (newVal) => {
|
||||
if (newVal) {
|
||||
|
||||
Reference in New Issue
Block a user