소스 검색

添加莒口水闸视角切换

WQQ 19 시간 전
부모
커밋
1e784e55a5

+ 689 - 0
RuoYi-Vue3/src/supermap-cesium-module/components/first-person/SuperMapFirstPerson.vue

@@ -0,0 +1,689 @@
+<template>
+  <!-- 纯逻辑组件,无UI渲染 -->
+  <div class="first-person-controller" v-if="isActive">
+    <!-- 提示信息 overlay - 只在未锁定鼠标时显示 -->
+    <div 
+      v-if="showInstructions && !isPointerLocked" 
+      class="instructions-overlay"
+      @click="requestPointerLock"
+    >
+      <div class="instructions-content">
+        <h3>第一人称漫游模式</h3>
+        <p><strong>W/A/S/D</strong> 或 <strong>方向键</strong> - 移动</p>
+        <p><strong>鼠标</strong> - 控制视角</p>
+        <p><strong>ESC</strong> - 退出漫游模式</p>
+        <p class="click-hint">点击此处开始漫游</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+
+/**
+ * SuperMap 第一人称控制器组件
+ */
+export default {
+  name: 'SuperMapFirstPerson',
+  
+  props: {
+    viewer: {
+      type: Object,
+      default: null
+    },
+    enabled: {
+      type: Boolean,
+      default: false
+    },
+    initialPosition: {
+      type: Object,
+      default: null
+    },
+    moveSpeed: {
+      type: Number,
+      default: 5.0
+    },
+    speedMultiplier: {
+      type: Number,
+      default: 2.0
+    },
+    rotationSensitivity: {
+      type: Number,
+      default: 0.002
+    },
+    eyeHeight: {
+      type: Number,
+      default: 1.7
+    },
+    jumpHeight: {
+      type: Number,
+      default: 2.0
+    },
+    collisionDistance: {
+      type: Number,
+      default: 1.0
+    },
+    enableCollision: {
+      type: Boolean,
+      default: true
+    },
+    enableGroundFollow: {
+      type: Boolean,
+      default: true
+    },
+    showInstructions: {
+      type: Boolean,
+      default: true
+    }
+  },
+  
+  emits: ['enter', 'exit', 'position-change', 'collision', 'error'],
+  
+  setup(props, { emit }) {
+    // 状态
+    const isActive = ref(false)
+    const isPointerLocked = ref(false)
+    
+    // 输入状态
+    const keyState = ref({
+      forward: false,
+      backward: false,
+      left: false,
+      right: false,
+      shift: false
+    })
+    
+    // 跳跃状态
+    const jumpState = ref({
+      isJumping: false,
+      jumpVelocity: 0,
+      baseHeight: 0
+    })
+    
+    // 内部状态
+    let _originalCameraState = null
+    let _animationFrameId = null
+    let _lastUpdateTime = 0
+    
+    // 获取 Cesium(延迟获取)
+    const getCesium = () => {
+      const Cesium = window.Cesium
+      if (!Cesium) {
+        console.error('Cesium 未加载')
+        return null
+      }
+      return Cesium
+    }
+    
+    // 计算属性
+    const actualMoveSpeed = computed(() => {
+      const baseSpeed = props.moveSpeed
+      return keyState.value.shift ? baseSpeed * props.speedMultiplier : baseSpeed
+    })
+    
+    /**
+     * 初始化第一人称控制器
+     */
+    const initController = () => {
+      const Cesium = getCesium()
+      if (!Cesium) {
+        emit('error', 'Cesium 未加载')
+        return false
+      }
+      
+      const viewer = props.viewer || window.viewer
+      if (!viewer) {
+        emit('error', 'Viewer 实例未传入')
+        return false
+      }
+      
+      try {
+        console.log('🎮 初始化第一人称控制器...')
+        
+        // 保存原始相机状态
+        _originalCameraState = {
+          position: viewer.camera.position.clone(),
+          heading: viewer.camera.heading,
+          pitch: viewer.camera.pitch,
+          roll: viewer.camera.roll
+        }
+        
+        // 设置初始位置
+        if (props.initialPosition) {
+          try {
+            const pos = props.initialPosition
+            const lon = pos.lon || pos.longitude
+            const lat = pos.lat || pos.latitude
+            const height = pos.height || pos.altitude || 35
+            const heading = Cesium.Math.toRadians(pos.heading || 0)
+            const pitch = Cesium.Math.toRadians(pos.pitch || -10)
+            
+            const cartesian = Cesium.Cartesian3.fromDegrees(lon, lat, height)
+            
+            viewer.camera.setView({
+              destination: cartesian,
+              orientation: {
+                heading: heading,
+                pitch: pitch,
+                roll: 0
+              }
+            })
+            
+            console.log(`📍 相机已定位到: ${lon}, ${lat}, ${height}`)
+          } catch (e) {
+            console.warn('设置初始位置失败:', e)
+          }
+        }
+        
+        // 禁用默认相机控制
+        viewer.scene.screenSpaceCameraController.enableRotate = false
+        viewer.scene.screenSpaceCameraController.enableTranslate = false
+        viewer.scene.screenSpaceCameraController.enableZoom = false
+        viewer.scene.screenSpaceCameraController.enableTilt = false
+        viewer.scene.screenSpaceCameraController.enableLook = false
+        
+        // 注册事件监听
+        registerEventListeners(viewer)
+        
+        // 启动更新循环
+        startUpdateLoop(Cesium, viewer)
+        
+        isActive.value = true
+        emit('enter')
+        
+        console.log('✅ 第一人称控制器初始化完成')
+        return true
+      } catch (error) {
+        console.error('❌ 初始化失败:', error)
+        emit('error', '初始化失败: ' + error.message)
+        return false
+      }
+    }
+    
+    /**
+     * 注册事件监听器
+     */
+    const registerEventListeners = (viewer) => {
+      document.addEventListener('keydown', onKeyDown)
+      document.addEventListener('keyup', onKeyUp)
+      document.addEventListener('pointerlockchange', onPointerLockChange)
+      document.addEventListener('pointerlockerror', onPointerLockError)
+      document.addEventListener('mousemove', onMouseMove)
+      
+      if (viewer && viewer.container) {
+        viewer.container.addEventListener('click', requestPointerLock)
+      }
+    }
+    
+    /**
+     * 移除事件监听器
+     */
+    const unregisterEventListeners = (viewer) => {
+      document.removeEventListener('keydown', onKeyDown)
+      document.removeEventListener('keyup', onKeyUp)
+      document.removeEventListener('pointerlockchange', onPointerLockChange)
+      document.removeEventListener('pointerlockerror', onPointerLockError)
+      document.removeEventListener('mousemove', onMouseMove)
+      
+      if (viewer && viewer.container) {
+        viewer.container.removeEventListener('click', requestPointerLock)
+      }
+    }
+    
+    /**
+     * 键盘按下事件
+     */
+    const onKeyDown = (event) => {
+      if (!isActive.value) return
+      
+      const key = event.key.toLowerCase()
+      
+      switch (key) {
+        case 'w':
+        case 'arrowup':
+          keyState.value.forward = true
+          break
+        case 's':
+        case 'arrowdown':
+          keyState.value.backward = true
+          break
+        case 'a':
+        case 'arrowleft':
+          keyState.value.left = true
+          break
+        case 'd':
+        case 'arrowright':
+          keyState.value.right = true
+          break
+        case 'shift':
+          keyState.value.shift = true
+          break
+        case 'escape':
+          exitPointerLock()
+          break
+      }
+      
+      event.preventDefault()
+    }
+    
+    /**
+     * 键盘释放事件
+     */
+    const onKeyUp = (event) => {
+      if (!isActive.value) return
+      
+      const key = event.key.toLowerCase()
+      
+      switch (key) {
+        case 'w':
+        case 'arrowup':
+          keyState.value.forward = false
+          break
+        case 's':
+        case 'arrowdown':
+          keyState.value.backward = false
+          break
+        case 'a':
+        case 'arrowleft':
+          keyState.value.left = false
+          break
+        case 'd':
+        case 'arrowright':
+          keyState.value.right = false
+          break
+        case 'shift':
+          keyState.value.shift = false
+          break
+      }
+      
+      event.preventDefault()
+    }
+    
+    /**
+     * 鼠标移动事件
+     */
+    const onMouseMove = (event) => {
+      if (!isActive.value || !isPointerLocked.value) return
+      
+      const Cesium = getCesium()
+      const viewer = props.viewer || window.viewer
+      if (!Cesium || !viewer) return
+      
+      const sensitivity = props.rotationSensitivity
+      const deltaX = event.movementX * sensitivity
+      const deltaY = event.movementY * sensitivity
+      
+      // 更新相机朝向
+      let newPitch = viewer.camera.pitch + deltaY
+      newPitch = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, newPitch))
+      
+      viewer.camera.setView({
+        orientation: {
+          heading: viewer.camera.heading + deltaX,
+          pitch: newPitch,
+          roll: 0
+        }
+      })
+    }
+    
+    /**
+     * PointerLock 状态变化
+     */
+    const onPointerLockChange = () => {
+      const viewer = props.viewer || window.viewer
+      const element = document.pointerLockElement
+      isPointerLocked.value = viewer && element === viewer.container
+      
+      if (!isPointerLocked.value && isActive.value) {
+        console.log('PointerLock 已退出')
+      }
+    }
+    
+    /**
+     * PointerLock 错误
+     */
+    const onPointerLockError = () => {
+      emit('error', 'PointerLock 获取失败')
+      ElMessage.warning('无法锁定鼠标,请点击画面重试')
+    }
+    
+    /**
+     * 请求 PointerLock
+     */
+    const requestPointerLock = () => {
+      if (!isActive.value) return
+      
+      const viewer = props.viewer || window.viewer
+      console.log('尝试锁定鼠标, viewer:', viewer)
+      
+      // 尝试锁定 viewer.container 或整个文档 body
+      let targetElement = null
+      
+      if (viewer && viewer.container) {
+        targetElement = viewer.container
+        console.log('使用 viewer.container:', targetElement)
+      } else {
+        // 使用 body 作为备选
+        targetElement = document.body
+        console.log('使用 document.body')
+      }
+      
+      try {
+        targetElement.requestPointerLock()
+        console.log('PointerLock 请求已发送')
+      } catch (error) {
+        console.error('PointerLock 请求失败:', error)
+        ElMessage.warning('无法锁定鼠标,请尝试按 ESC 后重新点击')
+      }
+    }
+    
+    /**
+     * 退出 PointerLock
+     */
+    const exitPointerLock = () => {
+      document.exitPointerLock()
+    }
+    
+    /**
+     * 更新跳跃状态
+     */
+    const updateJump = (Cesium, viewer, deltaTime) => {
+      if (!jumpState.value.isJumping) return
+      
+      const gravity = -9.8 * 0.5
+      jumpState.value.jumpVelocity += gravity * deltaTime
+      
+      const currentPos = viewer.camera.position
+      const cartographic = Cesium.Cartographic.fromCartesian(currentPos)
+      const lon = Cesium.Math.toDegrees(cartographic.longitude)
+      const lat = Cesium.Math.toDegrees(cartographic.latitude)
+      
+      let newHeight = cartographic.height + jumpState.value.jumpVelocity * deltaTime
+      const groundHeight = jumpState.value.baseHeight
+      
+      if (newHeight <= groundHeight + props.eyeHeight) {
+        newHeight = groundHeight + props.eyeHeight
+        jumpState.value.isJumping = false
+        jumpState.value.jumpVelocity = 0
+      }
+      
+      const newCartesian = Cesium.Cartesian3.fromDegrees(lon, lat, newHeight)
+      viewer.camera.setView({
+        destination: newCartesian,
+        orientation: {
+          heading: viewer.camera.heading,
+          pitch: viewer.camera.pitch,
+          roll: 0
+        }
+      })
+    }
+    
+    /**
+     * 计算移动方向
+     * Cesium 坐标系: heading=0 指北(Y正), π/2 指东(X正)
+     */
+    const calculateMoveDirection = (Cesium, viewer) => {
+      const keys = keyState.value
+      
+      if (!keys.forward && !keys.backward && !keys.left && !keys.right) {
+        return null
+      }
+      
+      const heading = viewer.camera.heading
+      
+      // 前方向(相机朝向)
+      // heading=0(北): forward=(0,1,0)
+      // heading=π/2(东): forward=(1,0,0)
+      const forward = new Cesium.Cartesian3(
+        Math.sin(heading),   // X分量
+        Math.cos(heading),   // Y分量
+        0
+      )
+      Cesium.Cartesian3.normalize(forward, forward)
+      
+      // 右方向(垂直于前方向,指向右侧)
+      // heading=0(北): right=(1,0,0) 东
+      // heading=π/2(东): right=(0,-1,0) 南
+      const right = new Cesium.Cartesian3(
+        Math.cos(heading),   // X分量
+        -Math.sin(heading),  // Y分量
+        0
+      )
+      Cesium.Cartesian3.normalize(right, right)
+      
+      // 计算移动方向
+      const moveDirection = new Cesium.Cartesian3(0, 0, 0)
+      
+      if (keys.forward) {
+        Cesium.Cartesian3.add(moveDirection, forward, moveDirection)
+      }
+      if (keys.backward) {
+        Cesium.Cartesian3.subtract(moveDirection, forward, moveDirection)
+      }
+      if (keys.right) {
+        Cesium.Cartesian3.add(moveDirection, right, moveDirection)
+      }
+      if (keys.left) {
+        Cesium.Cartesian3.subtract(moveDirection, right, moveDirection)
+      }
+      
+      if (Cesium.Cartesian3.magnitude(moveDirection) > 0) {
+        Cesium.Cartesian3.normalize(moveDirection, moveDirection)
+        return moveDirection
+      }
+      
+      return null
+    }
+    
+    /**
+     * 更新相机位置
+     * 使用经纬度计算移动,避免 Cartesian3 直接相加的问题
+     */
+    const updateCameraPosition = (Cesium, viewer, deltaTime) => {
+      const moveDirection = calculateMoveDirection(Cesium, viewer)
+      if (!moveDirection) return
+      
+      const moveDistance = actualMoveSpeed.value * deltaTime
+      
+      // 获取当前位置
+      const currentPos = viewer.camera.position
+      const currentCartographic = Cesium.Cartographic.fromCartesian(currentPos)
+      const lon = Cesium.Math.toDegrees(currentCartographic.longitude)
+      const lat = Cesium.Math.toDegrees(currentCartographic.latitude)
+      const height = currentCartographic.height
+      
+      // 将移动方向转换为经纬度增量
+      // moveDirection 是单位向量,需要转换为米/度
+      // 1度经度 ≈ 111km * cos(lat),1度纬度 ≈ 111km
+      const metersPerDegreeLat = 111000  // 纬度每度约111km
+      const metersPerDegreeLon = 111000 * Math.cos(Cesium.Math.toRadians(lat))  // 经度每度随纬度变化
+      
+      // moveDirection.x 对应经度变化(东/西)
+      // moveDirection.y 对应纬度变化(北/南)
+      const deltaLon = (moveDirection.x * moveDistance) / metersPerDegreeLon
+      const deltaLat = (moveDirection.y * moveDistance) / metersPerDegreeLat
+      
+      // 计算新位置
+      const newLon = lon + deltaLon
+      const newLat = lat + deltaLat
+      
+      // 更新相机位置
+      const finalPosition = Cesium.Cartesian3.fromDegrees(newLon, newLat, height)
+      
+      viewer.camera.setView({
+        destination: finalPosition,
+        orientation: {
+          heading: viewer.camera.heading,
+          pitch: viewer.camera.pitch,
+          roll: 0
+        }
+      })
+      
+      // 发射位置变化事件
+      emit('position-change', {
+        lon: newLon,
+        lat: newLat,
+        height: height
+      })
+    }
+    
+    /**
+     * 启动更新循环
+     */
+    const startUpdateLoop = (Cesium, viewer) => {
+      _lastUpdateTime = performance.now()
+      
+      const update = () => {
+        if (!isActive.value) return
+        
+        const currentTime = performance.now()
+        const deltaTime = (currentTime - _lastUpdateTime) / 1000
+        _lastUpdateTime = currentTime
+        
+        // 更新位置
+        updateCameraPosition(Cesium, viewer, deltaTime)
+        
+        // 触发渲染
+        viewer.scene.requestRender()
+        
+        _animationFrameId = requestAnimationFrame(update)
+      }
+      
+      _animationFrameId = requestAnimationFrame(update)
+    }
+    
+    /**
+     * 停止更新循环
+     */
+    const stopUpdateLoop = () => {
+      if (_animationFrameId) {
+        cancelAnimationFrame(_animationFrameId)
+        _animationFrameId = null
+      }
+    }
+    
+    /**
+     * 销毁控制器
+     */
+    const destroyController = () => {
+      console.log('🗑️ 销毁第一人称控制器...')
+      
+      stopUpdateLoop()
+      exitPointerLock()
+      
+      const viewer = props.viewer || window.viewer
+      if (viewer) {
+        unregisterEventListeners(viewer)
+        
+        // 恢复默认相机控制
+        viewer.scene.screenSpaceCameraController.enableRotate = true
+        viewer.scene.screenSpaceCameraController.enableTranslate = true
+        viewer.scene.screenSpaceCameraController.enableZoom = true
+        viewer.scene.screenSpaceCameraController.enableTilt = true
+        viewer.scene.screenSpaceCameraController.enableLook = true
+        
+        // 恢复原始相机状态
+        if (_originalCameraState) {
+          try {
+            viewer.camera.setView({
+              destination: _originalCameraState.position,
+              orientation: {
+                heading: _originalCameraState.heading,
+                pitch: _originalCameraState.pitch,
+                roll: _originalCameraState.roll
+              }
+            })
+          } catch (e) {
+            console.warn('恢复相机状态失败:', e)
+          }
+        }
+      }
+      
+      isActive.value = false
+      emit('exit')
+      
+      console.log('✅ 第一人称控制器已销毁')
+    }
+    
+    // 监听 enabled 属性变化
+    watch(() => props.enabled, (newVal) => {
+      if (newVal && !isActive.value) {
+        nextTick(() => {
+          initController()
+        })
+      } else if (!newVal && isActive.value) {
+        destroyController()
+      }
+    }, { immediate: true })
+    
+    // 组件卸载
+    onUnmounted(() => {
+      if (isActive.value) {
+        destroyController()
+      }
+    })
+    
+    return {
+      isActive,
+      isPointerLocked,
+      requestPointerLock,
+      exitPointerLock
+    }
+  }
+}
+</script>
+
+<style scoped>
+.first-person-controller {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 1000;
+}
+
+.instructions-overlay {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  background: rgba(0, 0, 0, 0.85);
+  color: #fff;
+  padding: 30px 40px;
+  border-radius: 12px;
+  text-align: center;
+  pointer-events: auto;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
+  max-width: 400px;
+}
+
+.instructions-content h3 {
+  margin: 0 0 20px 0;
+  font-size: 22px;
+  color: #4fc3f7;
+  font-weight: 600;
+}
+
+.instructions-content p {
+  margin: 10px 0;
+  font-size: 14px;
+  color: #e0e0e0;
+}
+
+.instructions-content p strong {
+  color: #fff;
+  font-weight: 600;
+}
+
+.click-hint {
+  margin-top: 25px;
+  padding-top: 15px;
+  border-top: 1px solid rgba(255, 255, 255, 0.2);
+  color: #ffd54f;
+  font-size: 16px;
+}
+</style>

