WQQ 3 недель назад
Родитель
Сommit
ccb6b6e219

+ 1 - 0
RuoYi-Vue3/package.json

@@ -29,6 +29,7 @@
     "element-plus": "2.10.7",
     "file-saver": "2.0.5",
     "fuse.js": "6.6.2",
+    "gsap": "^3.14.2",
     "js-beautify": "1.14.11",
     "js-cookie": "3.0.5",
     "jsencrypt": "3.3.2",

BIN
RuoYi-Vue3/public/models/启闭机.glb


+ 57 - 0
RuoYi-Vue3/public/test-model-console.js

@@ -0,0 +1,57 @@
+// 测试模型加载的控制台脚本
+console.log('=== 开始测试模型加载 ===');
+
+// 检查 Three.js 是否加载
+if (typeof THREE === 'undefined') {
+  console.error('Three.js 未加载');
+} else {
+  console.log('Three.js 已加载,版本:', THREE.REVISION);
+}
+
+// 检查 GLTFLoader 是否加载
+if (typeof GLTFLoader === 'undefined') {
+  console.error('GLTFLoader 未加载');
+} else {
+  console.log('GLTFLoader 已加载');
+}
+
+// 检查 Cesium 是否加载
+if (typeof window.viewer === 'undefined') {
+  console.error('Cesium viewer 未初始化');
+} else {
+  console.log('Cesium viewer 已初始化');
+}
+
+// 测试模型加载
+function testModelLoad() {
+  console.log('开始测试模型加载');
+  
+  if (typeof THREE === 'undefined' || typeof GLTFLoader === 'undefined') {
+    console.error('Three.js 或 GLTFLoader 未加载');
+    return;
+  }
+  
+  const loader = new GLTFLoader();
+  console.log('GLTFLoader 实例创建成功');
+  
+  loader.load(
+    '/models/启闭机.glb',
+    function (gltf) {
+      console.log('模型加载成功:', gltf);
+      console.log('模型场景:', gltf.scene);
+      console.log('模型部件数量:', gltf.scene.children.length);
+    },
+    function (xhr) {
+      const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
+      console.log(`加载进度: ${percent}%`);
+    },
+    function (error) {
+      console.error('模型加载失败:', error);
+    }
+  );
+}
+
+// 导出测试函数
+window.testModelLoad = testModelLoad;
+console.log('=== 测试脚本加载完成 ===');
+console.log('请在控制台中执行 testModelLoad() 来测试模型加载');

+ 187 - 0
RuoYi-Vue3/public/test-model-load.html

@@ -0,0 +1,187 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>模型加载测试</title>
+    <style>
+        body {
+            margin: 0;
+            font-family: Arial, sans-serif;
+            padding: 20px;
+        }
+        #info {
+            background: #f0f0f0;
+            padding: 20px;
+            border-radius: 5px;
+            margin-bottom: 20px;
+        }
+        #status {
+            font-weight: bold;
+            margin: 10px 0;
+        }
+        button {
+            padding: 10px 20px;
+            margin: 5px;
+            cursor: pointer;
+        }
+    </style>
+</head>
+<body>
+    <div id="info">
+        <h1>模型加载测试</h1>
+        <div id="status">准备测试...</div>
+        <button onclick="testDirectLoad()">直接加载模型</button>
+        <button onclick="testThreeJS()">Three.js加载模型</button>
+        <button onclick="testCesium()">检查Cesium</button>
+        <div id="details"></div>
+    </div>
+
+    <script>
+        const statusElement = document.getElementById('status');
+        const detailsElement = document.getElementById('details');
+
+        function updateStatus(message, isError = false) {
+            statusElement.innerHTML = message;
+            statusElement.style.color = isError ? 'red' : 'green';
+        }
+
+        function addDetails(message) {
+            const p = document.createElement('p');
+            p.textContent = message;
+            detailsElement.appendChild(p);
+        }
+
+        function testDirectLoad() {
+            updateStatus('正在直接加载模型...');
+            addDetails('开始直接加载模型测试');
+            
+            fetch('/models/启闭机.glb')
+                .then(response => {
+                    if (!response.ok) {
+                        throw new Error(`HTTP error! status: ${response.status}`);
+                    }
+                    return response.blob();
+                })
+                .then(blob => {
+                    addDetails(`模型文件大小: ${blob.size} bytes`);
+                    addDetails(`模型文件类型: ${blob.type}`);
+                    updateStatus('模型文件加载成功!');
+                })
+                .catch(error => {
+                    addDetails(`错误: ${error.message}`);
+                    updateStatus('模型文件加载失败!', true);
+                });
+        }
+
+        function testThreeJS() {
+            updateStatus('正在测试Three.js...');
+            addDetails('开始Three.js加载模型测试');
+            
+            // 检查Three.js是否加载
+            if (typeof THREE === 'undefined') {
+                addDetails('Three.js未加载');
+                updateStatus('Three.js未加载!', true);
+                return;
+            }
+            
+            addDetails(`Three.js版本: ${THREE.REVISION}`);
+            
+            // 检查GLTFLoader是否加载
+            if (typeof GLTFLoader === 'undefined') {
+                addDetails('GLTFLoader未加载');
+                updateStatus('GLTFLoader未加载!', true);
+                return;
+            }
+            
+            addDetails('GLTFLoader已加载');
+            
+            // 创建简单的场景
+            const scene = new THREE.Scene();
+            const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
+            camera.position.z = 5;
+            
+            const renderer = new THREE.WebGLRenderer();
+            renderer.setSize(window.innerWidth, window.innerHeight);
+            document.body.appendChild(renderer.domElement);
+            
+            // 添加光源
+            const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
+            scene.add(ambientLight);
+            
+            const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+            directionalLight.position.set(1, 1, 1);
+            scene.add(directionalLight);
+            
+            // 加载模型
+            const loader = new GLTFLoader();
+            addDetails('开始加载模型...');
+            
+            loader.load(
+                '/models/启闭机.glb',
+                function (gltf) {
+                    const model = gltf.scene;
+                    model.scale.set(0.5, 0.5, 0.5);
+                    scene.add(model);
+                    addDetails(`模型加载成功!部件数量: ${model.children.length}`);
+                    updateStatus('模型加载成功!');
+                    
+                    // 动画循环
+                    function animate() {
+                        requestAnimationFrame(animate);
+                        model.rotation.y += 0.01;
+                        renderer.render(scene, camera);
+                    }
+                    animate();
+                },
+                function (xhr) {
+                    const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
+                    addDetails(`加载进度: ${percent}%`);
+                },
+                function (error) {
+                    addDetails(`错误: ${error.message}`);
+                    updateStatus('模型加载失败!', true);
+                }
+            );
+        }
+
+        function testCesium() {
+            updateStatus('正在检查Cesium...');
+            addDetails('开始Cesium检查测试');
+            
+            if (typeof Cesium === 'undefined') {
+                addDetails('Cesium未加载');
+                updateStatus('Cesium未加载!', true);
+                return;
+            }
+            
+            addDetails('Cesium已加载');
+            
+            if (typeof window.viewer === 'undefined') {
+                addDetails('window.viewer未定义');
+                updateStatus('Cesium viewer未初始化!', true);
+                return;
+            }
+            
+            addDetails('Cesium viewer已初始化');
+            
+            const canvas = document.querySelector('.cesium-viewer canvas');
+            if (!canvas) {
+                addDetails('Cesium canvas未找到');
+                updateStatus('Cesium canvas未找到!', true);
+                return;
+            }
+            
+            addDetails('Cesium canvas已找到');
+            addDetails(`Canvas尺寸: ${canvas.width}x${canvas.height}`);
+            updateStatus('Cesium检查完成!');
+        }
+
+        // 页面加载完成
+        window.addEventListener('load', function() {
+            addDetails('页面加载完成');
+            addDetails('当前URL: ' + window.location.href);
+        });
+    </script>
+</body>
+</html>

+ 65 - 0
RuoYi-Vue3/public/test-model-simple.html

@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>简化模型测试</title>
+    <style>
+        body {
+            margin: 0;
+            font-family: Arial, sans-serif;
+        }
+        #info {
+            padding: 20px;
+        }
+    </style>
+</head>
+<body>
+    <div id="info">
+        <h1>模型加载测试</h1>
+        <p>测试 GLTFLoader 是否能正常工作</p>
+        <div id="status">准备测试...</div>
+        <button onclick="testModelLoad()">测试模型加载</button>
+    </div>
+    <script type="importmap">
+        {
+            "imports": {
+                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
+                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
+            }
+        }
+    </script>
+    <script type="module">
+        import * as THREE from 'three';
+        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+        
+        console.log('Three.js 版本:', THREE.REVISION);
+        console.log('GLTFLoader 加载:', GLTFLoader);
+        
+        window.testModelLoad = function() {
+            const status = document.getElementById('status');
+            status.innerHTML = '开始加载模型...';
+            
+            const loader = new GLTFLoader();
+            console.log('GLTFLoader 实例创建成功');
+            
+            loader.load(
+                '/models/启闭机.glb',
+                function (gltf) {
+                    console.log('模型加载成功:', gltf);
+                    status.innerHTML = '模型加载成功!';
+                },
+                function (xhr) {
+                    const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
+                    status.innerHTML = `加载中: ${percent}%`;
+                    console.log(`加载进度: ${percent}%`);
+                },
+                function (error) {
+                    console.error('模型加载失败:', error);
+                    status.innerHTML = '模型加载失败: ' + error.message;
+                }
+            );
+        }
+    </script>
+</body>
+</html>

+ 100 - 0
RuoYi-Vue3/public/test-model.html

@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>模型测试</title>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
+    <style>
+        body {
+            margin: 0;
+            overflow: hidden;
+        }
+        #info {
+            position: absolute;
+            top: 10px;
+            left: 10px;
+            background: rgba(255, 255, 255, 0.8);
+            padding: 10px;
+            border-radius: 5px;
+            font-family: Arial, sans-serif;
+        }
+    </style>
+</head>
+<body>
+    <div id="info">
+        <h3>模型测试</h3>
+        <p>模型路径: /models/启闭机.glb</p>
+        <div id="status">加载中...</div>
+    </div>
+    <script>
+        // 创建场景
+        const scene = new THREE.Scene();
+        scene.background = new THREE.Color(0xf0f0f0);
+        
+        // 创建相机
+        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
+        camera.position.z = 5;
+        
+        // 创建渲染器
+        const renderer = new THREE.WebGLRenderer({ antialias: true });
+        renderer.setSize(window.innerWidth, window.innerHeight);
+        document.body.appendChild(renderer.domElement);
+        
+        // 添加光源
+        const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
+        scene.add(ambientLight);
+        
+        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+        directionalLight.position.set(1, 1, 1);
+        scene.add(directionalLight);
+        
+        // 加载模型
+        const loader = new THREE.GLTFLoader();
+        const statusElement = document.getElementById('status');
+        
+        loader.load(
+            '/models/启闭机.glb',
+            function (gltf) {
+                const model = gltf.scene;
+                model.scale.set(0.5, 0.5, 0.5);
+                scene.add(model);
+                statusElement.innerHTML = '模型加载成功!';
+                console.log('模型加载成功:', gltf);
+            },
+            function (xhr) {
+                const percent = (xhr.loaded / xhr.total * 100).toFixed(0);
+                statusElement.innerHTML = `加载中: ${percent}%`;
+            },
+            function (error) {
+                statusElement.innerHTML = '模型加载失败: ' + error.message;
+                console.error('模型加载失败:', error);
+            }
+        );
+        
+        // 动画循环
+        function animate() {
+            requestAnimationFrame(animate);
+            
+            // 旋转模型
+            scene.traverse(function (object) {
+                if (object.isMesh) {
+                    object.rotation.y += 0.01;
+                }
+            });
+            
+            renderer.render(scene, camera);
+        }
+        
+        animate();
+        
+        // 响应窗口大小变化
+        window.addEventListener('resize', function () {
+            camera.aspect = window.innerWidth / window.innerHeight;
+            camera.updateProjectionMatrix();
+            renderer.setSize(window.innerWidth, window.innerHeight);
+        });
+    </script>
+</body>
+</html>

+ 941 - 0
RuoYi-Vue3/src/components/ThreeCesiumIntegration/CesiumThreeFusion.vue

