diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue new file mode 100644 index 0000000..decdcff --- /dev/null +++ b/src/components/explain/ExplainHallSelect.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 5245ceb..a633500 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -64,6 +64,7 @@ import type { type ViewMode = 'overview' | 'floor' | 'multi' type CameraPreset = 'top' | 'oblique' +type TouchGestureMode = 'orbit' | 'pan' type FloorIndexItem = GuideModelFloorAsset @@ -79,12 +80,23 @@ type PoiVisibilityTier = 'tight' | 'balanced' | 'full' interface PoiSpriteUserData { poi?: RenderPoi baseScale?: number + labelBaseScaleX?: number + labelBaseScaleY?: number isPoiLabel?: boolean isPoiPulse?: boolean isPoiBase?: boolean isCorePoi?: boolean } +interface FocusHallMaterialState { + material: THREE.Material + color?: THREE.Color + emissive?: THREE.Color + emissiveIntensity?: number + opacity: number + transparent: boolean +} + interface TargetPoiFocusRequest { requestId: number | string poiId: string @@ -93,6 +105,7 @@ interface TargetPoiFocusRequest { floorLabel?: string primaryCategoryZh?: string positionGltf?: [number, number, number] + sourceObjectName?: string } interface TargetPoiFocusResult { @@ -120,6 +133,8 @@ const props = withDefaults(defineProps<{ showControls?: boolean showPoi?: boolean targetFocus?: TargetPoiFocusRequest | null + touchGestureMode?: TouchGestureMode + targetFocusDistanceFactor?: number routePreview?: GuideRouteResult | null showRoute?: boolean autoSwitch?: boolean @@ -133,6 +148,8 @@ const props = withDefaults(defineProps<{ showControls: true, showPoi: false, targetFocus: null, + touchGestureMode: 'orbit', + targetFocusDistanceFactor: 0.22, routePreview: null, showRoute: false, autoSwitch: true, @@ -183,6 +200,8 @@ let routeGroup: THREE.Group | null = null let activeFocusLabelSprite: THREE.Sprite | null = null let activeFocusPulseSprite: THREE.Sprite | null = null let activeFocusBaseSprite: THREE.Sprite | null = null +let activeFocusHallGlowMesh: THREE.Mesh | null = null +let activeFocusHallMaterialStates: FocusHallMaterialState[] = [] let animationId = 0 let resizeObserver: ResizeObserver | null = null let isDisposed = false @@ -201,6 +220,15 @@ let autoSwitchDisableTimer: ReturnType | null = null let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | null = null +const syncControlInteractionOptions = () => { + if (!controls) return + + const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan' + controls.enablePan = shouldEnablePan + controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE + controls.touches.TWO = THREE.TOUCH.DOLLY_PAN +} + const corePoiCategories = new Set([ 'basic_service_facility', 'transport_circulation', @@ -276,6 +304,61 @@ const getModelNodeNames = (object: THREE.Object3D) => { return names } +const normalizeModelMatchKey = (value: string) => ( + value + .trim() + .toLowerCase() + .replace(/[\s_\-./\\]/g, '') +) + +const getPoiModelMatchKeys = (poi: RenderPoi) => ( + [ + poi.sourceObjectName, + `${poi.floorId}_${poi.name}`, + poi.name + ] + .filter((value): value is string => Boolean(value)) + .map(normalizeModelMatchKey) + .filter(Boolean) +) + +const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => { + const keys = getPoiModelMatchKeys(poi) + if (!keys.length) return false + + return getModelNodeNames(object) + .map(normalizeModelMatchKey) + .some((name) => keys.some((key) => name === key || name.includes(key))) +} + +const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => { + if (!activeModel) return null + + let matchedRoot: THREE.Object3D | null = null + + activeModel.traverse((child) => { + if (matchedRoot || child === activeModel) return + + const childName = child.name ? normalizeModelMatchKey(child.name) : '' + const keys = getPoiModelMatchKeys(poi) + if (childName && keys.some((key) => childName === key || childName.includes(key))) { + matchedRoot = child + } + }) + + if (matchedRoot) return matchedRoot + + activeModel.traverse((child) => { + if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return + + if (isPoiModelNodeMatch(child, poi)) { + matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child + } + }) + + return matchedRoot +} + const isExteriorModelNode = (object: THREE.Object3D) => ( getModelNodeNames(object).some((name) => ( floorExteriorNameKeywords.some((keyword) => name.includes(keyword)) @@ -591,12 +674,12 @@ const initThree = async () => { controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true controls.dampingFactor = 0.08 - controls.enablePan = props.showControls controls.enableZoom = true controls.minDistance = 6 controls.maxDistance = 1200 controls.minPolarAngle = 0.08 controls.maxPolarAngle = Math.PI * 0.48 + syncControlInteractionOptions() loader = new GLTFLoader() poiGroup = new THREE.Group() @@ -636,6 +719,7 @@ const startRenderLoop = () => { if (isDisposed || !renderer || !scene || !camera) return controls?.update() + updateFocusLabelScale() renderer.render(scene, camera) animationId = window.requestAnimationFrame(render) } @@ -675,6 +759,174 @@ const disposeObject = (object: THREE.Object3D) => { }) } +const getMaterialColor = (material: THREE.Material) => { + const candidate = material as THREE.Material & { color?: unknown } + return candidate.color instanceof THREE.Color ? candidate.color : undefined +} + +const getMaterialEmissive = (material: THREE.Material) => { + const candidate = material as THREE.Material & { emissive?: unknown } + return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined +} + +const getMaterialEmissiveIntensity = (material: THREE.Material) => { + const candidate = material as THREE.Material & { emissiveIntensity?: unknown } + return typeof candidate.emissiveIntensity === 'number' + ? candidate.emissiveIntensity + : undefined +} + +const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => { + const candidate = material as THREE.Material & { emissiveIntensity?: number } + if (typeof candidate.emissiveIntensity === 'number') { + candidate.emissiveIntensity = value + } +} + +const clearFocusHallHighlight = () => { + activeFocusHallMaterialStates.forEach((state) => { + const color = getMaterialColor(state.material) + const emissive = getMaterialEmissive(state.material) + + if (color && state.color) { + color.copy(state.color) + } + + if (emissive && state.emissive) { + emissive.copy(state.emissive) + } + + if (typeof state.emissiveIntensity === 'number') { + setMaterialEmissiveIntensity(state.material, state.emissiveIntensity) + } + + state.material.opacity = state.opacity + state.material.transparent = state.transparent + state.material.needsUpdate = true + }) + + activeFocusHallMaterialStates = [] + + if (activeFocusHallGlowMesh) { + activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh) + const material = activeFocusHallGlowMesh.material + if (!Array.isArray(material)) { + const glowMaterial = material as THREE.MeshBasicMaterial + glowMaterial.map?.dispose() + } + disposeObject(activeFocusHallGlowMesh) + activeFocusHallGlowMesh = null + } +} + +const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set) => { + if (seenMaterials.has(material)) return + seenMaterials.add(material) + + const color = getMaterialColor(material) + const emissive = getMaterialEmissive(material) + const emissiveIntensity = getMaterialEmissiveIntensity(material) + + activeFocusHallMaterialStates.push({ + material, + color: color?.clone(), + emissive: emissive?.clone(), + emissiveIntensity, + opacity: material.opacity, + transparent: material.transparent + }) + + const glowColor = new THREE.Color('#e0df00') + + if (color) { + color.lerp(glowColor, 0.34) + } + + if (emissive) { + emissive.copy(glowColor) + setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76)) + } + + material.opacity = Math.max(material.opacity, 0.96) + material.transparent = true + material.needsUpdate = true +} + +const createFocusHallGlowMesh = (poi: RenderPoi) => { + if (!poi.positionGltf) return null + + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 256 + const context = canvas.getContext('2d') + + if (context) { + const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118) + gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)') + gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)') + gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)') + gradient.addColorStop(1, 'rgba(224, 223, 0, 0)') + + context.clearRect(0, 0, canvas.width, canvas.height) + context.fillStyle = gradient + context.fillRect(0, 0, canvas.width, canvas.height) + } + + const texture = new THREE.CanvasTexture(canvas) + texture.colorSpace = THREE.SRGBColorSpace + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: 0.78, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide + }) + const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14) + const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72) + const mesh = new THREE.Mesh(geometry, material) + const [x, y, z] = poi.positionGltf + + mesh.name = 'GuideFocusHallGlow' + mesh.position.set(x, y + 0.08, z) + mesh.rotation.x = -Math.PI / 2 + mesh.renderOrder = 7 + + return mesh +} + +const showFocusHallHighlight = (poi: RenderPoi) => { + clearFocusHallHighlight() + if (!activeModel) return + + activeFocusHallGlowMesh = createFocusHallGlowMesh(poi) + if (activeFocusHallGlowMesh) { + poiGroup?.add(activeFocusHallGlowMesh) + } + + const modelRoot = findPoiModelRoot(poi) + if (!modelRoot) return + + const seenMaterials = new Set() + let matchedMeshCount = 0 + + modelRoot.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.visible) return + + matchedMeshCount += 1 + const materials = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((material) => { + if (material) { + applyFocusHallMaterial(material, seenMaterials) + } + }) + }) + + if (!matchedMeshCount) { + clearFocusHallHighlight() + } +} + const disposeCachedOverviewModel = () => { if (!cachedOverviewModel) return @@ -840,6 +1092,7 @@ const renderRoutePreview = () => { const clearSceneData = () => { if (activeModel && scene) { + clearFocusHallHighlight() scene.remove(activeModel) if (activeModel === cachedOverviewModel) { cachedOverviewModel.visible = false @@ -1390,8 +1643,10 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => { depthWrite: false })) sprite.userData.isPoiLabel = true + sprite.userData.labelBaseScaleX = markerSize * 5.6 + sprite.userData.labelBaseScaleY = markerSize * 1.68 sprite.renderOrder = 30 - sprite.scale.set(markerSize * 5.6, markerSize * 1.68, 1) + sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1) return sprite } @@ -1501,6 +1756,7 @@ const showFocusPoiLabel = (poi: RenderPoi) => { const [x, y, z] = poi.positionGltf activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize) activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z) + updateFocusLabelScale() poiGroup.add(activeFocusLabelSprite) } @@ -1529,6 +1785,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => { } const showFocusPoiAffordances = (poi: RenderPoi) => { + showFocusHallHighlight(poi) showFocusPoiBase(poi) showFocusPoiPulse(poi) showFocusPoiLabel(poi) @@ -1552,6 +1809,28 @@ const getPoiMarkerSize = () => ( : 3 ) +const getFocusLabelScaleBoost = () => { + if (!controls || !activeModel) return 1 + + const distanceRatio = controls.getDistance() / getActiveModelSpan() + return Math.min(2.25, Math.max(1, distanceRatio / 0.36)) +} + +const updateFocusLabelScale = () => { + if (!activeFocusLabelSprite) return + + const userData = getPoiSpriteUserData(activeFocusLabelSprite) + const baseScaleX = typeof userData.labelBaseScaleX === 'number' + ? userData.labelBaseScaleX + : activeFocusLabelSprite.scale.x + const baseScaleY = typeof userData.labelBaseScaleY === 'number' + ? userData.labelBaseScaleY + : activeFocusLabelSprite.scale.y + const scaleBoost = getFocusLabelScaleBoost() + + activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1) +} + const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => { const userData = getPoiSpriteUserData(sprite) const baseScale = typeof userData.baseScale === 'number' @@ -1603,7 +1882,8 @@ const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | primaryCategory: 'target_preview', primaryCategoryZh: request.primaryCategoryZh || '位置预览', iconType: 'target_preview', - positionGltf: request.positionGltf + positionGltf: request.positionGltf, + sourceObjectName: request.sourceObjectName } } @@ -1654,6 +1934,7 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { const clearMapSelection = (shouldEmit = true) => { activeFocusPoiId.value = '' selectedPOI.value = null + clearFocusHallHighlight() disposeFocusLabel() disposeFocusPulse() disposeFocusBase() @@ -1779,7 +2060,12 @@ const focusCameraOnPoi = (poi: RenderPoi) => { ? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()) : new THREE.Vector3(80, 80, 80) const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1) - const distance = Math.min(Math.max(maxDim * 0.22, 28), Math.max(maxDim * 0.55, 80)) + const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1) + const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4) + const distance = Math.min( + Math.max(maxDim * focusDistanceFactor, 28), + Math.max(maxDim * focusMaxDistanceFactor, 80) + ) const direction = new THREE.Vector3(0.72, 0.58, 1).normalize() camera.position.copy(target).add(direction.multiplyScalar(distance)) @@ -2036,6 +2322,10 @@ watch(() => props.modelSource, () => { init3DScene() }) +watch(() => [props.showControls, props.touchGestureMode], () => { + syncControlInteractionOptions() +}) + watch(() => props.targetFocus, (request) => { queueTargetFocus(request || null) }, { diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index f1631f4..fecdf80 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -25,6 +25,8 @@ :show-controls="false" :show-poi="shouldShowIndoorPois" :target-focus="targetFocusRequest" + :touch-gesture-mode="touchGestureMode" + :target-focus-distance-factor="targetFocusDistanceFactor" :route-preview="routePreview" :show-route="showRoute" @floor-change="handleThreeFloorChange" @@ -216,6 +218,7 @@ interface TargetPoiFocusRequest { floorLabel?: string primaryCategoryZh?: string positionGltf?: [number, number, number] + sourceObjectName?: string } interface TargetPoiFocusResult { @@ -228,6 +231,7 @@ interface TargetPoiFocusResult { type IndoorViewMode = 'overview' | 'floor' | 'multi' type LayerDisplayMode = 'single' | 'multi' +type TouchGestureMode = 'orbit' | 'pan' const props = withDefaults(defineProps<{ searchText?: string @@ -267,6 +271,8 @@ const props = withDefaults(defineProps<{ normalizeFloorId?: (labelOrId: string) => string dimmed?: boolean targetFocusRequest?: TargetPoiFocusRequest | null + touchGestureMode?: TouchGestureMode + targetFocusDistanceFactor?: number routePreview?: GuideRouteResult | null showRoute?: boolean }>(), { @@ -307,6 +313,8 @@ const props = withDefaults(defineProps<{ normalizeFloorId: (labelOrId: string) => labelOrId, dimmed: false, targetFocusRequest: null, + touchGestureMode: 'orbit', + targetFocusDistanceFactor: 0.22, routePreview: null, showRoute: false }) diff --git a/src/components/navigation/GuidePageFrame.vue b/src/components/navigation/GuidePageFrame.vue index 1a2bb81..c5d7793 100644 --- a/src/components/navigation/GuidePageFrame.vue +++ b/src/components/navigation/GuidePageFrame.vue @@ -1,6 +1,10 @@ diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 14fc199..ef5f703 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -1,9 +1,10 @@ @@ -227,9 +205,9 @@ import RoutePlannerPanel from '@/components/navigation/RoutePlannerPanel.vue' import type { RoutePointOption } from '@/components/navigation/RoutePointPicker.vue' -import ExplainList, { type Exhibit } from '@/components/explain/ExplainList.vue' -import FloatingAudioButton from '@/components/audio/FloatingAudioButton.vue' -import AudioPlayer, { type AudioItem } from '@/components/audio/AudioPlayer.vue' +import ExplainHallSelect, { + type ExplainHallSelectItem +} from '@/components/explain/ExplainHallSelect.vue' import { getLastGuideLocationPreviewUrl, isGuideTopTab, @@ -244,15 +222,13 @@ import { import { explainUseCase } from '@/usecases/explainUseCase' -import { - toExplainCardViewModel -} from '@/view-models/explainViewModels' import type { GuideRenderPoi } from '@/domain/guideModel' import type { GuideRouteResult, GuideRouteTarget, + MuseumExhibit, MuseumFloor } from '@/domain/museum' @@ -355,11 +331,7 @@ const routeSimulationStepText = computed(() => { return hasConnector ? '路线示意 · 跨楼层连接点' : '路线示意 · 同层位置关系' }) -// 音频播放器状态 -const showAudioPlayer = ref(false) -const isAudioPlaying = ref(false) -const currentAudio = ref(null) -const explainExhibits = ref([]) +const explainHallItems = ref([]) const explainLoading = ref(false) const explainError = ref('') let explainLoadPromise: Promise | null = null @@ -469,7 +441,7 @@ const loadGuideFloors = async () => { } const loadExplainExhibits = async () => { - if (explainExhibits.value.length) return + if (explainHallItems.value.length) return if (explainLoadPromise) return explainLoadPromise explainLoading.value = true @@ -477,12 +449,15 @@ const loadExplainExhibits = async () => { explainLoadPromise = (async () => { try { - const exhibits = await explainUseCase.listExplainExhibits() - explainExhibits.value = exhibits.map(toExplainCardViewModel) + const [halls, exhibits] = await Promise.all([ + explainUseCase.listHalls(), + explainUseCase.listExplainExhibits() + ]) + explainHallItems.value = buildExplainHallItems(halls, exhibits) } catch (error) { console.error('加载讲解内容失败:', error) explainError.value = '讲解内容加载失败,请稍后重试' - explainExhibits.value = [] + explainHallItems.value = [] } finally { explainLoading.value = false explainLoadPromise = null @@ -492,6 +467,68 @@ const loadExplainExhibits = async () => { return explainLoadPromise } +const normalizeExplainKeyword = (value?: string) => (value || '').trim().toLowerCase() + +const buildExplainHallItems = ( + halls: Awaited>, + exhibits: MuseumExhibit[] +): ExplainHallSelectItem[] => { + const countsByHallId = new Map() + const countsByHallName = new Map() + const keywordsByHallId = new Map>() + const keywordsByHallName = new Map>() + + exhibits.forEach((exhibit) => { + const keywordParts = [ + exhibit.name, + exhibit.hallName, + exhibit.floorLabel, + exhibit.description, + exhibit.guideTitle, + exhibit.guideText, + ...(exhibit.tags || []) + ].filter(Boolean) as string[] + + if (exhibit.hallId) { + countsByHallId.set(exhibit.hallId, (countsByHallId.get(exhibit.hallId) || 0) + 1) + const keywords = keywordsByHallId.get(exhibit.hallId) || new Set() + keywordParts.forEach((part) => keywords.add(part)) + keywordsByHallId.set(exhibit.hallId, keywords) + } + + if (exhibit.hallName) { + countsByHallName.set(exhibit.hallName, (countsByHallName.get(exhibit.hallName) || 0) + 1) + const keywords = keywordsByHallName.get(exhibit.hallName) || new Set() + keywordParts.forEach((part) => keywords.add(part)) + keywordsByHallName.set(exhibit.hallName, keywords) + } + }) + + return halls.map((hall) => { + const explainCount = countsByHallId.get(hall.id) + || countsByHallName.get(hall.name) + || hall.exhibitCount + || 0 + const keywordParts = [ + hall.name, + hall.floorLabel, + hall.description, + hall.area, + ...Array.from(keywordsByHallId.get(hall.id) || []), + ...Array.from(keywordsByHallName.get(hall.name) || []) + ].filter(Boolean) as string[] + + return { + id: hall.id, + name: hall.name, + floorLabel: hall.floorLabel, + image: hall.image, + explainCount, + searchText: normalizeExplainKeyword(keywordParts.join(' ')) + } + }) +} + const handleGuideSearchTap = () => { uni.navigateTo({ url: '/pages/search/index' @@ -843,131 +880,21 @@ const guideSearchText = computed(() => { return '请输入地点进行搜索' }) -// 音频相关处理 -const handleAudioClick = async (exhibit: Exhibit) => { - if (exhibit.audioUrl) { - currentAudio.value = { - id: `media-${exhibit.id}`, - name: exhibit.title, - audioUrl: exhibit.audioUrl, - image: exhibit.coverImage, - duration: exhibit.audioDuration - } - showAudioPlayer.value = true - return - } - - const selection = await explainUseCase.selectAudioForExhibit(exhibit.id) - - if (!selection?.playable || !selection.media?.url) { - uni.showToast({ - title: selection?.unavailableMessage || exhibit.audioStatusText || '该讲解暂无可播放音频', - icon: 'none' - }) - return - } - - currentAudio.value = { - id: selection.media.id, - name: selection.track?.title || selection.exhibit.name, - audioUrl: selection.media.url, - image: selection.exhibit.image, - duration: selection.media.duration - } - showAudioPlayer.value = true -} - -const handleFloatingButtonClick = () => { - if (showAudioPlayer.value) { - showAudioPlayer.value = false - } else if (currentAudio.value) { - showAudioPlayer.value = true - } else { - uni.showToast({ - title: '请先选择一个讲解内容', - icon: 'none' - }) - } -} - -const clearAudioState = () => { - showAudioPlayer.value = false - isAudioPlaying.value = false - currentAudio.value = null -} - -const handleAudioVisibleChange = (visible: boolean) => { - showAudioPlayer.value = visible - if (!visible) { - isAudioPlaying.value = false - currentAudio.value = null - } -} - -const handleAudioPlay = (audio: AudioItem) => { - console.log('开始播放:', audio) - isAudioPlaying.value = true -} - -const handleAudioPause = (audio: AudioItem) => { - console.log('暂停播放:', audio) - isAudioPlaying.value = false -} - -const handleAudioEnded = (audio: AudioItem) => { - console.log('播放结束:', audio) - clearAudioState() -} - -const handleAudioError = (_audio: AudioItem | null, message: string) => { - console.warn('讲解音频不可播放:', message) - clearAudioState() -} - // 讲解页面处理 -const handleExplainExhibitClick = (exhibit: Exhibit) => { - uni.navigateTo({ - url: `/pages/exhibit/detail?id=${exhibit.id}&tab=explain` - }) -} - -const handleExplainFeaturedClick = async (exhibit: Exhibit) => { - await handleAudioClick(exhibit) -} - const handleExplainHallClick = (hallId: string) => { uni.navigateTo({ url: `/pages/hall/detail?id=${encodeURIComponent(hallId)}&tab=explain` }) } -const handleExplainBrowseAll = () => { - uni.navigateTo({ - url: '/pages/explain/list' - }) -} - -const handleExplainLocationClick = async (exhibit: Exhibit) => { - if (!exhibit.poiId) { - uni.showToast({ - title: '该讲解暂无三维位置数据', - icon: 'none' - }) - 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` - }) +const handleExplainBack = () => { + currentTab.value = 'guide' + syncTopTabToUrl('guide') + loadTopTabData('guide') + showGuideMoreMenu.value = false + showRoutePlanner.value = false + isSimulatingRoute.value = false + isPoiCardCollapsed.value = false } @@ -1187,7 +1114,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => { .route-sim-top-pill { position: absolute; - top: 154px; + top: 126px; left: 50%; max-width: calc(100% - 40px); height: 34px; @@ -1263,7 +1190,7 @@ const handleExplainLocationClick = async (exhibit: Exhibit) => { .entrance-tip { position: absolute; - top: 154px; + top: 126px; left: 30px; width: 168px; height: 42px; diff --git a/src/pages/route/detail.vue b/src/pages/route/detail.vue index a3ae922..5d8e512 100644 --- a/src/pages/route/detail.vue +++ b/src/pages/route/detail.vue @@ -94,15 +94,6 @@ - - 室外地图预览:{{ route.target }} @@ -126,7 +117,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import GuideMapShell from '@/components/navigation/GuideMapShell.vue' -import LocationPreview from '@/components/navigation/LocationPreview.vue' import { guideUseCase } from '@/usecases/guideUseCase' @@ -226,6 +216,7 @@ const shellConfig = computed(() => { tools: [], showSearch: true, showFloor: false, + showModeRow: false, modeTop: '104px', modeLayout: 'full' as const, mapType: 'indoor' as const @@ -243,6 +234,7 @@ const shellConfig = computed(() => { tools: [], showSearch: true, showFloor: false, + showModeRow: false, modeTop: '104px', modeLayout: 'full' as const, mapType: 'indoor' as const, @@ -261,6 +253,7 @@ const shellConfig = computed(() => { tools: [], showSearch: true, showFloor: false, + showModeRow: false, modeTop: '104px', modeLayout: 'status' as const, modeStatus: '室外参考', @@ -279,9 +272,12 @@ const shellConfig = computed(() => { tools: ['回正'], showSearch: true, showFloor: true, + showModeRow: false, modeTop: '104px', modeLayout: 'full' as const, - mapType: 'indoor' as const + mapType: 'indoor' as const, + touchGestureMode: 'pan' as const, + targetFocusDistanceFactor: 0.86 } }) @@ -360,7 +356,8 @@ const requestTargetFocus = (target: Partial | null, fallbac floorId: target.floorId, floorLabel, primaryCategoryZh: target.primaryCategoryZh, - positionGltf: target.positionGltf + positionGltf: target.positionGltf, + sourceObjectName: target.sourceObjectName } activeFloor.value = floorLabel @@ -477,29 +474,6 @@ const handleSearchTap = () => { }) } -const handleViewOutdoorMap = () => { - isManualLocationPanelOpen.value = false - routeViewState.value = 'outdoor-preview' - saveCurrentLocationPreviewUrl() -} - -const handleShowTargetLocation = (target: Partial | null) => { - const focused = requestTargetFocus(target || route.value.targetLocation) - - if (!focused) { - uni.showToast({ - title: '目标暂无三维位置数据', - icon: 'none' - }) - return - } - - uni.showToast({ - title: '已显示三维位置', - icon: 'none' - }) -} - const handleTargetFocus = (result: TargetPoiFocusResult) => { if (result.status === 'focused') return diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index fa93bc2..60fc068 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -20,7 +20,8 @@ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({ primaryCategory: poi.primaryCategory, primaryCategoryZh: poi.primaryCategoryZh, iconType: poi.iconType || poi.primaryCategory, - positionGltf: poi.positionGltf + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName }) export interface GuideModelRepository extends GuideModelSource {} diff --git a/src/repositories/GuideRepository.ts b/src/repositories/GuideRepository.ts index 5347048..654956a 100644 --- a/src/repositories/GuideRepository.ts +++ b/src/repositories/GuideRepository.ts @@ -47,7 +47,8 @@ const toLocationPreview = (poi: MuseumPoi): GuideLocationPreview => ({ floorId: poi.floorId, floorLabel: poi.floorLabel, primaryCategoryZh: poi.primaryCategory.label, - positionGltf: poi.positionGltf + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName }) export interface GuideRepository { diff --git a/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/app_nav_manifest.json b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/app_nav_manifest.json index 04934f0..917ae05 100644 --- a/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/app_nav_manifest.json +++ b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/app_nav_manifest.json @@ -45,57 +45,57 @@ }, "assets": { "overviewModel": { - "asset": "web_model/current_session.glb", - "bytes": 32345596, + "asset": "web_model/current_session.display.glb", + "bytes": 14924720, "sharedModelAsset": true }, "floorModels": [ { "floorId": "L-2", "order": 0, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L-1", "order": 1, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L1", "order": 2, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L1.5", "order": 3, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L2", "order": 4, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L3", "order": 5, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L4", "order": 6, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true }, { "floorId": "L5", "order": 7, - "asset": "web_model/current_session.glb", + "asset": "web_model/current_session.display.glb", "sharedModelAsset": true } ] @@ -186,7 +186,7 @@ "notesZh": [ "该包由 2026-06-21 finalxy 中心线路网 run 生成,nav_data.json 与 route_graph.json 为权威数据源。", "前端兼容 JSON 由 nav_data.buildingPois/navAnchors 派生,页面仍通过 Provider / Adapter / Repository 读取 domain models。", - "current_session.glb 是本包唯一三维模型;楼层切换复用同一模型并按楼层加载 POI。", + "current_session.display.glb 是用户端展示模型,已移除中心线路网辅助网格;原始 current_session.glb 保留给 nav_data.json 与 route_graph.json 的数据生成/审计链路使用。", "POI positionGltf 已按 [x, y, z] -> [x, z, -y] 转换到 Three.js/GLB 坐标。", "connector_machine_verification 已记录 finalVerticalEndpointXYGate,通过的 102 个垂直连接候选才进入最终图。" ] diff --git a/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/data/floor_index.json b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/data/floor_index.json index 164956a..1ca8046 100644 --- a/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/data/floor_index.json +++ b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/data/floor_index.json @@ -11,7 +11,7 @@ "name": "L-2", "label": "B2", "zRepresentative": -14.244266, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L-2.json", "poiCount": 59 @@ -22,7 +22,7 @@ "name": "L-1", "label": "B1", "zRepresentative": -10.673778, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L-1.json", "poiCount": 47 @@ -33,7 +33,7 @@ "name": "L1", "label": "1F", "zRepresentative": 0.170108, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L1.json", "poiCount": 74 @@ -44,7 +44,7 @@ "name": "L1.5", "label": "1.5F", "zRepresentative": 5.1561, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L1.5.json", "poiCount": 21 @@ -55,7 +55,7 @@ "name": "L2", "label": "2F", "zRepresentative": 14.75636, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L2.json", "poiCount": 46 @@ -66,7 +66,7 @@ "name": "L3", "label": "3F", "zRepresentative": 16.577412, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L3.json", "poiCount": 15 @@ -77,7 +77,7 @@ "name": "L4", "label": "4F", "zRepresentative": 22.86525, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L4.json", "poiCount": 25 @@ -88,7 +88,7 @@ "name": "L5", "label": "5F", "zRepresentative": 27.10755, - "modelAsset": "web_model/current_session.glb", + "modelAsset": "web_model/current_session.display.glb", "sharedModelAsset": true, "poiDataAsset": "data/poi_by_floor/L5.json", "poiCount": 11 diff --git a/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/web_model/current_session.display.glb b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/web_model/current_session.display.glb new file mode 100644 index 0000000..d46440f Binary files /dev/null and b/static/nav-assets/codex_nav_20260621_175310_museum_default_centerline_finalxy/web_model/current_session.display.glb differ