+ 33 - 0
RuoYi-Vue3/src/supermap-cesium-module/components/first-person/index.js

@@ -0,0 +1,33 @@
+/**
+ * SuperMap 第一人称控制器组件
+ * 
+ * 使用示例:
+ * 
+ * import SuperMapFirstPerson from '@/supermap-cesium-module/components/first-person'
+ * 
+ * // 在 Vue 组件中:
+ * <SuperMapFirstPerson 
+ *   :viewer="viewer"
+ *   :enabled="isFirstPersonMode"
+ *   :initial-position="{ lon: 119.14, lat: 25.87, height: 30, heading: 0, pitch: -10 }"
+ *   :move-speed="5"
+ *   :enable-collision="true"
+ *   :enable-ground-follow="true"
+ *   @enter="onEnterFirstPerson"
+ *   @exit="onExitFirstPerson"
+ *   @position-change="onPositionChange"
+ * />
+ * 
+ * // 或者手动控制:
+ * <SuperMapFirstPerson ref="firstPersonController" :viewer="viewer" />
+ * 
+ * // 然后在代码中:
+ * this.$refs.firstPersonController.start()
+ * this.$refs.firstPersonController.stop()
+ * this.$refs.firstPersonController.setPosition({ lon: 119.14, lat: 25.87, height: 30 })
+ */
+
+import SuperMapFirstPerson from './SuperMapFirstPerson.vue'
+
+export default SuperMapFirstPerson
+export { SuperMapFirstPerson }