@@ -0,0 +1,941 @@
+<template>
+  <div class="cesium-three-fusion">
+    <!-- 控制面板 -->
+    <div class="control-panel">
+      <el-button 
+        type="primary" 
+        :class="{ 'is-exploded': isExploded }"
+        @click="toggleExplosion"
+      >
+        <el-icon><Expand v-if="!isExploded" /><Fold v-else /></el-icon>
+        {{ isExploded ? '还原模型' : '炸开模型' }}
+      </el-button>
+      <el-button 
+        type="warning" 
+        @click="flyToModel"
+      >
+        <el-icon><Aim /></el-icon>
+        定位模型
+      </el-button>
+      <el-button 
+        type="info" 
+        @click="toggleWireframe"
+      >
+        <el-icon><Grid /></el-icon>
+        {{ isWireframe ? '实体模式' : '线框模式' }}
+      </el-button>
+    </div>
+
+    <!-- 爆炸程度滑块 -->
+    <div class="explosion-slider" v-if="isExploded || explosionFactor > 0">
+      <span class="slider-label">爆炸程度</span>
+      <el-slider 
+        v-model="explosionFactor" 
+        :min="0" 
+        :max="3" 
+        :step="0.1"
+        @input="updateExplosion"
+      />
+      <span class="slider-value">{{ explosionFactor.toFixed(1) }}</span>
+    </div>
+
+
+
+    <!-- 模型信息面板 -->
+    <div class="model-info-panel" v-if="modelLoaded">
+      <h4>启闭机模型</h4>
+      <p>经度: {{ longitude }}</p>
+      <p>纬度: {{ latitude }}</p>
+      <p>高度: {{ height }}m</p>
+      <p>部件数量: {{ partCount }}</p>
+      <p>状态: {{ isExploded ? '已炸开' : '正常' }}</p>
+    </div>
+
+    <!-- 说明面板 -->
+    <div class="instruction-panel">
+      <h4>操作说明</h4>
+      <ul>
+        <li>左键拖动:旋转视角</li>
+        <li>右键拖动:平移视角</li>
+        <li>滚轮:缩放</li>
+        <li>点击"炸开模型":查看拆解动画</li>
+      </ul>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
+import * as THREE from 'three'
+// 尝试不同的GLTFLoader导入方式
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+console.log('GLTFLoader 导入:', GLTFLoader)
+import { Expand, Fold, Aim, Grid } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const props = defineProps({
+  // 模型位置(经纬度)
+  longitude: {
+    type: Number,
+    default: 118.852918
+  },
+  latitude: {
+    type: Number,
+    default: 25.228317
+  },
+  height: {
+    type: Number,
+    default: 10
+  },
+  // 模型路径
+  modelPath: {
+    type: String,
+    default: '/models/启闭机.glb'
+  }
+})
+
+const emit = defineEmits(['model-loaded', 'explosion-complete', 'reset-complete'])
+
+// 响应式数据
+const isExploded = ref(false)
+const isWireframe = ref(false)
+const explosionFactor = ref(0)
+const modelLoaded = ref(false)
+const partCount = ref(0)
+
+// Three.js 和 Cesium 相关变量
+let scene = null
+let camera = null
+let renderer = null
+let model = null
+let animationId = null
+let threeContainer = null
+let renderInterval = null // 添加渲染循环的interval ID
+
+// 存储原始位置和爆炸方向
+const originalTransforms = new Map()
+const explosionDirections = new Map()
+
+// 初始化 Three.js 场景并与 Cesium 融合
+const initThreeScene = () => {
+  console.log('开始初始化 Three.js 场景')
+  // 获取 Cesium 的 canvas
+  const cesiumCanvas = document.querySelector('.cesium-viewer canvas')
+  console.log('Cesium canvas:', cesiumCanvas)
+  if (!cesiumCanvas) {
+    console.error('Cesium canvas 未找到')
+    return false
+  }
+
+  // 创建 Three.js 场景
+  scene = new THREE.Scene()
+  console.log('Three.js 场景创建成功')
+
+  // 创建相机
+  camera = new THREE.PerspectiveCamera(60, cesiumCanvas.width / cesiumCanvas.height, 0.01, 10000000)
+  console.log('Three.js 相机创建成功')
+  console.log('相机配置:', {
+    fov: 60,
+    aspect: cesiumCanvas.width / cesiumCanvas.height,
+    near: 0.01,
+    far: 10000000
+  })
+
+  // 创建独立的 Three.js canvas
+  renderer = new THREE.WebGLRenderer({
+    antialias: true,
+    alpha: true,
+    preserveDrawingBuffer: true
+  })
+  console.log('Three.js 渲染器创建成功')
+  
+  // 设置渲染器尺寸与 Cesium canvas 相同
+  renderer.setSize(cesiumCanvas.width, cesiumCanvas.height)
+  renderer.setPixelRatio(window.devicePixelRatio)
+  renderer.domElement.style.position = 'absolute'
+  renderer.domElement.style.top = '0'
+  renderer.domElement.style.left = '0'
+  renderer.domElement.style.width = '100%'
+  renderer.domElement.style.height = '100%'
+  renderer.domElement.style.pointerEvents = 'none'
+  renderer.domElement.style.zIndex = '100'
+  
+  // 将 Three.js canvas 添加到 Cesium canvas 的父容器中
+  const cesiumContainer = cesiumCanvas.parentElement
+  cesiumContainer.appendChild(renderer.domElement)
+  console.log('Three.js canvas 已添加到 Cesium 容器')
+  
+  renderer.autoClear = true // 自动清除画布
+  renderer.shadowMap.enabled = true
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap
+  renderer.setClearColor(0x000000, 0) // 设置透明背景
+
+  // 添加光源
+  setupLights()
+  console.log('Three.js 光源设置成功')
+
+  // 监听 Cesium 渲染事件
+  if (window.viewer) {
+    console.log('添加 Cesium 渲染事件监听器')
+    window.viewer.scene.postRender.addEventListener(syncCamera)
+  } else {
+    console.error('window.viewer 未定义')
+  }
+
+  console.log('Three.js 场景初始化完成')
+  
+  // 添加窗口大小变化监听器
+  window.addEventListener('resize', handleResize)
+  console.log('窗口大小变化监听器已添加')
+  
+  return true
+}
+
+// 处理窗口大小变化
+const handleResize = () => {
+  if (!renderer || !camera) return
+  
+  const cesiumCanvas = document.querySelector('.cesium-viewer canvas')
+  if (!cesiumCanvas) return
+  
+  // 更新相机宽高比
+  camera.aspect = cesiumCanvas.width / cesiumCanvas.height
+  camera.updateProjectionMatrix()
+  
+  // 更新渲染器尺寸
+  renderer.setSize(cesiumCanvas.width, cesiumCanvas.height)
+  
+  console.log('窗口大小已更新:', cesiumCanvas.width, cesiumCanvas.height)
+}
+
+// 设置光源
+const setupLights = () => {
+  // 环境光
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+  scene.add(ambientLight)
+
+  // 主光源(模拟太阳光)
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0)
+  directionalLight.position.set(100, 200, 100)
+  directionalLight.castShadow = true
+  scene.add(directionalLight)
+
+  // 辅助光源
+  const fillLight = new THREE.DirectionalLight(0x4080ff, 0.3)
+  fillLight.position.set(-100, 50, -100)
+  scene.add(fillLight)
+}
+
+// 同步 Cesium 相机到 Three.js
+const syncCamera = () => {
+  if (!window.viewer || !camera || !scene || !renderer) return
+
+  const cesiumCamera = window.viewer.camera
+
+  // 同步相机位置
+  camera.position.set(
+    cesiumCamera.position.x,
+    cesiumCamera.position.y,
+    cesiumCamera.position.z
+  )
+
+  // 同步相机方向
+  const direction = cesiumCamera.direction
+  const up = cesiumCamera.up
+  camera.lookAt(
+    camera.position.x + direction.x,
+    camera.position.y + direction.y,
+    camera.position.z + direction.z
+  )
+  camera.up.set(up.x, up.y, up.z)
+
+  // 同步投影矩阵
+  camera.projectionMatrix.fromArray(cesiumCamera.frustum.projectionMatrix)
+
+  // 渲染 Three.js 场景
+  renderer.render(scene, camera)
+}
+
+// 将经纬度转换为 Three.js 世界坐标(使用 Cesium 坐标系)
+const latLonToWorld = (lon, lat, alt) => {
+  if (window.viewer) {
+    const cartesian = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
+    console.log('Cesium 坐标转换:', {
+      lon, lat, alt,
+      cartesian: { x: cartesian.x, y: cartesian.y, z: cartesian.z }
+    })
+    return new THREE.Vector3(cartesian.x, cartesian.y, cartesian.z)
+  }
+  return new THREE.Vector3(0, 0, 0)
+}
+
+// 计算模型中心(优先使用底座作为中心)
+const getModelCenter = (object) => {
+  // 尝试找到底座部件
+  let base = null
+  object.traverse((child) => {
+    if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+      base = child
+    }
+  })
+  
+  // 如果找到底座,以底座的位置为中心
+  if (base) {
+    const baseCenter = new THREE.Vector3()
+    if (base.isMesh) {
+      base.geometry.computeBoundingBox()
+      base.geometry.boundingBox.getCenter(baseCenter)
+      baseCenter.applyMatrix4(base.matrixWorld)
+    } else {
+      base.getWorldPosition(baseCenter)
+    }
+    console.log('使用底座作为爆炸中心:', base.name)
+    return baseCenter
+  }
+  
+  // 如果没有找到底座,使用模型的边界框中心
+  const box = new THREE.Box3().setFromObject(object)
+  const center = box.getCenter(new THREE.Vector3())
+  console.log('使用模型边界框作为爆炸中心')
+  return center
+}
+
+// 计算爆炸方向(从模型中心向外)
+const calculateExplosionDirection = (object, center) => {
+  const objectCenter = new THREE.Vector3()
+  
+  if (object.geometry) {
+    object.geometry.computeBoundingBox()
+    object.geometry.boundingBox.getCenter(objectCenter)
+    objectCenter.applyMatrix4(object.matrixWorld)
+  } else {
+    object.getWorldPosition(objectCenter)
+  }
+  
+  // 计算从模型中心到部件中心的方向
+  const direction = new THREE.Vector3().subVectors(objectCenter, center)
+  
+  // 确保方向向量长度不为零
+  if (direction.length() > 0) {
+    direction.normalize()
+  } else {
+    // 如果方向向量长度为零,使用随机方向
+    direction.set(
+      Math.random() - 0.5,
+      Math.random() - 0.5,
+      Math.random() - 0.5
+    ).normalize()
+  }
+  
+  return direction
+}
+
+// 递归收集所有可爆炸的部件
+const collectExplodableParts = (object, center) => {
+  const parts = []
+  
+  // 遍历模型中的所有对象,包括嵌套的组和网格
+  object.traverse((child) => {
+    // 只收集有名称的对象,这些通常是用户在建模软件中创建的部件
+    if ((child.isGroup || (child.isMesh && child.geometry)) && child.name) {
+      // 保存原始变换
+      originalTransforms.set(child.uuid, {
+        position: child.position.clone(),
+        rotation: child.rotation.clone(),
+        scale: child.scale.clone()
+      })
+      
+      // 计算爆炸方向
+      const direction = calculateExplosionDirection(child, center)
+      explosionDirections.set(child.uuid, direction)
+      
+      parts.push(child)
+      console.log('收集到部件:', child.name)
+    }
+  })
+  
+  // 如果没有收集到有名称的部件,尝试收集所有网格
+  if (parts.length === 0) {
+    object.traverse((child) => {
+      if (child.isMesh && child.geometry) {
+        // 保存原始变换
+        originalTransforms.set(child.uuid, {
+          position: child.position.clone(),
+          rotation: child.rotation.clone(),
+          scale: child.scale.clone()
+        })
+        
+        // 计算爆炸方向
+        const direction = calculateExplosionDirection(child, center)
+        explosionDirections.set(child.uuid, direction)
+        
+        parts.push(child)
+        console.log('收集到网格:', child.uuid)
+      }
+    })
+  }
+  
+  console.log('收集到的可爆炸部件数量:', parts.length)
+  return parts
+}
+
+// 加载 GLB 模型
+const loadModel = () => {
+  console.log('开始加载模型:', props.modelPath)
+  if (!scene) {
+    console.error('场景未初始化')
+    ElMessage.error('场景未初始化')
+    return
+  }
+
+  try {
+    const loader = new GLTFLoader()
+    console.log('GLTFLoader 实例创建成功')
+    
+    console.log('尝试加载模型:', props.modelPath)
+    loader.load(
+      props.modelPath,
+      (gltf) => {
+        console.log('模型加载成功,开始处理:', gltf)
+        model = gltf.scene
+        
+        // 计算模型位置(使用 Cesium 世界坐标)
+        // 先设置模型缩放,再计算边界框
+        const scale = 100 // 调整缩放值,使模型在地图上更明显
+        model.scale.set(scale, scale, scale)
+        
+        // 获取模型的边界框,计算模型的中心点和底部位置
+        const boundingBox = new THREE.Box3().setFromObject(model)
+        const boxCenter = new THREE.Vector3()
+        boundingBox.getCenter(boxCenter)
+        const size = new THREE.Vector3()
+        boundingBox.getSize(size)
+        
+        // 计算模型底部到中心点的距离
+        const bottomOffset = size.y / 2
+        
+        // 调整高度为0,确保模型贴合地面
+        const adjustedHeight = 0
+        const position = latLonToWorld(props.longitude, props.latitude, adjustedHeight) // 调整高度为0,确保模型贴合地面
+        model.position.copy(position)
+        
+        // 调整模型位置,确保底部贴合地面
+        model.position.y -= bottomOffset
+        
+        console.log('模型边界框尺寸:', size)
+        console.log('模型底部偏移:', bottomOffset)
+        console.log('调整后的高度:', adjustedHeight)
+        
+        // 模型缩放已经在前面设置过了
+        console.log('模型缩放值:', scale)
+        console.log('模型位置:', model.position)
+        console.log('模型坐标:', {
+          x: model.position.x,
+          y: model.position.y,
+          z: model.position.z
+        })
+        console.log('模型边界框:', new THREE.Box3().setFromObject(model))
+        
+        // 计算地表对齐旋转,确保模型垂直于当地地表法线
+        if (window.viewer) {
+          // 获取当地坐标系(ENU - 东东北天)的变换矩阵
+          const cartesianPosition = Cesium.Cartesian3.fromDegrees(props.longitude, props.latitude, adjustedHeight)
+          const enuMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(cartesianPosition)
+          
+          // 提取旋转部分并转换为 Three.js 矩阵
+          const threeMatrix = new THREE.Matrix4()
+          
+          // 手动填充矩阵值(从 Cesium 的 Matrix4 转换)
+          threeMatrix.elements[0] = enuMatrix[0]
+          threeMatrix.elements[1] = enuMatrix[1]
+          threeMatrix.elements[2] = enuMatrix[2]
+          threeMatrix.elements[3] = enuMatrix[3]
+          threeMatrix.elements[4] = enuMatrix[4]
+          threeMatrix.elements[5] = enuMatrix[5]
+          threeMatrix.elements[6] = enuMatrix[6]
+          threeMatrix.elements[7] = enuMatrix[7]
+          threeMatrix.elements[8] = enuMatrix[8]
+          threeMatrix.elements[9] = enuMatrix[9]
+          threeMatrix.elements[10] = enuMatrix[10]
+          threeMatrix.elements[11] = enuMatrix[11]
+          threeMatrix.elements[12] = 0
+          threeMatrix.elements[13] = 0
+          threeMatrix.elements[14] = 0
+          threeMatrix.elements[15] = 1
+          
+          // 从矩阵中提取旋转
+          const quaternion = new THREE.Quaternion()
+          quaternion.setFromRotationMatrix(threeMatrix)
+          
+          // 应用旋转到模型
+          model.quaternion.copy(quaternion)
+          
+          // 调整模型朝向,使其垂直于地面
+          // 绕 x 轴旋转 90 度,使模型垂直于地面
+          const xRotation = new THREE.Quaternion().setFromAxisAngle(
+            new THREE.Vector3(1, 0, 0),
+            Math.PI / 2
+          )
+          model.quaternion.multiply(xRotation)
+          
+          console.log('模型四元数旋转:', model.quaternion)
+        } else {
+          // 备用旋转方案
+          model.rotation.x = Math.PI / 2 // 绕 x 轴旋转 90 度,使模型垂直于地面
+          model.rotation.y = 0
+          model.rotation.z = 0
+          console.log('模型旋转:', model.rotation)
+        }
+        
+        // 启用阴影和增强材质
+        model.traverse((child) => {
+          if (child.isMesh) {
+            child.castShadow = true
+            child.receiveShadow = true
+            
+            if (child.material) {
+              child.material.metalness = 0.3
+              child.material.roughness = 0.7
+              child.material.side = THREE.DoubleSide
+              child.material.transparent = false
+              child.material.needsUpdate = true
+              child.material.depthTest = true
+              child.material.depthWrite = true
+            }
+          }
+        })
+        
+        scene.add(model)
+        
+        // 计算模型中心并收集可爆炸部件
+        const center = getModelCenter(model)
+        const parts = collectExplodableParts(model, center)
+        partCount.value = parts.length
+        
+        modelLoaded.value = true
+        emit('model-loaded', { model, parts: parts.length })
+        ElMessage.success('启闭机模型加载成功')
+        
+        console.log('模型加载成功:', {
+          position: position,
+          parts: parts.length,
+          center: center,
+          scale: scale,
+          model: model
+        })
+        
+        // 检查模型是否在场景中
+        console.log('模型是否在场景中:', scene.children.includes(model))
+        console.log('场景中的对象数量:', scene.children.length)
+        console.log('Three.js 渲染器:', renderer)
+        console.log('Three.js 相机:', camera)
+        console.log('Three.js 场景:', scene)
+        
+        // 强制渲染一次
+        setTimeout(() => {
+          if (renderer && scene && camera) {
+            console.log('强制渲染 Three.js 场景')
+            renderer.render(scene, camera)
+          }
+        }, 100)
+        
+        // 自动定位到模型
+        flyToModel()
+      },
+      (progress) => {
+        const percent = (progress.loaded / progress.total * 100).toFixed(0)
+        console.log(`模型加载进度: ${percent}%`)
+      },
+      (error) => {
+      console.error('模型加载失败:', error)
+      console.error('错误详情:', error.message)
+      console.error('错误堆栈:', error.stack)
+      console.error('错误类型:', error.type)
+      console.error('错误目标:', error.target)
+      ElMessage.error('模型加载失败: ' + error.message)
+    }
+    )
+  } catch (error) {
+    console.error('模型加载过程中发生异常:', error)
+    ElMessage.error('模型加载过程中发生异常: ' + error.message)
+  }
+}
+
+// 更新爆炸效果
+const updateExplosion = () => {
+  if (!model) return
+  
+  const factor = explosionFactor.value
+  
+  // 只对收集到的可爆炸部件进行操作
+  originalTransforms.forEach((original, uuid) => {
+    const child = model.getObjectByProperty('uuid', uuid)
+    if (child) {
+      // 检查是否是底座,如果是底座则跳过移动
+      if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+        console.log('跳过底座移动:', child.name)
+        return
+      }
+      
+      const direction = explosionDirections.get(uuid)
+      
+      if (direction) {
+        // 计算爆炸位移(只向外扩开,距离适中)
+        const distance = factor * 1 // 进一步减小爆炸距离系数,使炸开程度为3时的距离只有原来的0.2
+        const offset = direction.clone().multiplyScalar(distance)
+        
+        // 应用位移(只向外扩开,不添加旋转和随机性)
+        child.position.copy(original.position).add(offset)
+        
+        // 如果是组,需要更新组内所有子元素的位置
+        if (child.isGroup) {
+          child.traverse((groupChild) => {
+            // 检查子元素是否是底座,如果是底座则跳过移动
+            if (groupChild.name && (groupChild.name.includes('底座') || groupChild.name.includes('base') || groupChild.name.includes('Base'))) {
+              console.log('跳过底座子元素移动:', groupChild.name)
+              return
+            }
+            
+            if (groupChild.isMesh && groupChild !== child) {
+              // 保存子元素的原始位置
+              if (!originalTransforms.has(groupChild.uuid)) {
+                originalTransforms.set(groupChild.uuid, {
+                  position: groupChild.position.clone(),
+                  rotation: groupChild.rotation.clone(),
+                  scale: groupChild.scale.clone()
+                })
+              }
+              
+              // 应用相同的位移
+              const groupChildOriginal = originalTransforms.get(groupChild.uuid)
+              groupChild.position.copy(groupChildOriginal.position).add(offset)
+            }
+          })
+        }
+      }
+    }
+  })
+}
+
+// 切换爆炸状态
+const toggleExplosion = () => {
+  if (!model) {
+    ElMessage.warning('模型未加载')
+    return
+  }
+  
+  isExploded.value = !isExploded.value
+  
+  if (isExploded.value) {
+    animateExplosion(1.5)
+  } else {
+    animateExplosion(0)
+  }
+}
+
+// 爆炸/还原动画
+const animateExplosion = (targetFactor) => {
+  const startFactor = explosionFactor.value
+  const duration = 1200 // 1.2秒,更平滑
+  const startTime = Date.now()
+  
+  const animate = () => {
+    const elapsed = Date.now() - startTime
+    const progress = Math.min(elapsed / duration, 1)
+    
+    // 使用弹性缓动函数
+    const eased = progress < 0.5 
+      ? 4 * progress * progress * progress 
+      : 1 - Math.pow(-2 * progress + 2, 3) / 2
+    
+    explosionFactor.value = startFactor + (targetFactor - startFactor) * eased
+    updateExplosion()
+    
+    if (progress < 1) {
+      requestAnimationFrame(animate)
+    } else {
+      emit(isExploded.value ? 'explosion-complete' : 'reset-complete')
+    }
+  }
+  
+  animate()
+}
+
+// 飞行到模型位置
+const flyToModel = () => {
+  if (!window.viewer) return
+  
+  window.viewer.camera.flyTo({
+    destination: Cesium.Cartesian3.fromDegrees(
+      props.longitude,
+      props.latitude,
+      props.height + 100
+    ),
+    orientation: {
+      heading: Cesium.Math.toRadians(0),
+      pitch: Cesium.Math.toRadians(-30),
+      roll: 0
+    },
+    duration: 2
+  })
+}
+
+// 切换线框模式
+const toggleWireframe = () => {
+  if (!model) return
+  
+  isWireframe.value = !isWireframe.value
+  
+  model.traverse((child) => {
+    if (child.isMesh && child.material) {
+      if (Array.isArray(child.material)) {
+        child.material.forEach(mat => {
+          mat.wireframe = isWireframe.value
+        })
+      } else {
+        child.material.wireframe = isWireframe.value
+      }
+    }
+  })
+}
+
+// 生命周期钩子
+onMounted(() => {
+  console.log('CesiumThreeFusion 组件已挂载')
+  // 等待 Cesium 初始化完成
+  const checkCesium = setInterval(() => {
+    console.log('检查 Cesium 初始化状态:', {
+      windowViewer: !!window.viewer,
+      canvasExists: !!document.querySelector('.cesium-viewer canvas')
+    })
+    if (window.viewer && document.querySelector('.cesium-viewer canvas')) {
+      clearInterval(checkCesium)
+      console.log('Cesium 初始化完成,开始初始化 Three.js 场景')
+      // 初始化 Three.js 场景
+      if (initThreeScene()) {
+        console.log('Three.js 场景初始化成功,准备加载模型')
+        // 延迟加载模型
+        setTimeout(() => {
+          loadModel()
+        }, 1000)
+      } else {
+        console.error('Three.js 场景初始化失败')
+      }
+    }
+  }, 500)
+})
+
+onUnmounted(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+  
+  // 移除 Cesium 事件监听
+  if (window.viewer) {
+    window.viewer.scene.postRender.removeEventListener(syncCamera)
+  }
+  
+  // 移除窗口大小变化监听器
+  window.removeEventListener('resize', handleResize)
+  console.log('窗口大小变化监听器已移除')
+  
+  // 清理资源
+  originalTransforms.clear()
+  explosionDirections.clear()
+  
+  if (model) {
+    scene.remove(model)
+    model = null
+  }
+  
+  // 清理渲染器
+  if (renderer) {
+    renderer.dispose()
+    renderer = null
+  }
+  
+  // 移除 Three.js canvas
+  const threeCanvas = renderer?.domElement
+  if (threeCanvas && threeCanvas.parentElement) {
+    threeCanvas.parentElement.removeChild(threeCanvas)
+  }
+  
+  console.log('CesiumThreeFusion 组件已卸载')
+})
+
+// 暴露方法给父组件
+defineExpose({
+  loadModel,
+  toggleExplosion,
+  flyToModel,
+  toggleWireframe,
+  updateExplosion
+})
+</script>
+
+<style scoped>
+.cesium-three-fusion {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  z-index: 100;
+}
+
+.control-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  z-index: 1000;
+  display: flex;
+  gap: 10px;
+  pointer-events: auto;
+  background: rgba(255, 255, 255, 0.95);
+  padding: 12px;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+  backdrop-filter: blur(10px);
+}
+
+.control-panel .el-button {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.control-panel .el-button.is-exploded {
+  background-color: #67c23a;
+  border-color: #67c23a;
+}
+
+.explosion-slider {
+  position: absolute;
+  top: 90px;
+  left: 20px;
+  z-index: 1000;
+  width: 320px;
+  background: rgba(255, 255, 255, 0.95);
+  padding: 15px 20px;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+  pointer-events: auto;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  backdrop-filter: blur(10px);
+}
+
+
+
+.slider-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+.slider-value {
+  font-size: 14px;
+  color: #409eff;
+  font-weight: bold;
+  min-width: 40px;
+  text-align: right;
+}
+
+.explosion-slider :deep(.el-slider) {
+  flex: 1;
+}
+
+.model-info-panel {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  z-index: 1000;
+  width: 220px;
+  background: rgba(255, 255, 255, 0.95);
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+  pointer-events: auto;
+  backdrop-filter: blur(10px);
+}
+
+.model-info-panel h4 {
+  margin: 0 0 12px 0;
+  font-size: 16px;
+  color: #303133;
+  border-bottom: 2px solid #409eff;
+  padding-bottom: 10px;
+}
+
+.model-info-panel p {
+  margin: 8px 0;
+  font-size: 13px;
+  color: #606266;
+}
+
+.instruction-panel {
+  position: absolute;
+  bottom: 20px;
+  left: 20px;
+  z-index: 1000;
+  width: 200px;
+  background: rgba(0, 0, 0, 0.7);
+  padding: 15px 20px;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+  pointer-events: auto;
+  backdrop-filter: blur(10px);
+}
+
+.instruction-panel h4 {
+  margin: 0 0 10px 0;
+  font-size: 14px;
+  color: #ffffff;
+  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+  padding-bottom: 8px;
+}
+
+.instruction-panel ul {
+  margin: 0;
+  padding-left: 18px;
+  list-style: disc;
+}
+
+.instruction-panel li {
+  margin: 5px 0;
+  font-size: 12px;
+  color: rgba(255, 255, 255, 0.9);
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+  .control-panel {
+    top: 10px;
+    left: 10px;
+    flex-wrap: wrap;
+    padding: 8px;
+  }
+  
+  .explosion-slider {
+    top: auto;
+    bottom: 150px;
+    left: 10px;
+    width: calc(100% - 20px);
+  }
+  
+  .model-info-panel {
+    top: auto;
+    bottom: 20px;
+    right: 10px;
+    left: 10px;
+    width: auto;
+  }
+  
+  .instruction-panel {
+    display: none;
+  }
+}
+</style>

+ 639 - 0
RuoYi-Vue3/src/components/ThreeCesiumIntegration/index.vue

@@ -0,0 +1,639 @@
+<template>
+  <div class="three-cesium-integration">
+    <!-- 控制按钮 -->
+    <div class="control-panel">
+      <el-button 
+        type="primary" 
+        :class="{ 'is-exploded': isExploded }"
+        @click="toggleExplosion"
+      >
+        <el-icon><Expand v-if="!isExploded" /><Fold v-else /></el-icon>
+        {{ isExploded ? '还原模型' : '炸开模型' }}
+      </el-button>
+      <el-button 
+        type="warning" 
+        @click="resetCamera"
+      >
+        <el-icon><Aim /></el-icon>
+        重置视角
+      </el-button>
+      <el-button 
+        type="info" 
+        @click="toggleWireframe"
+      >
+        <el-icon><Grid /></el-icon>
+        {{ isWireframe ? '实体模式' : '线框模式' }}
+      </el-button>
+    </div>
+
+    <!-- 爆炸程度滑块 -->
+    <div class="explosion-slider" v-if="isExploded || explosionFactor > 0">
+      <span class="slider-label">爆炸程度</span>
+      <el-slider 
+        v-model="explosionFactor" 
+        :min="0" 
+        :max="3" 
+        :step="0.1"
+        @input="updateExplosion"
+      />
+      <span class="slider-value">{{ explosionFactor.toFixed(1) }}</span>
+    </div>
+
+    <!-- 模型信息面板 -->
+    <div class="model-info-panel" v-if="modelLoaded">
+      <h4>启闭机模型</h4>
+      <p>经度: {{ longitude }}</p>
+      <p>纬度: {{ latitude }}</p>
+      <p>高度: {{ height }}m</p>
+      <p>部件数量: {{ partCount }}</p>
+    </div>
+
+    <!-- Three.js 画布容器 -->
+    <div ref="threeContainer" class="three-container"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted, watch } from 'vue'
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { Expand, Fold, Aim, Grid } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const props = defineProps({
+  // 模型位置(经纬度)
+  longitude: {
+    type: Number,
+    default: 118.852918
+  },
+  latitude: {
+    type: Number,
+    default: 25.228317
+  },
+  height: {
+    type: Number,
+    default: 10
+  },
+  // 模型路径
+  modelPath: {
+    type: String,
+    default: '/models/启闭机.glb'
+  },
+  // 是否自动加载
+  autoLoad: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['model-loaded', 'explosion-complete', 'reset-complete'])
+
+// 响应式数据
+const threeContainer = ref(null)
+const isExploded = ref(false)
+const isWireframe = ref(false)
+const explosionFactor = ref(0)
+const modelLoaded = ref(false)
+const partCount = ref(0)
+
+// Three.js 相关变量
+let scene = null
+let camera = null
+let renderer = null
+let controls = null
+let model = null
+let animationId = null
+let cesiumViewer = null
+
+// 存储原始位置和爆炸方向
+const originalPositions = new Map()
+const explosionDirections = new Map()
+
+// 初始化 Three.js 场景
+const initThreeScene = () => {
+  if (!threeContainer.value) return
+
+  // 创建场景
+  scene = new THREE.Scene()
+  scene.background = null // 透明背景,让Cesium地球可见
+
+  // 创建相机
+  const aspect = threeContainer.value.clientWidth / threeContainer.value.clientHeight
+  camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 10000)
+  camera.position.set(50, 50, 50)
+
+  // 创建渲染器
+  renderer = new THREE.WebGLRenderer({
+    antialias: true,
+    alpha: true, // 透明背景
+    preserveDrawingBuffer: true
+  })
+  renderer.setSize(threeContainer.value.clientWidth, threeContainer.value.clientHeight)
+  renderer.setPixelRatio(window.devicePixelRatio)
+  renderer.shadowMap.enabled = true
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap
+  threeContainer.value.appendChild(renderer.domElement)
+
+  // 添加光源
+  setupLights()
+
+  // 创建控制器
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.05
+  controls.minDistance = 10
+  controls.maxDistance = 500
+
+  // 开始渲染循环
+  animate()
+
+  // 监听窗口大小变化
+  window.addEventListener('resize', handleResize)
+}
+
+// 设置光源
+const setupLights = () => {
+  // 环境光
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+  scene.add(ambientLight)
+
+  // 主光源(模拟太阳光)
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0)
+  directionalLight.position.set(100, 200, 100)
+  directionalLight.castShadow = true
+  directionalLight.shadow.mapSize.width = 2048
+  directionalLight.shadow.mapSize.height = 2048
+  directionalLight.shadow.camera.near = 0.5
+  directionalLight.shadow.camera.far = 500
+  directionalLight.shadow.camera.left = -100
+  directionalLight.shadow.camera.right = 100
+  directionalLight.shadow.camera.top = 100
+  directionalLight.shadow.camera.bottom = -100
+  scene.add(directionalLight)
+
+  // 辅助光源
+  const fillLight = new THREE.DirectionalLight(0x4080ff, 0.3)
+  fillLight.position.set(-100, 50, -100)
+  scene.add(fillLight)
+}
+
+// 将经纬度转换为 Three.js 世界坐标
+const latLonToWorld = (lon, lat, alt) => {
+  // 使用 Cesium 的坐标转换
+  if (window.viewer) {
+    const cartesian = Cesium.Cartesian3.fromDegrees(lon, lat, alt)
+    return new THREE.Vector3(cartesian.x, cartesian.y, cartesian.z)
+  }
+  // 如果没有 Cesium,使用简化的转换(仅用于测试)
+  const x = lon * 1000
+  const y = alt
+  const z = -lat * 1000
+  return new THREE.Vector3(x, y, z)
+}
+
+// 计算爆炸方向(从模型中心向外)
+const calculateExplosionDirection = (object, center) => {
+  const objectCenter = new THREE.Vector3()
+  if (object.geometry) {
+    object.geometry.computeBoundingBox()
+    object.geometry.boundingBox.getCenter(objectCenter)
+    objectCenter.applyMatrix4(object.matrixWorld)
+  } else {
+    object.getWorldPosition(objectCenter)
+  }
+  
+  const direction = new THREE.Vector3().subVectors(objectCenter, center).normalize()
+  return direction
+}
+
+// 递归收集所有可爆炸的部件
+const collectExplodableParts = (object, center) => {
+  const parts = []
+  
+  object.traverse((child) => {
+    if (child.isMesh) {
+      // 保存原始位置
+      const originalPosition = child.position.clone()
+      originalPositions.set(child.uuid, originalPosition)
+      
+      // 计算爆炸方向
+      const direction = calculateExplosionDirection(child, center)
+      explosionDirections.set(child.uuid, direction)
+      
+      parts.push(child)
+    }
+  })
+  
+  return parts
+}
+
+// 加载 GLB 模型
+const loadModel = () => {
+  if (!scene) {
+    ElMessage.error('场景未初始化')
+    return
+  }
+
+  const loader = new GLTFLoader()
+  
+  loader.load(
+    props.modelPath,
+    (gltf) => {
+      model = gltf.scene
+      
+      // 计算模型位置
+      const position = latLonToWorld(props.longitude, props.latitude, props.height)
+      model.position.copy(position)
+      
+      // 计算模型缩放(根据距离调整大小)
+      const scale = 10 // 根据实际情况调整
+      model.scale.set(scale, scale, scale)
+      
+      // 启用阴影
+      model.traverse((child) => {
+        if (child.isMesh) {
+          child.castShadow = true
+          child.receiveShadow = true
+          
+          // 增强材质效果
+          if (child.material) {
+            child.material.metalness = 0.3
+            child.material.roughness = 0.7
+          }
+        }
+      })
+      
+      scene.add(model)
+      
+      // 计算模型中心
+      const box = new THREE.Box3().setFromObject(model)
+      const center = box.getCenter(new THREE.Vector3())
+      
+      // 收集所有可爆炸部件
+      const parts = collectExplodableParts(model, center)
+      partCount.value = parts.length
+      
+      // 调整相机位置
+      const size = box.getSize(new THREE.Vector3())
+      const maxDim = Math.max(size.x, size.y, size.z)
+      const cameraDistance = maxDim * 3
+      
+      camera.position.set(
+        position.x + cameraDistance,
+        position.y + cameraDistance * 0.5,
+        position.z + cameraDistance
+      )
+      camera.lookAt(position)
+      controls.target.copy(position)
+      controls.update()
+      
+      modelLoaded.value = true
+      emit('model-loaded', { model, parts: parts.length })
+      ElMessage.success('模型加载成功')
+      
+      console.log('模型加载成功:', {
+        position: position,
+        parts: parts.length,
+        center: center
+      })
+    },
+    (progress) => {
+      const percent = (progress.loaded / progress.total * 100).toFixed(0)
+      console.log(`模型加载进度: ${percent}%`)
+    },
+    (error) => {
+      console.error('模型加载失败:', error)
+      ElMessage.error('模型加载失败: ' + error.message)
+    }
+  )
+}
+
+// 更新爆炸效果
+const updateExplosion = () => {
+  if (!model) return
+  
+  const factor = explosionFactor.value
+  
+  model.traverse((child) => {
+    if (child.isMesh && originalPositions.has(child.uuid)) {
+      const originalPos = originalPositions.get(child.uuid)
+      const direction = explosionDirections.get(child.uuid)
+      
+      if (direction) {
+        // 计算爆炸位移(非线性,让效果更明显)
+        const explosionDistance = factor * factor * 20 // 平方关系,爆炸效果更显著
+        const offset = direction.clone().multiplyScalar(explosionDistance)
+        
+        // 添加一些随机性,让爆炸更自然
+        const randomOffset = new THREE.Vector3(
+          (Math.random() - 0.5) * factor * 2,
+          (Math.random() - 0.5) * factor * 2,
+          (Math.random() - 0.5) * factor * 2
+        )
+        offset.add(randomOffset)
+        
+        child.position.copy(originalPos).add(offset)
+        
+        // 添加旋转效果
+        child.rotation.x = factor * Math.sin(child.uuid.charCodeAt(0)) * 0.2
+        child.rotation.y = factor * Math.cos(child.uuid.charCodeAt(1)) * 0.2
+      }
+    }
+  })
+}
+
+// 切换爆炸状态
+const toggleExplosion = () => {
+  if (!model) {
+    ElMessage.warning('模型未加载')
+    return
+  }
+  
+  isExploded.value = !isExploded.value
+  
+  if (isExploded.value) {
+    // 开始爆炸动画
+    animateExplosion(1.5)
+  } else {
+    // 开始还原动画
+    animateExplosion(0)
+  }
+}
+
+// 爆炸/还原动画
+const animateExplosion = (targetFactor) => {
+  const startFactor = explosionFactor.value
+  const duration = 1000 // 1秒
+  const startTime = Date.now()
+  
+  const animate = () => {
+    const elapsed = Date.now() - startTime
+    const progress = Math.min(elapsed / duration, 1)
+    
+    // 使用缓动函数
+    const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
+    
+    explosionFactor.value = startFactor + (targetFactor - startFactor) * eased
+    updateExplosion()
+    
+    if (progress < 1) {
+      requestAnimationFrame(animate)
+    } else {
+      emit(isExploded.value ? 'explosion-complete' : 'reset-complete')
+    }
+  }
+  
+  animate()
+}
+
+// 重置相机
+const resetCamera = () => {
+  if (!model) return
+  
+  const position = latLonToWorld(props.longitude, props.latitude, props.height)
+  const offset = 100
+  
+  camera.position.set(
+    position.x + offset,
+    position.y + offset * 0.5,
+    position.z + offset
+  )
+  camera.lookAt(position)
+  controls.target.copy(position)
+  controls.update()
+  
+  emit('reset-complete')
+}
+
+// 切换线框模式
+const toggleWireframe = () => {
+  if (!model) return
+  
+  isWireframe.value = !isWireframe.value
+  
+  model.traverse((child) => {
+    if (child.isMesh && child.material) {
+      if (Array.isArray(child.material)) {
+        child.material.forEach(mat => {
+          mat.wireframe = isWireframe.value
+        })
+      } else {
+        child.material.wireframe = isWireframe.value
+      }
+    }
+  })
+}
+
+// 渲染循环
+const animate = () => {
+  animationId = requestAnimationFrame(animate)
+  
+  if (controls) {
+    controls.update()
+  }
+  
+  if (renderer && scene && camera) {
+    renderer.render(scene, camera)
+  }
+}
+
+// 处理窗口大小变化
+const handleResize = () => {
+  if (!threeContainer.value || !camera || !renderer) return
+  
+  const width = threeContainer.value.clientWidth
+  const height = threeContainer.value.clientHeight
+  
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+  renderer.setSize(width, height)
+}
+
+// 同步 Cesium 相机(如果存在)
+const syncWithCesium = () => {
+  if (!window.viewer || !camera) return
+  
+  const cesiumCamera = window.viewer.camera
+  const cesiumPosition = cesiumCamera.position
+  const cesiumDirection = cesiumCamera.direction
+  const cesiumUp = cesiumCamera.up
+  
+  // 将 Cesium 相机参数转换为 Three.js
+  camera.position.set(cesiumPosition.x, cesiumPosition.y, cesiumPosition.z)
+  camera.lookAt(
+    cesiumPosition.x + cesiumDirection.x,
+    cesiumPosition.y + cesiumDirection.y,
+    cesiumPosition.z + cesiumDirection.z
+  )
+  camera.up.set(cesiumUp.x, cesiumUp.y, cesiumUp.z)
+}
+
+// 生命周期钩子
+onMounted(() => {
+  initThreeScene()
+  
+  if (props.autoLoad) {
+    // 延迟加载模型,确保场景已准备好
+    setTimeout(() => {
+      loadModel()
+    }, 500)
+  }
+})
+
+onUnmounted(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+  
+  window.removeEventListener('resize', handleResize)
+  
+  if (renderer) {
+    renderer.dispose()
+    if (threeContainer.value && renderer.domElement) {
+      threeContainer.value.removeChild(renderer.domElement)
+    }
+  }
+  
+  // 清理资源
+  originalPositions.clear()
+  explosionDirections.clear()
+})
+
+// 暴露方法给父组件
+defineExpose({
+  loadModel,
+  toggleExplosion,
+  resetCamera,
+  toggleWireframe,
+  updateExplosion,
+  syncWithCesium
+})
+</script>
+
+<style scoped>
+.three-cesium-integration {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  pointer-events: none; /* 让鼠标事件穿透到 Cesium */
+}
+
+.three-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: auto; /* Three.js 画布接收鼠标事件 */
+}
+
+.control-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  z-index: 1000;
+  display: flex;
+  gap: 10px;
+  pointer-events: auto;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 10px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+}
+
+.control-panel .el-button {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.control-panel .el-button.is-exploded {
+  background-color: #67c23a;
+  border-color: #67c23a;
+}
+
+.explosion-slider {
+  position: absolute;
+  top: 80px;
+  left: 20px;
+  z-index: 1000;
+  width: 300px;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.slider-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+}
+
+.slider-value {
+  font-size: 14px;
+  color: #409eff;
+  font-weight: bold;
+  min-width: 40px;
+  text-align: right;
+}
+
+.explosion-slider :deep(.el-slider) {
+  flex: 1;
+}
+
+.model-info-panel {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  z-index: 1000;
+  width: 200px;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+}
+
+.model-info-panel h4 {
+  margin: 0 0 10px 0;
+  font-size: 16px;
+  color: #303133;
+  border-bottom: 1px solid #ebeef5;
+  padding-bottom: 8px;
+}
+
+.model-info-panel p {
+  margin: 5px 0;
+  font-size: 13px;
+  color: #606266;
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+  .control-panel {
+    top: 10px;
+    left: 10px;
+    flex-wrap: wrap;
+  }
+  
+  .explosion-slider {
+    top: 120px;
+    left: 10px;
+    width: calc(100% - 20px);
+  }
+  
+  .model-info-panel {
+    top: auto;
+    bottom: 20px;
+    right: 10px;
+    left: 10px;
+    width: auto;
+  }
+}
+</style>

+ 903 - 0
RuoYi-Vue3/src/components/ThreeModelViewer/ModelViewer.vue

@@ -0,0 +1,903 @@
+<template>
+  <div class="three-model-viewer">
+    <!-- 左侧部件列表 -->
+    <div class="parts-panel" v-if="modelLoaded">
+      <h4>模型部件</h4>
+      <div class="part-list">
+        <div 
+          v-for="part in partsList" 
+          :key="part.uuid" 
+          class="part-item"
+          :class="{ 'selected': selectedPart === part.uuid }"
+        >
+          <span @click="selectPart(part.uuid)">{{ part.name }}</span>
+          <el-icon 
+            :class="{ 'visibility-icon': true, 'visible': visibleParts.includes(part.uuid) }"
+            @click="(e) => { e.stopPropagation(); handlePartVisibilityChange(part.uuid, !visibleParts.includes(part.uuid)) }"
+          >
+            <View v-if="visibleParts.includes(part.uuid)" />
+            <Hide v-else />
+          </el-icon>
+        </div>
+      </div>
+    </div>
+
+    <!-- 控制按钮 -->
+    <div class="control-panel">
+      <el-button 
+        type="primary" 
+        :class="{ 'is-exploded': isExploded }"
+        @click="toggleExplosion"
+      >
+        <el-icon><Expand v-if="!isExploded" /><Fold v-else /></el-icon>
+        {{ isExploded ? '还原模型' : '炸开模型' }}
+      </el-button>
+      <el-button 
+        type="warning" 
+        @click="resetCamera"
+      >
+        <el-icon><Aim /></el-icon>
+        重置视角
+      </el-button>
+      <el-button 
+        type="info" 
+        @click="toggleWireframe"
+      >
+        <el-icon><Grid /></el-icon>
+        {{ isWireframe ? '实体模式' : '线框模式' }}
+      </el-button>
+    </div>
+
+    <!-- 爆炸程度滑块 -->
+    <div class="explosion-slider" v-if="isExploded || explosionFactor > 0">
+      <span class="slider-label">爆炸程度</span>
+      <el-slider 
+        v-model="explosionFactor" 
+        :min="0" 
+        :max="3" 
+        :step="0.1"
+        @input="updateExplosion"
+      />
+      <span class="slider-value">{{ explosionFactor.toFixed(1) }}</span>
+    </div>
+
+    <!-- 模型信息面板 -->
+    <div class="model-info-panel" v-if="modelLoaded">
+      <h4>{{ modelName }}</h4>
+      <p>部件数量: {{ partCount }}</p>
+      <p>状态: {{ isExploded ? '已炸开' : '正常' }}</p>
+    </div>
+
+    <!-- Three.js 画布容器 -->
+    <div ref="threeContainer" class="three-container"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { Expand, Fold, Aim, Grid, View, Hide } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const props = defineProps({
+  modelPath: {
+    type: String,
+    default: '/models/启闭机.glb'
+  },
+  modelName: {
+    type: String,
+    default: '模型'
+  },
+  autoLoad: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['model-loaded', 'explosion-complete', 'reset-complete'])
+
+const threeContainer = ref(null)
+const isExploded = ref(false)
+const isWireframe = ref(false)
+const explosionFactor = ref(0)
+const modelLoaded = ref(false)
+const partCount = ref(0)
+const partsList = ref([])
+const visibleParts = ref([])
+const selectedPart = ref(null)
+
+let scene = null
+let camera = null
+let renderer = null
+let controls = null
+let model = null
+let animationId = null
+
+const originalTransforms = new Map()
+const explosionDirections = new Map()
+
+const raycaster = new THREE.Raycaster()
+const mouse = new THREE.Vector2()
+let handleClick = null
+let handleMouseDown = null
+let handleMouseUp = null
+let clickTimer = null
+const CLICK_TIMEOUT = 200
+
+const initThreeScene = () => {
+  if (!threeContainer.value) return false
+
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0xf0f0f0)
+
+  const width = threeContainer.value.clientWidth || 800
+  const height = threeContainer.value.clientHeight || 600
+  camera = new THREE.PerspectiveCamera(60, width / height, 0.01, 10000)
+  camera.position.set(0, 5, 10)
+
+  renderer = new THREE.WebGLRenderer({ antialias: true })
+  renderer.setSize(width, height)
+  renderer.setPixelRatio(window.devicePixelRatio)
+  renderer.shadowMap.enabled = true
+  threeContainer.value.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.05
+  controls.enableSelect = false
+
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+  scene.add(ambientLight)
+
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
+  directionalLight.position.set(10, 20, 10)
+  directionalLight.castShadow = true
+  scene.add(directionalLight)
+
+
+
+  window.addEventListener('resize', handleResize)
+  
+  // 处理鼠标按下事件
+  handleMouseDown = (event) => {
+    // 只处理左键点击
+    if (event.button !== 0) return
+    
+    // 清除之前的定时器
+    if (clickTimer) {
+      clearTimeout(clickTimer)
+    }
+    
+    // 设置定时器,判断是否是单击
+    clickTimer = setTimeout(() => {
+      // 长按,不执行选择操作
+      clickTimer = null
+    }, CLICK_TIMEOUT)
+  }
+  
+  // 处理鼠标释放事件
+  handleMouseUp = (event) => {
+    // 只处理左键点击
+    if (event.button !== 0) return
+    
+    // 清除定时器
+    if (clickTimer) {
+      clearTimeout(clickTimer)
+      clickTimer = null
+      
+      // 单击,执行选择操作
+      handleModelClick(event)
+    }
+  }
+  
+  // 添加事件监听器
+  renderer.domElement.addEventListener('mousedown', handleMouseDown)
+  renderer.domElement.addEventListener('mouseup', handleMouseUp)
+  animate()
+  return true
+}
+
+const handleResize = () => {
+  if (!threeContainer.value || !camera || !renderer) return
+
+  const width = threeContainer.value.clientWidth
+  const height = threeContainer.value.clientHeight
+
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+  renderer.setSize(width, height)
+}
+
+const handleModelClick = (event) => {
+  if (!model || !camera) return
+  
+  // 确保事件不会被 OrbitControls 阻止
+  event.stopPropagation()
+  
+  const rect = renderer.domElement.getBoundingClientRect()
+  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
+  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
+  
+  raycaster.setFromCamera(mouse, camera)
+  
+  // 直接遍历所有可渲染对象,而不仅仅是 model.children
+  const allObjects = []
+  model.traverse((object) => {
+    if (object.isMesh) {
+      // 检查对象及其所有父对象是否可见
+      let isVisible = true
+      let current = object
+      while (current) {
+        if (!current.visible) {
+          isVisible = false
+          break
+        }
+        current = current.parent
+      }
+      if (isVisible) {
+        allObjects.push(object)
+      }
+    }
+  })
+  
+  const intersects = raycaster.intersectObjects(allObjects, false)
+  
+  if (intersects.length > 0) {
+    let clickedObject = intersects[0].object
+    
+    // 找到最顶层的部件,确保它是列表中的部件
+    let topLevelObject = clickedObject
+    while (topLevelObject.parent && topLevelObject.parent !== model) {
+      topLevelObject = topLevelObject.parent
+    }
+    
+    // 检查是否在部件列表中
+    const partInList = partsList.value.find(part => part.uuid === topLevelObject.uuid)
+    if (partInList) {
+      selectPart(topLevelObject.uuid)
+    } else {
+      // 如果不在列表中,尝试找到最近的有名称的父级
+      let namedObject = clickedObject
+      while (namedObject.parent && namedObject.parent !== model) {
+        if (namedObject.name) {
+          // 提取基础名称(去除数字后缀)
+          const baseName = namedObject.name.replace(/_\d+$/, '')
+          // 查找对应的部件
+          const part = partsList.value.find(p => p.name === baseName)
+          if (part) {
+            selectPart(part.uuid)
+            return
+          }
+        }
+        namedObject = namedObject.parent
+      }
+    }
+  } else {
+    // 点击空白处,取消选择
+    deselectPart()
+  }
+}
+
+const deselectPart = () => {
+  if (selectedPart.value) {
+    const previousPart = model.getObjectByProperty('uuid', selectedPart.value)
+    if (previousPart) {
+      if (previousPart.isGroup) {
+        previousPart.traverse((child) => {
+          if (child.isMesh && child.userData.originalMaterial) {
+            child.material = child.userData.originalMaterial
+          }
+        })
+      } else if (previousPart.isMesh && previousPart.userData.originalMaterial) {
+        previousPart.material = previousPart.userData.originalMaterial
+      }
+    }
+    selectedPart.value = null
+  }
+}
+
+const animate = () => {
+  animationId = requestAnimationFrame(animate)
+  if (controls) controls.update()
+  if (renderer && scene && camera) renderer.render(scene, camera)
+}
+
+const getModelCenter = (object) => {
+  let base = null
+  object.traverse((child) => {
+    if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+      base = child
+    }
+  })
+  
+  if (base) {
+    const baseCenter = new THREE.Vector3()
+    if (base.isMesh) {
+      base.geometry.computeBoundingBox()
+      base.geometry.boundingBox.getCenter(baseCenter)
+      baseCenter.applyMatrix4(base.matrixWorld)
+    } else {
+      base.getWorldPosition(baseCenter)
+    }
+    return baseCenter
+  }
+  
+  const box = new THREE.Box3().setFromObject(object)
+  const center = box.getCenter(new THREE.Vector3())
+  return center
+}
+
+const calculateExplosionDirection = (object, center) => {
+  const objectCenter = new THREE.Vector3()
+  
+  if (object.geometry) {
+    object.geometry.computeBoundingBox()
+    object.geometry.boundingBox.getCenter(objectCenter)
+    objectCenter.applyMatrix4(object.matrixWorld)
+  } else {
+    object.getWorldPosition(objectCenter)
+  }
+  
+  const direction = new THREE.Vector3().subVectors(objectCenter, center)
+  
+  if (direction.length() > 0) {
+    direction.normalize()
+  } else {
+    direction.set(
+      Math.random() - 0.5,
+      Math.random() - 0.5,
+      Math.random() - 0.5
+    ).normalize()
+  }
+  
+  return direction
+}
+
+const collectExplodableParts = (object, center) => {
+  const parts = []
+  const partsInfo = []
+  const partNames = new Set()
+  const basePartNames = new Set()
+  
+  object.traverse((child) => {
+    if ((child.isGroup || (child.isMesh && child.geometry)) && child.name) {
+      // 提取基础名称(去除数字后缀)
+      const baseName = child.name.replace(/_\d+$/, '')
+      
+      // 检查是否已经有基础名称的部件
+      if (!basePartNames.has(baseName)) {
+        basePartNames.add(baseName)
+        partNames.add(child.name)
+        
+        originalTransforms.set(child.uuid, {
+          position: child.position.clone(),
+          rotation: child.rotation.clone(),
+          scale: child.scale.clone()
+        })
+        
+        const direction = calculateExplosionDirection(child, center)
+        explosionDirections.set(child.uuid, direction)
+        
+        parts.push(child)
+        partsInfo.push({ uuid: child.uuid, name: baseName, object: child })
+      }
+    }
+  })
+  
+  if (parts.length === 0) {
+    object.traverse((child) => {
+      if (child.isMesh && child.geometry) {
+        originalTransforms.set(child.uuid, {
+          position: child.position.clone(),
+          rotation: child.rotation.clone(),
+          scale: child.scale.clone()
+        })
+        
+        const direction = calculateExplosionDirection(child, center)
+        explosionDirections.set(child.uuid, direction)
+        
+        parts.push(child)
+        partsInfo.push({ uuid: child.uuid, name: `部件 ${child.uuid.substring(0, 8)}`, object: child })
+      }
+    })
+  }
+  
+  partsList.value = partsInfo
+  visibleParts.value = partsInfo.map(part => part.uuid)
+  
+  return parts
+}
+
+const loadModel = () => {
+  if (!scene) {
+    ElMessage.error('场景未初始化')
+    return
+  }
+
+  try {
+    const loader = new GLTFLoader()
+    loader.load(
+      props.modelPath,
+      (gltf) => {
+        model = gltf.scene
+        
+        const boundingBox = new THREE.Box3().setFromObject(model)
+        const size = new THREE.Vector3()
+        boundingBox.getSize(size)
+        const maxSize = Math.max(size.x, size.y, size.z)
+        const scale = 10 / maxSize
+        model.scale.set(scale, scale, scale)
+        
+        const center = getModelCenter(model)
+        model.position.sub(center)
+        
+        model.traverse((child) => {
+          if (child.isMesh) {
+            child.castShadow = true
+            child.receiveShadow = true
+            
+            if (child.material) {
+              child.material.metalness = 0.3
+              child.material.roughness = 0.7
+              child.material.side = THREE.DoubleSide
+              child.material.transparent = false
+              child.material.needsUpdate = true
+            }
+          }
+        })
+        
+        scene.add(model)
+        
+        const newCenter = getModelCenter(model)
+        const parts = collectExplodableParts(model, newCenter)
+        partCount.value = parts.length
+        
+        modelLoaded.value = true
+        emit('model-loaded', { model, parts: parts.length })
+        ElMessage.success(`${props.modelName}加载成功`)
+        
+        resetCamera()
+        
+        if (renderer && scene && camera) {
+          renderer.render(scene, camera)
+        }
+      },
+      (progress) => {
+        const percent = (progress.loaded / progress.total * 100).toFixed(0)
+        console.log(`模型加载进度: ${percent}%`)
+      },
+      (error) => {
+        console.error('模型加载失败:', error)
+        ElMessage.error('模型加载失败: ' + error.message)
+      }
+    )
+  } catch (error) {
+    console.error('模型加载过程中发生异常:', error)
+    ElMessage.error('模型加载过程中发生异常: ' + error.message)
+  }
+}
+
+const updateExplosion = () => {
+  if (!model) return
+  
+  const factor = explosionFactor.value
+  
+  originalTransforms.forEach((original, uuid) => {
+    const child = model.getObjectByProperty('uuid', uuid)
+    if (child) {
+      if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+        return
+      }
+      
+      const direction = explosionDirections.get(uuid)
+      
+      if (direction) {
+        const distance = factor * 1
+        const offset = direction.clone().multiplyScalar(distance)
+        
+        child.position.copy(original.position).add(offset)
+        
+        if (child.isGroup) {
+          child.traverse((groupChild) => {
+            if (groupChild.name && (groupChild.name.includes('底座') || groupChild.name.includes('base') || groupChild.name.includes('Base'))) {
+              return
+            }
+            
+            if (groupChild.isMesh && groupChild !== child) {
+              if (!originalTransforms.has(groupChild.uuid)) {
+                originalTransforms.set(groupChild.uuid, {
+                  position: groupChild.position.clone(),
+                  rotation: groupChild.rotation.clone(),
+                  scale: groupChild.scale.clone()
+                })
+              }
+              
+              const groupChildOriginal = originalTransforms.get(groupChild.uuid)
+              groupChild.position.copy(groupChildOriginal.position).add(offset)
+            }
+          })
+        }
+      }
+    }
+  })
+}
+
+const toggleExplosion = () => {
+  if (!model) {
+    ElMessage.warning('模型未加载')
+    return
+  }
+  
+  isExploded.value = !isExploded.value
+  
+  if (isExploded.value) {
+    animateExplosion(1.5)
+  } else {
+    animateExplosion(0)
+  }
+}
+
+const animateExplosion = (targetFactor) => {
+  const startFactor = explosionFactor.value
+  const duration = 1200
+  const startTime = Date.now()
+  
+  const animate = () => {
+    const elapsed = Date.now() - startTime
+    const progress = Math.min(elapsed / duration, 1)
+    
+    const eased = progress < 0.5 
+      ? 4 * progress * progress * progress 
+      : 1 - Math.pow(-2 * progress + 2, 3) / 2
+    
+    explosionFactor.value = startFactor + (targetFactor - startFactor) * eased
+    updateExplosion()
+    
+    if (progress < 1) {
+      requestAnimationFrame(animate)
+    } else {
+      emit(isExploded.value ? 'explosion-complete' : 'reset-complete')
+    }
+  }
+  
+  animate()
+}
+
+const resetCamera = () => {
+  if (!model || !camera || !controls) return
+  
+  const boundingBox = new THREE.Box3().setFromObject(model)
+  const size = new THREE.Vector3()
+  boundingBox.getSize(size)
+  
+  const distance = size.length() * 2
+  camera.position.set(distance, distance * 0.5, distance)
+  
+  const center = boundingBox.getCenter(new THREE.Vector3())
+  controls.target.copy(center)
+  controls.update()
+}
+
+const toggleWireframe = () => {
+  if (!model) return
+  
+  isWireframe.value = !isWireframe.value
+  
+  model.traverse((child) => {
+    if (child.isMesh && child.material) {
+      if (Array.isArray(child.material)) {
+        child.material.forEach(mat => {
+          mat.wireframe = isWireframe.value
+        })
+      } else {
+        child.material.wireframe = isWireframe.value
+      }
+    }
+  })
+}
+
+const handlePartVisibilityChange = (uuid, value) => {
+  if (!model) return
+  
+  if (value) {
+    if (!visibleParts.value.includes(uuid)) {
+      visibleParts.value.push(uuid)
+    }
+  } else {
+    visibleParts.value = visibleParts.value.filter(id => id !== uuid)
+  }
+  
+  model.traverse((child) => {
+    if (child.uuid === uuid) {
+      child.visible = value
+    }
+  })
+}
+
+const selectPart = (partUuid) => {
+  if (!model) return
+  
+  if (selectedPart.value) {
+    const previousPart = model.getObjectByProperty('uuid', selectedPart.value)
+    if (previousPart) {
+      if (previousPart.isGroup) {
+        previousPart.traverse((child) => {
+          if (child.isMesh && child.userData.originalMaterial) {
+            child.material = child.userData.originalMaterial
+          }
+        })
+      } else if (previousPart.isMesh && previousPart.userData.originalMaterial) {
+        previousPart.material = previousPart.userData.originalMaterial
+      }
+    }
+  }
+  
+  selectedPart.value = partUuid
+  
+  const selectedObject = model.getObjectByProperty('uuid', partUuid)
+  if (selectedObject) {
+    const highlightMaterial = new THREE.MeshStandardMaterial({
+      color: 0xffff00,
+      emissive: 0xffff00,
+      emissiveIntensity: 0.5,
+      metalness: 0.3,
+      roughness: 0.7
+    })
+    
+    if (selectedObject.isGroup) {
+      selectedObject.traverse((child) => {
+        if (child.isMesh) {
+          if (!child.userData.originalMaterial) {
+            child.userData.originalMaterial = child.material
+          }
+          child.material = highlightMaterial
+        }
+      })
+    } else if (selectedObject.isMesh) {
+      if (!selectedObject.userData.originalMaterial) {
+        selectedObject.userData.originalMaterial = selectedObject.material
+      }
+      selectedObject.material = highlightMaterial
+    }
+  }
+}
+
+const focusOnPart = (part) => {
+  if (!camera || !controls || !part) return
+  
+  const boundingBox = new THREE.Box3().setFromObject(part)
+  const size = new THREE.Vector3()
+  boundingBox.getSize(size)
+  
+  const distance = size.length() * 2
+  const center = boundingBox.getCenter(new THREE.Vector3())
+  
+  camera.position.copy(center)
+  camera.position.z += distance
+  
+  controls.target.copy(center)
+  controls.update()
+}
+
+onMounted(() => {
+  setTimeout(() => {
+    if (initThreeScene()) {
+      setTimeout(() => {
+        if (props.autoLoad) {
+          loadModel()
+        }
+      }, 500)
+    }
+  }, 100)
+})
+
+onUnmounted(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+  
+  if (renderer && renderer.domElement) {
+    if (renderer.domElement.parentElement) {
+      renderer.domElement.parentElement.removeChild(renderer.domElement)
+    }
+    renderer.dispose()
+  }
+  
+  if (scene) {
+    scene.clear()
+  }
+  
+  window.removeEventListener('resize', handleResize)
+  if (renderer && renderer.domElement) {
+    renderer.domElement.removeEventListener('mousedown', handleMouseDown)
+    renderer.domElement.removeEventListener('mouseup', handleMouseUp)
+  }
+  
+  // 清除定时器
+  if (clickTimer) {
+    clearTimeout(clickTimer)
+    clickTimer = null
+  }
+})
+
+defineExpose({
+  loadModel,
+  toggleExplosion,
+  resetCamera,
+  toggleWireframe,
+  updateExplosion
+})
+</script>
+
+<style scoped>
+.three-model-viewer {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.parts-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  width: 250px;
+  max-height: calc(100% - 40px);
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  z-index: 1000;
+  overflow-y: auto;
+}
+
+.parts-panel h4 {
+  margin: 0 0 15px 0;
+  color: #303133;
+  font-size: 16px;
+}
+
+.part-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.part-item {
+  display: flex;
+  align-items: center;
+  padding: 5px 0;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  border-radius: 4px;
+}
+
+.part-item:hover {
+  background-color: rgba(64, 158, 255, 0.05);
+}
+
+.part-item.selected {
+  background-color: rgba(64, 158, 255, 0.1);
+  padding: 5px 10px;
+}
+
+.part-item span {
+  flex: 1;
+  cursor: pointer;
+}
+
+.visibility-icon {
+  flex-shrink: 0;
+  cursor: pointer;
+  font-size: 18px;
+  transition: all 0.3s ease;
+}
+
+.visibility-icon:hover {
+  color: #409eff;
+}
+
+.visibility-icon.visible {
+  color: #409eff;
+}
+
+.visibility-icon:not(.visible) {
+  color: #909399;
+}
+
+.part-item span:hover {
+  color: #409eff;
+}
+
+.three-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+}
+
+.control-panel {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  z-index: 1000;
+  display: flex;
+  gap: 10px;
+  pointer-events: auto;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 10px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+}
+
+.control-panel .el-button {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.control-panel .el-button.is-exploded {
+  background-color: #67c23a;
+  border-color: #67c23a;
+}
+
+.explosion-slider {
+  position: absolute;
+  top: 80px;
+  right: 20px;
+  z-index: 1000;
+  width: 300px;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.slider-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+}
+
+.slider-value {
+  font-size: 14px;
+  color: #409eff;
+  font-weight: bold;
+  min-width: 40px;
+  text-align: right;
+}
+
+.model-info-panel {
+  position: absolute;
+  bottom: 20px;
+  right: 20px;
+  z-index: 1000;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  min-width: 200px;
+}
+
+.model-info-panel h4 {
+  margin: 0 0 10px 0;
+  color: #303133;
+  font-size: 16px;
+}
+
+.model-info-panel p {
+  margin: 5px 0;
+  color: #606266;
+  font-size: 14px;
+}
+</style>

+ 724 - 0
RuoYi-Vue3/src/components/ThreeModelViewer/index.vue

@@ -0,0 +1,724 @@
+<template>
+  <div class="three-model-viewer">
+    <!-- 左侧部件列表 -->
+    <div class="parts-panel" v-if="modelLoaded">
+      <h4>模型部件</h4>
+      <div class="part-list">
+        <div 
+          v-for="part in partsList" 
+          :key="part.uuid" 
+          class="part-item"
+          :class="{ 'selected': selectedPart === part.uuid }"
+        >
+          <el-checkbox 
+            :checked="visibleParts.includes(part.uuid)"
+            @change="(val) => handlePartVisibilityChange(part.uuid, val)"
+          />
+          <span @click="selectPart(part.uuid)">{{ part.name }}</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 控制按钮 -->
+    <div class="control-panel">
+      <el-button 
+        type="primary" 
+        :class="{ 'is-exploded': isExploded }"
+        @click="toggleExplosion"
+      >
+        <el-icon><Expand v-if="!isExploded" /><Fold v-else /></el-icon>
+        {{ isExploded ? '还原模型' : '炸开模型' }}
+      </el-button>
+      <el-button 
+        type="warning" 
+        @click="resetCamera"
+      >
+        <el-icon><Aim /></el-icon>
+        重置视角
+      </el-button>
+      <el-button 
+        type="info" 
+        @click="toggleWireframe"
+      >
+        <el-icon><Grid /></el-icon>
+        {{ isWireframe ? '实体模式' : '线框模式' }}
+      </el-button>
+    </div>
+
+    <!-- 爆炸程度滑块 -->
+    <div class="explosion-slider" v-if="isExploded || explosionFactor > 0">
+      <span class="slider-label">爆炸程度</span>
+      <el-slider 
+        v-model="explosionFactor" 
+        :min="0" 
+        :max="3" 
+        :step="0.1"
+        @input="updateExplosion"
+      />
+      <span class="slider-value">{{ explosionFactor.toFixed(1) }}</span>
+    </div>
+
+    <!-- 模型信息面板 -->
+    <div class="model-info-panel" v-if="modelLoaded">
+      <h4>{{ modelName }}</h4>
+      <p>部件数量: {{ partCount }}</p>
+      <p>状态: {{ isExploded ? '已炸开' : '正常' }}</p>
+    </div>
+
+    <!-- Three.js 画布容器 -->
+    <div ref="threeContainer" class="three-container"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { Expand, Fold, Aim, Grid } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const props = defineProps({
+  modelPath: {
+    type: String,
+    default: '/models/启闭机.glb'
+  },
+  modelName: {
+    type: String,
+    default: '模型'
+  },
+  autoLoad: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['model-loaded', 'explosion-complete', 'reset-complete'])
+
+const threeContainer = ref(null)
+const isExploded = ref(false)
+const isWireframe = ref(false)
+const explosionFactor = ref(0)
+const modelLoaded = ref(false)
+const partCount = ref(0)
+const partsList = ref([])
+const visibleParts = ref([])
+const selectedPart = ref(null)
+
+let scene = null
+let camera = null
+let renderer = null
+let controls = null
+let model = null
+let animationId = null
+
+const originalTransforms = new Map()
+const explosionDirections = new Map()
+
+const initThreeScene = () => {
+  if (!threeContainer.value) return false
+
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0xf0f0f0)
+
+  const width = threeContainer.value.clientWidth || 800
+  const height = threeContainer.value.clientHeight || 600
+  camera = new THREE.PerspectiveCamera(60, width / height, 0.01, 10000)
+  camera.position.set(0, 5, 10)
+
+  renderer = new THREE.WebGLRenderer({ antialias: true })
+  renderer.setSize(width, height)
+  renderer.setPixelRatio(window.devicePixelRatio)
+  renderer.shadowMap.enabled = true
+  threeContainer.value.appendChild(renderer.domElement)
+
+  controls = new OrbitControls(camera, renderer.domElement)
+  controls.enableDamping = true
+  controls.dampingFactor = 0.05
+
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+  scene.add(ambientLight)
+
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
+  directionalLight.position.set(10, 20, 10)
+  directionalLight.castShadow = true
+  scene.add(directionalLight)
+
+  const gridHelper = new THREE.GridHelper(20, 20, 0x888888, 0x444444)
+  scene.add(gridHelper)
+
+  window.addEventListener('resize', handleResize)
+  animate()
+  return true
+}
+
+const handleResize = () => {
+  if (!threeContainer.value || !camera || !renderer) return
+
+  const width = threeContainer.value.clientWidth
+  const height = threeContainer.value.clientHeight
+
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+  renderer.setSize(width, height)
+}
+
+const animate = () => {
+  animationId = requestAnimationFrame(animate)
+  if (controls) controls.update()
+  if (renderer && scene && camera) renderer.render(scene, camera)
+}
+
+const getModelCenter = (object) => {
+  let base = null
+  object.traverse((child) => {
+    if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+      base = child
+    }
+  })
+  
+  if (base) {
+    const baseCenter = new THREE.Vector3()
+    if (base.isMesh) {
+      base.geometry.computeBoundingBox()
+      base.geometry.boundingBox.getCenter(baseCenter)
+      baseCenter.applyMatrix4(base.matrixWorld)
+    } else {
+      base.getWorldPosition(baseCenter)
+    }
+    return baseCenter
+  }
+  
+  const box = new THREE.Box3().setFromObject(object)
+  const center = box.getCenter(new THREE.Vector3())
+  return center
+}
+
+const calculateExplosionDirection = (object, center) => {
+  const objectCenter = new THREE.Vector3()
+  
+  if (object.geometry) {
+    object.geometry.computeBoundingBox()
+    object.geometry.boundingBox.getCenter(objectCenter)
+    objectCenter.applyMatrix4(object.matrixWorld)
+  } else {
+    object.getWorldPosition(objectCenter)
+  }
+  
+  const direction = new THREE.Vector3().subVectors(objectCenter, center)
+  
+  if (direction.length() > 0) {
+    direction.normalize()
+  } else {
+    direction.set(
+      Math.random() - 0.5,
+      Math.random() - 0.5,
+      Math.random() - 0.5
+    ).normalize()
+  }
+  
+  return direction
+}
+
+const collectExplodableParts = (object, center) => {
+  const parts = []
+  const partsInfo = []
+  const partNames = new Set()
+  
+  object.traverse((child) => {
+    if ((child.isGroup || (child.isMesh && child.geometry)) && child.name) {
+      if (!partNames.has(child.name)) {
+        partNames.add(child.name)
+        
+        originalTransforms.set(child.uuid, {
+          position: child.position.clone(),
+          rotation: child.rotation.clone(),
+          scale: child.scale.clone()
+        })
+        
+        const direction = calculateExplosionDirection(child, center)
+        explosionDirections.set(child.uuid, direction)
+        
+        parts.push(child)
+        partsInfo.push({ uuid: child.uuid, name: child.name, object: child })
+      }
+    }
+  })
+  
+  if (parts.length === 0) {
+    object.traverse((child) => {
+      if (child.isMesh && child.geometry) {
+        originalTransforms.set(child.uuid, {
+          position: child.position.clone(),
+          rotation: child.rotation.clone(),
+          scale: child.scale.clone()
+        })
+        
+        const direction = calculateExplosionDirection(child, center)
+        explosionDirections.set(child.uuid, direction)
+        
+        parts.push(child)
+        partsInfo.push({ uuid: child.uuid, name: `部件 ${child.uuid.substring(0, 8)}`, object: child })
+      }
+    })
+  }
+  
+  partsList.value = partsInfo
+  visibleParts.value = partsInfo.map(part => part.uuid)
+  
+  return parts
+}
+
+const loadModel = () => {
+  if (!scene) {
+    ElMessage.error('场景未初始化')
+    return
+  }
+
+  try {
+    const loader = new GLTFLoader()
+    loader.load(
+      props.modelPath,
+      (gltf) => {
+        model = gltf.scene
+        
+        const boundingBox = new THREE.Box3().setFromObject(model)
+        const size = new THREE.Vector3()
+        boundingBox.getSize(size)
+        const maxSize = Math.max(size.x, size.y, size.z)
+        const scale = 10 / maxSize
+        model.scale.set(scale, scale, scale)
+        
+        const center = getModelCenter(model)
+        model.position.sub(center)
+        
+        model.traverse((child) => {
+          if (child.isMesh) {
+            child.castShadow = true
+            child.receiveShadow = true
+            
+            if (child.material) {
+              child.material.metalness = 0.3
+              child.material.roughness = 0.7
+              child.material.side = THREE.DoubleSide
+              child.material.transparent = false
+              child.material.needsUpdate = true
+            }
+          }
+        })
+        
+        scene.add(model)
+        
+        const newCenter = getModelCenter(model)
+        const parts = collectExplodableParts(model, newCenter)
+        partCount.value = parts.length
+        
+        modelLoaded.value = true
+        emit('model-loaded', { model, parts: parts.length })
+        ElMessage.success(`${props.modelName}加载成功`)
+        
+        resetCamera()
+        
+        if (renderer && scene && camera) {
+          renderer.render(scene, camera)
+        }
+      },
+      (progress) => {
+        const percent = (progress.loaded / progress.total * 100).toFixed(0)
+        console.log(`模型加载进度: ${percent}%`)
+      },
+      (error) => {
+        console.error('模型加载失败:', error)
+        ElMessage.error('模型加载失败: ' + error.message)
+      }
+    )
+  } catch (error) {
+    console.error('模型加载过程中发生异常:', error)
+    ElMessage.error('模型加载过程中发生异常: ' + error.message)
+  }
+}
+
+const updateExplosion = () => {
+  if (!model) return
+  
+  const factor = explosionFactor.value
+  
+  originalTransforms.forEach((original, uuid) => {
+    const child = model.getObjectByProperty('uuid', uuid)
+    if (child) {
+      if (child.name && (child.name.includes('底座') || child.name.includes('base') || child.name.includes('Base'))) {
+        return
+      }
+      
+      const direction = explosionDirections.get(uuid)
+      
+      if (direction) {
+        const distance = factor * 1
+        const offset = direction.clone().multiplyScalar(distance)
+        
+        child.position.copy(original.position).add(offset)
+        
+        if (child.isGroup) {
+          child.traverse((groupChild) => {
+            if (groupChild.name && (groupChild.name.includes('底座') || groupChild.name.includes('base') || groupChild.name.includes('Base'))) {
+              return
+            }
+            
+            if (groupChild.isMesh && groupChild !== child) {
+              if (!originalTransforms.has(groupChild.uuid)) {
+                originalTransforms.set(groupChild.uuid, {
+                  position: groupChild.position.clone(),
+                  rotation: groupChild.rotation.clone(),
+                  scale: groupChild.scale.clone()
+                })
+              }
+              
+              const groupChildOriginal = originalTransforms.get(groupChild.uuid)
+              groupChild.position.copy(groupChildOriginal.position).add(offset)
+            }
+          })
+        }
+      }
+    }
+  })
+}
+
+const toggleExplosion = () => {
+  if (!model) {
+    ElMessage.warning('模型未加载')
+    return
+  }
+  
+  isExploded.value = !isExploded.value
+  
+  if (isExploded.value) {
+    animateExplosion(1.5)
+  } else {
+    animateExplosion(0)
+  }
+}
+
+const animateExplosion = (targetFactor) => {
+  const startFactor = explosionFactor.value
+  const duration = 1200
+  const startTime = Date.now()
+  
+  const animate = () => {
+    const elapsed = Date.now() - startTime
+    const progress = Math.min(elapsed / duration, 1)
+    
+    const eased = progress < 0.5 
+      ? 4 * progress * progress * progress 
+      : 1 - Math.pow(-2 * progress + 2, 3) / 2
+    
+    explosionFactor.value = startFactor + (targetFactor - startFactor) * eased
+    updateExplosion()
+    
+    if (progress < 1) {
+      requestAnimationFrame(animate)
+    } else {
+      emit(isExploded.value ? 'explosion-complete' : 'reset-complete')
+    }
+  }
+  
+  animate()
+}
+
+const resetCamera = () => {
+  if (!model || !camera || !controls) return
+  
+  const boundingBox = new THREE.Box3().setFromObject(model)
+  const size = new THREE.Vector3()
+  boundingBox.getSize(size)
+  
+  const distance = size.length() * 2
+  camera.position.set(distance, distance * 0.5, distance)
+  
+  const center = boundingBox.getCenter(new THREE.Vector3())
+  controls.target.copy(center)
+  controls.update()
+}
+
+const toggleWireframe = () => {
+  if (!model) return
+  
+  isWireframe.value = !isWireframe.value
+  
+  model.traverse((child) => {
+    if (child.isMesh && child.material) {
+      if (Array.isArray(child.material)) {
+        child.material.forEach(mat => {
+          mat.wireframe = isWireframe.value
+        })
+      } else {
+        child.material.wireframe = isWireframe.value
+      }
+    }
+  })
+}
+
+const handlePartVisibilityChange = (uuid, value) => {
+  if (!model) return
+  
+  if (value) {
+    if (!visibleParts.value.includes(uuid)) {
+      visibleParts.value.push(uuid)
+    }
+  } else {
+    visibleParts.value = visibleParts.value.filter(id => id !== uuid)
+  }
+  
+  model.traverse((child) => {
+    if (child.uuid === uuid) {
+      child.visible = value
+    }
+  })
+}
+
+const selectPart = (partUuid) => {
+  if (!model) return
+  
+  if (selectedPart.value) {
+    const previousPart = model.getObjectByProperty('uuid', selectedPart.value)
+    if (previousPart) {
+      if (previousPart.userData.originalMaterial) {
+        previousPart.material = previousPart.userData.originalMaterial
+      }
+    }
+  }
+  
+  selectedPart.value = partUuid
+  
+  const selectedObject = model.getObjectByProperty('uuid', partUuid)
+  if (selectedObject) {
+    if (!selectedObject.userData.originalMaterial) {
+      selectedObject.userData.originalMaterial = selectedObject.material
+    }
+    
+    const highlightMaterial = new THREE.MeshStandardMaterial({
+      color: 0xffff00,
+      emissive: 0xffff00,
+      emissiveIntensity: 0.5,
+      metalness: 0.3,
+      roughness: 0.7
+    })
+    
+    selectedObject.material = highlightMaterial
+    
+    focusOnPart(selectedObject)
+  }
+}
+
+const focusOnPart = (part) => {
+  if (!camera || !controls || !part) return
+  
+  const boundingBox = new THREE.Box3().setFromObject(part)
+  const size = new THREE.Vector3()
+  boundingBox.getSize(size)
+  
+  const distance = size.length() * 2
+  const center = boundingBox.getCenter(new THREE.Vector3())
+  
+  camera.position.copy(center)
+  camera.position.z += distance
+  
+  controls.target.copy(center)
+  controls.update()
+}
+
+onMounted(() => {
+  setTimeout(() => {
+    if (initThreeScene()) {
+      setTimeout(() => {
+        if (props.autoLoad) {
+          loadModel()
+        }
+      }, 500)
+    }
+  }, 100)
+})
+
+onUnmounted(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+  
+  if (renderer && renderer.domElement) {
+    if (renderer.domElement.parentElement) {
+      renderer.domElement.parentElement.removeChild(renderer.domElement)
+    }
+    renderer.dispose()
+  }
+  
+  if (scene) {
+    scene.clear()
+  }
+  
+  window.removeEventListener('resize', handleResize)
+})
+
+defineExpose({
+  loadModel,
+  toggleExplosion,
+  resetCamera,
+  toggleWireframe,
+  updateExplosion
+})
+</script>
+
+<style scoped>
+.three-model-viewer {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+
+.parts-panel {
+  position: absolute;
+  top: 20px;
+  left: 20px;
+  width: 250px;
+  max-height: calc(100% - 40px);
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  z-index: 1000;
+  overflow-y: auto;
+}
+
+.parts-panel h4 {
+  margin: 0 0 15px 0;
+  color: #303133;
+  font-size: 16px;
+}
+
+.part-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.part-item {
+  display: flex;
+  align-items: center;
+  padding: 5px 0;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  border-radius: 4px;
+}
+
+.part-item:hover {
+  background-color: rgba(64, 158, 255, 0.05);
+}
+
+.part-item.selected {
+  background-color: rgba(64, 158, 255, 0.1);
+  padding: 5px 10px;
+}
+
+.part-item .el-checkbox {
+  margin-right: 10px;
+  flex-shrink: 0;
+}
+
+.part-item span {
+  flex: 1;
+  cursor: pointer;
+}
+
+.part-item span:hover {
+  color: #409eff;
+}
+
+.three-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1;
+}
+
+.control-panel {
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  z-index: 1000;
+  display: flex;
+  gap: 10px;
+  pointer-events: auto;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 10px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+}
+
+.control-panel .el-button {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.control-panel .el-button.is-exploded {
+  background-color: #67c23a;
+  border-color: #67c23a;
+}
+
+.explosion-slider {
+  position: absolute;
+  top: 80px;
+  right: 20px;
+  z-index: 1000;
+  width: 300px;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.slider-label {
+  font-size: 14px;
+  color: #606266;
+  white-space: nowrap;
+}
+
+.slider-value {
+  font-size: 14px;
+  color: #409eff;
+  font-weight: bold;
+  min-width: 40px;
+  text-align: right;
+}
+
+.model-info-panel {
+  position: absolute;
+  top: 20px;
+  right: 340px;
+  z-index: 1000;
+  background: rgba(255, 255, 255, 0.9);
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+  pointer-events: auto;
+  min-width: 200px;
+}
+
+.model-info-panel h4 {
+  margin: 0 0 10px 0;
+  color: #303133;
+  font-size: 16px;
+}
+
+.model-info-panel p {
+  margin: 5px 0;
+  color: #606266;
+  font-size: 14px;
+}
+</style>

+ 21 - 0
RuoYi-Vue3/src/router/index.js

@@ -66,6 +66,27 @@ export const constantRoutes = [
     name: 'FrontModelManagement',
     hidden: true
   },
+  // 模型查看器演示页面
+  {
+    path: '/model-viewer-demo',
+    component: () => import('@/views/model-viewer-demo.vue'),
+    name: 'ModelViewerDemo',
+    hidden: true
+  },
+  // 模型测试页面
+  {
+    path: '/model-test',
+    component: () => import('@/views/model-test.vue'),
+    name: 'ModelTest',
+    hidden: true
+  },
+  // Three.js 基础测试页面
+  {
+    path: '/three-test',
+    component: () => import('@/views/three-test.vue'),
+    name: 'ThreeTest',
+    hidden: true
+  },
   // 后台管理模块 - 所有后台功能都放在/admin下
   {
     path: '/admin',

+ 70 - 6
RuoYi-Vue3/src/supermap-cesium-module/views/layout/aside.vue

@@ -195,6 +195,8 @@
             </el-popconfirm>
           </el-menu-item>
         </el-sub-menu>
+
+
       </el-menu>
     </el-aside>
     <!-- 侧边栏部分--end -->
@@ -211,6 +213,7 @@
         :visible="showTyphoon"
         :data-url="typhoonDataUrl"
       ></TyphoonVisualization>
+
       <!-- 添加自定义服务组件 -->
       <component
         v-if="addService"
@@ -232,6 +235,25 @@
       </keep-alive>
       <!-- 地质体组件,单独展示,需要销毁 -->
       <component v-if="view2" :is="view2" :key="view2"></component>
+      
+      <!-- 模型拆解弹框 -->
+      <teleport to="body">
+        <el-dialog
+          v-model="showModelDialog"
+          title="模型拆解"
+          width="70%"
+          destroy-on-close
+          style="top: 50% !important; left: 50% !important; transform: translate(-60%, -50%) !important;"
+        >
+          <div style="width: 100%; height: 600px;">
+            <ThreeModelViewer 
+              :model-path="'/models/启闭机.glb'"
+              :model-name="'启闭机模型'"
+              :auto-load="true"
+            />
+          </div>
+        </el-dialog>
+      </teleport>
     </el-main>
   </el-container>
 </template>
@@ -244,7 +266,9 @@ import layerManagement from "../../js/common/layerManagement.js";  //图层管
 import camera from "../../js/common/camera.js";  //相机操作
 import loadingBar from "../../components/loading.vue";  //加载动画
 import TyphoonVisualization from "../../components/typhoon-visualization/typhoon-visualization.vue";  //台风可视化组件
-import { CircleClose, Plus, CirclePlus } from '@element-plus/icons-vue';  //删除图标
+import CesiumThreeFusion from "@/components/ThreeCesiumIntegration/CesiumThreeFusion.vue";  //Three.js与Cesium融合组件
+import ThreeModelViewer from "@/components/ThreeModelViewer/ModelViewer.vue";  //模型查看器组件
+import { CircleClose, Plus, CirclePlus, Setting } from '@element-plus/icons-vue';  //删除图标
 import { listModel } from '@/api/watershed/model';  //模型API
 import { getDefaultMapConfig, saveMapConfig } from '@/api/cesium/mapConfig';  //地图配置API
 import serviceApi from '@/api/watershed/service';  //自定义服务API
@@ -258,7 +282,9 @@ export default {
   components: {
     loadingBar,
     CircleClose,
-    TyphoonVisualization
+    TyphoonVisualization,
+    CesiumThreeFusion,
+    ThreeModelViewer
   },
   setup() {
     // 使用 computed 来获取 window.viewer
@@ -294,6 +320,7 @@ export default {
       showTyphoon: false,  //是否显示台风可视化
       typhoonDataUrl: '',  //台风数据URL
       loadedTyphoonId: null,  //已加载的台风ID
+      showModelDialog: false,  //是否显示模型弹框
     };
   },
 
@@ -693,7 +720,7 @@ export default {
     // 加载台风路径
     loadTyphoon(typhoonId) {
       console.log('加载台风路径:', typhoonId)
-      
+
       if (!window.viewer) {
         ElMessage.error('Cesium viewer 未初始化')
         return
@@ -701,17 +728,19 @@ export default {
 
       // 使用API地址获取台风数据
       const typhoonJsonUrl = `https://typhoon.slt.zj.gov.cn/Api/TyphoonInfo/${typhoonId}`
-      
+
       console.log('台风数据URL:', typhoonJsonUrl)
-      
+
       // 显示台风可视化组件
       this.showTyphoon = true
       this.typhoonDataUrl = typhoonJsonUrl
       this.loadedTyphoonId = typhoonId
-      
+
       ElMessage.success('台风路径加载成功')
     },
 
+
+
     // 区分地质体组件(需销毁)和其他组件
     change(val, val2) {
       if (val2) {
@@ -982,8 +1011,43 @@ export default {
         await this.fetchCustomServices();
         await this.restoreLoadedServices();
         this.isRestoring = false; // 恢复完成,允许保存
+        
+        // 添加模型图标
+        this.addModelIcon();
       }, 1500);
     },
+    
+    // 添加模型图标到 Cesium 地图
+    addModelIcon() {
+      if (!window.viewer) return
+      
+      // 定义图标位置(经纬度)
+      const longitude = 118.853990
+      const latitude = 25.230104
+      const height = 0.03
+      
+      // 创建图标实体
+      const modelIcon = window.viewer.entities.add({
+        name: '模型拆解',
+        position: Cesium.Cartesian3.fromDegrees(longitude, latitude, height),
+        billboard: {
+          image: '/img/svg/logo.svg', // 使用现有的图标
+          width: 32,
+          height: 32,
+          verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
+          pixelOffset: new Cesium.Cartesian2(0, -16)
+        },
+        description: '点击查看模型拆解'
+      })
+      
+      // 添加点击事件
+      window.viewer.screenSpaceEventHandler.setInputAction((movement) => {
+        const pickedObject = window.viewer.scene.pick(movement.position)
+        if (Cesium.defined(pickedObject) && pickedObject.id === modelIcon) {
+          this.showModelDialog = true
+        }
+      }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
+    },
 
     // 删除场景公共服务
     DeleteDates(datatype, obj) {

+ 54 - 0
RuoYi-Vue3/src/views/model-test.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="model-test">
+    <h1>模型测试页面</h1>
+    <div class="model-container" style="width: 100%; height: 80vh; border: 1px solid #ccc;">
+      <ThreeModelViewer 
+        :model-path="'/models/启闭机.glb'"
+        :model-name="'启闭机模型'"
+        :auto-load="true"
+        @model-loaded="handleModelLoaded"
+        @explosion-complete="handleExplosionComplete"
+        @reset-complete="handleResetComplete"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import ThreeModelViewer from '@/components/ThreeModelViewer/ModelViewer.vue'
+
+// 处理模型加载完成
+const handleModelLoaded = (data) => {
+  console.log('模型加载完成:', data)
+  alert('模型加载完成!')
+}
+
+// 处理爆炸完成
+const handleExplosionComplete = () => {
+  console.log('模型炸开完成')
+}
+
+// 处理重置完成
+const handleResetComplete = () => {
+  console.log('模型重置完成')
+}
+</script>
+
+<style scoped>
+.model-test {
+  padding: 20px;
+}
+
+.model-test h1 {
+  font-size: 24px;
+  color: #303133;
+  margin-bottom: 20px;
+}
+
+.model-container {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+}
+</style>

+ 116 - 0
RuoYi-Vue3/src/views/model-viewer-demo.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="model-viewer-demo">
+    <h1>模型查看器演示</h1>
+    <p>点击下方按钮打开模型查看器弹框,体验模型炸开效果</p>
+    
+    <el-button type="primary" size="large" @click="openModelDialog">
+      <el-icon><View /></el-icon>
+      打开模型查看器
+    </el-button>
+
+    <!-- 模型查看器弹框 -->
+    <el-dialog
+      v-model="dialogVisible"
+      title="模型查看器"
+      width="90%"
+      :close-on-click-modal="false"
+    >
+      <div class="model-viewer-container" style="width: 100%; height: 80vh;">
+        <ThreeModelViewer 
+          :model-path="modelPath"
+          :model-name="modelName"
+          :auto-load="true"
+          @model-loaded="handleModelLoaded"
+          @explosion-complete="handleExplosionComplete"
+          @reset-complete="handleResetComplete"
+        />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { View } from '@element-plus/icons-vue'
+import ThreeModelViewer from '@/components/ThreeModelViewer/index.vue'
+
+// 响应式数据
+const dialogVisible = ref(false)
+const modelPath = ref('/models/启闭机.glb')
+const modelName = ref('启闭机模型')
+
+// 处理模型加载完成
+const handleModelLoaded = (data) => {
+  console.log('模型加载完成:', data)
+}
+
+// 处理爆炸完成
+const handleExplosionComplete = () => {
+  console.log('模型炸开完成')
+}
+
+// 处理重置完成
+const handleResetComplete = () => {
+  console.log('模型重置完成')
+}
+
+// 打开模型查看器弹框
+const openModelDialog = () => {
+  dialogVisible.value = true
+}
+</script>
+
+<style scoped>
+.model-viewer-demo {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 100vh;
+  padding: 20px;
+  background-color: #f5f7fa;
+}
+
+.model-viewer-demo h1 {
+  font-size: 24px;
+  color: #303133;
+  margin-bottom: 20px;
+}
+
+.model-viewer-demo p {
+  font-size: 16px;
+  color: #606266;
+  margin-bottom: 40px;
+  text-align: center;
+}
+
+.model-viewer-container {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+:deep(.el-dialog__body) {
+  padding: 0 !important;
+  height: calc(100% - 60px);
+}
+
+:deep(.el-dialog__header) {
+  border-bottom: 1px solid #e4e7ed;
+  padding: 15px 20px;
+}
+
+:deep(.el-dialog__title) {
+  font-size: 16px;
+  font-weight: 600;
+}
+
+:deep(.el-dialog__footer) {
+  border-top: 1px solid #e4e7ed;
+  padding: 15px 20px;
+  text-align: right;
+}
+</style>

+ 102 - 0
RuoYi-Vue3/src/views/three-test.vue

@@ -0,0 +1,102 @@
+<template>
+  <div class="three-test">
+    <h1>Three.js 基础测试</h1>
+    <div ref="container" class="container" style="width: 800px; height: 600px; border: 1px solid #ccc;"></div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import * as THREE from 'three'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+
+const container = ref(null)
+let scene, camera, renderer, controls, cube
+
+onMounted(() => {
+  console.log('Three.js 测试组件已挂载')
+  
+  // 创建场景
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0xf0f0f0)
+  
+  // 创建相机
+  camera = new THREE.PerspectiveCamera(75, 800 / 600, 0.1, 1000)
+  camera.position.z = 5
+  
+  // 创建渲染器
+  renderer = new THREE.WebGLRenderer({ antialias: true })
+  renderer.setSize(800, 600)
+  container.value.appendChild(renderer.domElement)
+  
+  // 添加轨道控制器
+  controls = new OrbitControls(camera, renderer.domElement)
+  
+  // 添加光源
+  const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
+  scene.add(ambientLight)
+  
+  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
+  directionalLight.position.set(1, 1, 1)
+  scene.add(directionalLight)
+  
+  // 添加一个立方体
+  const geometry = new THREE.BoxGeometry(1, 1, 1)
+  const material = new THREE.MeshStandardMaterial({ color: 0x0077ff })
+  cube = new THREE.Mesh(geometry, material)
+  scene.add(cube)
+  
+  // 添加网格辅助线
+  const gridHelper = new THREE.GridHelper(10, 10)
+  scene.add(gridHelper)
+  
+  // 开始渲染循环
+  animate()
+  
+  console.log('Three.js 场景初始化完成')
+})
+
+const animate = () => {
+  requestAnimationFrame(animate)
+  
+  // 旋转立方体
+  if (cube) {
+    cube.rotation.x += 0.01
+    cube.rotation.y += 0.01
+  }
+  
+  if (controls) controls.update()
+  if (renderer && scene && camera) renderer.render(scene, camera)
+}
+
+onUnmounted(() => {
+  if (renderer && renderer.domElement) {
+    if (renderer.domElement.parentElement) {
+      renderer.domElement.parentElement.removeChild(renderer.domElement)
+    }
+    renderer.dispose()
+  }
+  
+  if (scene) {
+    scene.clear()
+  }
+})
+</script>
+
+<style scoped>
+.three-test {
+  padding: 20px;
+}
+
+.three-test h1 {
+  font-size: 24px;
+  color: #303133;
+  margin-bottom: 20px;
+}
+
+.container {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+}
+</style>

+ 3 - 1
RuoYi-Vue3/vite.config.js

@@ -67,7 +67,9 @@ export default defineConfig(({ mode, command }) => {
         overlay: true
       },
       // 增加服务器响应超时时间
-      timeout: 30000,
+      timeout: 60000,
+      // 增加最大请求体大小
+      maxBodySize: '100mb',
       proxy: {
         // https://cn.vitejs.dev/config/#server-proxy
         '/dev-api': {