+ 6 - 3
RuoYi-Vue3/src/supermap-cesium-module/components/gate-control/GateControl.vue

@@ -170,6 +170,7 @@ export default {
         window.gateControlInstance = null;
       }
     });
+    
     // 闸门列表
     const gates = ref([])
     const gateListCollapsed = ref(false)
@@ -346,13 +347,14 @@ export default {
 <style scoped>
 .gate-control-panel {
   position: absolute !important;
-  width: 320px;
-  max-height: calc(100% - 15px);  /* 不超过父容器高度 */
+  width: 340px;
+  max-height: calc(100vh - 60px);
   top: 30px !important;
   right: 20px !important;
   left: auto !important;
   padding: 15px;
   overflow-y: auto;
+  box-sizing: border-box;
 }
 
 .sm-panel-header {
@@ -449,8 +451,9 @@ export default {
   display: flex;
   flex-direction: column;
   gap: 12px;
-  max-height: 600px;
+  max-height: 400px;
   overflow-y: auto;
+  padding-right: 4px;
 }
 
 .gate-item {

+ 381 - 0
RuoYi-Vue3/src/supermap-cesium-module/components/ju-kou-scene/JuKouShuiZhaPage.vue

@@ -0,0 +1,381 @@
+<template>
+  <div class="ju-kou-scene-page">
+    <!-- 左侧视角切换按钮 -->
+    <div class="view-switch-panel">
+      <div class="view-switch-buttons">
+        <el-button 
+          :type="viewMode === 'thirdPerson' ? 'primary' : 'default'"
+          @click="switchToThirdPerson"
+          class="view-btn"
+          :title="viewMode === 'thirdPerson' ? '当前:第三人称' : '切换到第三人称'"
+        >
+          <el-icon><View /></el-icon>
+        </el-button>
+        <el-button 
+          :type="viewMode === 'firstPerson' ? 'primary' : 'default'"
+          @click="switchToFirstPerson"
+          class="view-btn"
+          :title="viewMode === 'firstPerson' ? '当前:第一人称' : '切换到第一人称'"
+        >
+          <el-icon><User /></el-icon>
+        </el-button>
+      </div>
+      <!-- 第一人称模式提示 -->
+      <div v-if="viewMode === 'firstPerson'" class="mode-hint">
+        <span>WASD移动 | ESC退出</span>
+      </div>
+    </div>
+
+    <!-- 右侧闸门控制面板 -->
+    <div class="right-panel">
+      <GateControl
+        ref="gateControlRef"
+        :visible="showGateControl"
+        :initial-position="gatePosition"
+        @close="showGateControl = false"
+        @position-change="handleGatePositionChange"
+      />
+    </div>
+
+    <!-- 第一人称漫游控制器 -->
+    <SuperMapFirstPerson
+      ref="firstPersonRef"
+      :viewer="viewer || window.viewer"
+      :enabled="viewMode === 'firstPerson'"
+      :initial-position="firstPersonPosition"
+      :move-speed="5"
+      :enable-collision="true"
+      :enable-ground-follow="true"
+      :show-instructions="true"
+      @enter="onFirstPersonEnter"
+      @exit="onFirstPersonExit"
+      @position-change="onFirstPersonPositionChange"
+    />
+
+    <!-- 场景加载提示 -->
+    <div v-if="isLoading" class="loading-overlay">
+      <el-icon class="loading-icon"><Loading /></el-icon>
+      <span>正在加载场景...</span>
+    </div>
+  </div>
+</template>
+
+<script>
+import { ref, onMounted, onUnmounted, watch } from 'vue'
+import { View, User, Loading } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import GateControl from '../gate-control/GateControl.vue'
+import SuperMapFirstPerson from '../first-person/SuperMapFirstPerson.vue'
+import { 
+  loadJuKouScene, 
+  unloadJuKouScene, 
+  getController, 
+  JuKouSceneConfig
+} from '../../business-scenes/JuKouShuiZhaScene.js'
+
+/**
+ * 莒口水闸业务场景独立页面组件
+ * 
+ * 功能:
+ * - 视角切换(第三人称/第一人称)
+ * - 闸门启闭控制
+ */
+export default {
+  name: 'JuKouShuiZhaPage',
+  
+  components: {
+    GateControl,
+    SuperMapFirstPerson,
+    View,
+    User,
+    Loading
+  },
+  
+  props: {
+    viewer: {
+      type: Object,
+      default: null
+    },
+    visible: {
+      type: Boolean,
+      default: false
+    }
+  },
+  
+  emits: ['scene-loaded', 'scene-unloaded', 'view-change', 'gate-change'],
+  
+  setup(props, { emit }) {
+    const isLoading = ref(false)
+    const viewMode = ref('thirdPerson')
+    const showGateControl = ref(true)
+    const gatePosition = ref(0)
+    
+    const firstPersonPosition = ref({
+      lon: 119.144412,
+      lat: 25.866431,
+      height: 35,
+      heading: 0,
+      pitch: -10
+    })
+    
+    const gateControlRef = ref(null)
+    const firstPersonRef = ref(null)
+    
+    let thirdPersonCameraState = null
+    const isInitialized = ref(false)
+    
+    const loadScene = async () => {
+      if (!props.viewer) {
+        ElMessage.warning('Viewer 未初始化')
+        return
+      }
+      
+      isLoading.value = true
+      
+      try {
+        await loadJuKouScene(props.viewer, {
+          initialPosition: gatePosition.value,
+          onGateNodesReady: (gateNames) => {
+            if (gateControlRef.value) {
+              gateControlRef.value.updateGateList(gateNames)
+            }
+            isInitialized.value = true
+            emit('scene-loaded')
+          },
+          onSceneReady: (entity) => {
+            console.log('✅ 场景已加载:', entity)
+          },
+          onError: (error) => {
+            console.error('❌ 场景加载失败:', error)
+            ElMessage.error('场景加载失败: ' + error)
+            isLoading.value = false
+          }
+        })
+        
+        isLoading.value = false
+      } catch (error) {
+        console.error('加载场景异常:', error)
+        ElMessage.error('加载场景异常')
+        isLoading.value = false
+      }
+    }
+    
+    const unloadScene = () => {
+      if (viewMode.value === 'firstPerson') {
+        viewMode.value = 'thirdPerson'
+      }
+      
+      unloadJuKouScene()
+      isInitialized.value = false
+      emit('scene-unloaded')
+    }
+    
+    const switchToThirdPerson = () => {
+      if (viewMode.value === 'thirdPerson') return
+      
+      viewMode.value = 'thirdPerson'
+      restoreThirdPersonCamera()
+      emit('view-change', { mode: 'thirdPerson' })
+    }
+    
+    const switchToFirstPerson = () => {
+      if (viewMode.value === 'firstPerson') return
+      
+      // 使用 props.viewer 或 window.viewer
+      const viewer = props.viewer || window.viewer
+      if (!viewer) {
+        ElMessage.warning('Viewer 未初始化')
+        return
+      }
+      
+      console.log('🔄 切换到第一人称视角...')
+      
+      saveThirdPersonCamera(viewer)
+      viewMode.value = 'firstPerson'
+      emit('view-change', { mode: 'firstPerson', position: firstPersonPosition.value })
+      ElMessage.success('已进入第一人称漫游,点击画面开始')
+    }
+    
+    const saveThirdPersonCamera = (viewer) => {
+      if (!viewer) return
+      thirdPersonCameraState = {
+        position: viewer.camera.position.clone(),
+        heading: viewer.camera.heading,
+        pitch: viewer.camera.pitch,
+        roll: viewer.camera.roll
+      }
+      console.log('📸 第三人称相机状态已保存')
+    }
+    
+    const restoreThirdPersonCamera = () => {
+      const viewer = props.viewer || window.viewer
+      if (!viewer || !thirdPersonCameraState) return
+      
+      viewer.camera.setView({
+        destination: thirdPersonCameraState.position,
+        orientation: {
+          heading: thirdPersonCameraState.heading,
+          pitch: thirdPersonCameraState.pitch,
+          roll: thirdPersonCameraState.roll
+        }
+      })
+      console.log('📸 第三人称相机状态已恢复')
+    }
+    
+    const handleGatePositionChange = (data) => {
+      const controller = getController()
+      if (!controller || !controller.isInitialized) return
+      
+      if (typeof data === 'number') {
+        gatePosition.value = data
+        controller.setAllGatePosition(data)
+      } else if (data && data.gateName && typeof data.position === 'number') {
+        controller.setSingleGatePosition(data.gateName, data.position)
+      }
+      
+      emit('gate-change', data)
+    }
+    
+    const onFirstPersonEnter = () => {
+      console.log('✅ 进入第一人称漫游模式')
+    }
+    
+    const onFirstPersonExit = () => {
+      viewMode.value = 'thirdPerson'
+    }
+    
+    const onFirstPersonPositionChange = (position) => {}
+    
+    watch(() => props.visible, (newVal) => {
+      if (newVal && !isInitialized.value) {
+        loadScene()
+      } else if (!newVal && isInitialized.value) {
+        unloadScene()
+      }
+    }, { immediate: true })
+    
+    onUnmounted(() => {
+      if (isInitialized.value) {
+        unloadScene()
+      }
+    })
+    
+    return {
+      isLoading,
+      viewMode,
+      showGateControl,
+      gatePosition,
+      firstPersonPosition,
+      gateControlRef,
+      firstPersonRef,
+      loadScene,
+      unloadScene,
+      switchToThirdPerson,
+      switchToFirstPerson,
+      handleGatePositionChange,
+      onFirstPersonEnter,
+      onFirstPersonExit,
+      onFirstPersonPositionChange
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ju-kou-scene-page {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 100;
+}
+
+/* 左侧视角切换面板 */
+.view-switch-panel {
+  position: absolute;
+  top: 80px;
+  left: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  pointer-events: auto;
+}
+
+/* 视角切换按钮组 */
+.view-switch-buttons {
+  display: flex;
+  flex-direction: row;
+  gap: 8px;
+}
+
+.view-btn {
+  width: 44px;
+  height: 44px;
+  padding: 0;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+/* 右侧闸门控制面板需要可点击 */
+.right-panel {
+  position: absolute;
+  top: 30px;
+  right: 20px;
+  pointer-events: auto;
+}
+
+/* 加载提示可点击 */
+.loading-overlay {
+  pointer-events: auto;
+}
+
+.view-btn .el-icon {
+  font-size: 20px;
+}
+
+.mode-hint {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 2px;
+  padding: 8px 10px;
+  background: rgba(255, 255, 255, 0.95);
+  border-radius: 6px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+  color: #d48806;
+  font-size: 11px;
+  text-align: center;
+  line-height: 1.3;
+}
+
+/* 加载提示 */
+.loading-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 15px;
+  color: #fff;
+  font-size: 16px;
+  pointer-events: auto;
+  z-index: 1000;
+}
+
+.loading-icon {
+  font-size: 40px;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+</style>

+ 14 - 0
RuoYi-Vue3/src/supermap-cesium-module/components/ju-kou-scene/index.js

@@ -0,0 +1,14 @@
+/**
+ * 莒口水闸业务场景页面组件
+ * 
+ * 独立管理莒口水闸场景的UI布局,包括:
+ * - 视角切换(第三人称/第一人称)
+ * - 闸门启闭控制
+ * - 场景信息展示
+ * - 快捷操作
+ */
+
+import JuKouShuiZhaPage from './JuKouShuiZhaPage.vue'
+
+export default JuKouShuiZhaPage
+export { JuKouShuiZhaPage }

+ 12 - 2
RuoYi-Vue3/src/supermap-cesium-module/config/fixedScenes.js

@@ -10,14 +10,24 @@ export const fixedScenes = [
     sceneId: JuKouSceneConfig.sceneId,
     sceneName: JuKouSceneConfig.sceneName,
     sceneType: 'fixed',
-    description: '莒口水闸固定场景,包含闸门启闭控制功能',
-    // 相机配置(用于视口跳转)
+    description: '莒口水闸固定场景,包含闸门启闭控制功能和第一人称漫游',
+    // 第三人称相机配置(用于视口跳转)
     thirdPersonCameraPos: JSON.stringify(JuKouSceneConfig.camera.position),
     cameraDirection: JSON.stringify(JuKouSceneConfig.camera.direction),
     cameraUp: JSON.stringify(JuKouSceneConfig.camera.up),
+    // 第一人称相机配置(用于第一人称漫游初始位置)
+    firstPersonCameraPos: JSON.stringify({
+      lon: 119.144008,
+      lat: 25.867707,
+      height: 35,
+      heading: 0,
+      pitch: -10
+    }),
     // 闸门控制配置
     enableGateControl: true,
     initialGatePosition: JuKouSceneConfig.initialGatePosition,
+    // 第一人称漫游配置
+    enableFirstPerson: true,
     // 模型配置(从 JuKouSceneConfig 引用,用于显示在配置界面)
     loadedModels: JSON.stringify([JuKouSceneConfig.model]),
     // 是否禁用模型变换弹框

+ 38 - 47
RuoYi-Vue3/src/supermap-cesium-module/views/layout/aside.vue

@@ -373,14 +373,24 @@
         @close="mvtPopupVisible = false"
       />
 
-      <!-- 闸门启闭控制组件 -->
+      <!-- 闸门启闭控制组件(非莒口水闸场景使用) -->
       <GateControl
+        v-if="!showJuKouScenePage"
         ref="gateControl"
         :visible="showGateControl"
         :initial-position="gatePosition"
         @close="showGateControl = false"
         @position-change="handleGatePositionChange"
       />
+      
+      <!-- 莒口水闸业务场景独立页面组件 -->
+      <JuKouShuiZhaPage
+        v-if="showJuKouScenePage"
+        :viewer="viewer"
+        :visible="showJuKouScenePage"
+        @scene-loaded="onJuKouSceneLoaded"
+        @scene-unloaded="onJuKouSceneUnloaded"
+      />
     </el-main>
   </el-container>
 </template>
@@ -409,6 +419,7 @@ import ModelEditor from "../../components/draw/draw-solid-figure/ModelEditor";
 import MvtAttributePopup from "../../components/MvtAttributePopup.vue";  //MVT属性弹窗组件
 import MvtClickHandler from "../../js/common/mvtClickHandler.js";  //MVT点击处理器
 import GateControl from "../../components/gate-control/GateControl.vue";  //闸门启闭控制组件
+import JuKouShuiZhaPage from "../../components/ju-kou-scene/JuKouShuiZhaPage.vue";  //莒口水闸业务场景页面组件
 import { getFixedScenes, isFixedScene } from "../../config/fixedScenes.js";  //固定场景配置
 import { loadJuKouScene, unloadJuKouScene, getController, JuKouSceneConfig } from "../../business-scenes/JuKouShuiZhaScene.js";  //莒口水闸业务场景模块
 
@@ -421,7 +432,8 @@ export default {
     CesiumThreeFusion,
     ModelDisassemblyDialog,
     MvtAttributePopup,
-    GateControl
+    GateControl,
+    JuKouShuiZhaPage
   },
   setup() {
     // 使用 computed 来获取 window.viewer
@@ -466,6 +478,8 @@ export default {
       // 闸门控制相关
       showGateControl: false,  //是否显示闸门控制面板
       gatePosition: 0,  //闸门当前开度(0-100)
+      // 莒口水闸场景页面显示
+      showJuKouScenePage: false,  //是否显示莒口水闸场景页面组件
       // MVT属性弹窗相关
       mvtPopupVisible: false,  //弹窗是否可见
       mvtPopupPosition: { x: 0, y: 0 },  //弹窗位置
@@ -1443,52 +1457,16 @@ export default {
           }
         }
 
-        // 莒口水闸场景使用独立加载方法
+        // 莒口水闸场景使用独立页面组件
         if (scene.sceneId === JuKouSceneConfig.sceneId) {
-          console.log('📌 使用莒口水闸独立场景加载方法')
+          console.log('📌 使用莒口水闸独立页面组件')
           
-          await loadJuKouScene(window.viewer, {
-            initialPosition: scene.initialGatePosition || 0,
-            onGateNodesReady: (gateNames) => {
-              console.log(`✅ onGateNodesReady 回调触发,闸门数量: ${gateNames?.length || 0}`)
-              console.log('📋 闸门名称:', gateNames)
-              
-              // 显示闸门控制面板
-              this.showGateControl = true
-              this.gatePosition = scene.initialGatePosition || 0
-              console.log('🔓 showGateControl 已设置为 true')
-              
-              // 更新 GateControl 组件的闸门列表
-              // 使用多次 nextTick 确保组件渲染完成
-              this.$nextTick(() => {
-                console.log('⏱️ 第一次 $nextTick,检查组件渲染...')
-                
-                if (this.$refs.gateControl) {
-                  console.log('✅ 找到 gateControl 组件')
-                  this.$refs.gateControl.updateGateList(gateNames)
-                  console.log('✅ 闸门列表已更新')
-                } else {
-                  console.warn('⚠️ 第一次 nextTick 未找到组件,再次等待...')
-                  this.$nextTick(() => {
-                    if (this.$refs.gateControl) {
-                      console.log('✅ 第二次 nextTick 找到 gateControl 组件')
-                      this.$refs.gateControl.updateGateList(gateNames)
-                      console.log('✅ 闸门列表已更新')
-                    } else if (window.gateControlInstance) {
-                      console.log('✅ 通过全局实例更新闸门列表')
-                      window.gateControlInstance.updateGateList(gateNames)
-                    } else {
-                      console.error('❌ 无法找到 GateControl 组件或全局实例')
-                    }
-                  })
-                }
-              })
-            },
-            onError: (error) => {
-              console.error('❌ 莒口水闸场景加载失败:', error)
-              ElMessage.error('莒口水闸场景加载失败: ' + error)
-            }
-          })
+          // 显示莒口水闸页面组件
+          this.showJuKouScenePage = true
+          this.gatePosition = scene.initialGatePosition || 0
+          
+          // 等待组件加载场景
+          // 场景加载逻辑由 JuKouShuiZhaPage 组件内部处理
         } else {
           // 其他场景使用通用加载方法
           if (scene.loadedModels) {
@@ -1558,6 +1536,9 @@ export default {
       // 关闭闸门控制面板
       this.showGateControl = false
       this.gatePosition = 0
+      
+      // 关闭莒口水闸页面组件
+      this.showJuKouScenePage = false
 
       // 清除模型拆解图标配置
       this.modelIconConfig = null
@@ -1814,7 +1795,17 @@ export default {
         controller.setSingleGatePosition(data.gateName, data.position)
       }
     },
-
+    
+    // 莒口水闸场景加载完成回调
+    onJuKouSceneLoaded() {
+      console.log('✅ 莒口水闸场景页面组件加载完成')
+    },
+    
+    // 莒口水闸场景卸载完成回调
+    onJuKouSceneUnloaded() {
+      console.log('✅ 莒口水闸场景页面组件已卸载')
+      this.showJuKouScenePage = false
+    },
 
 
     // 区分地质体组件(需销毁)和其他组件