Kaynağa Gözat

模型查看预览

WQQ 1 hafta önce
ebeveyn
işleme
b261c51e4c

+ 2 - 0
RuoYi-Vue3/package.json

@@ -17,6 +17,7 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "2.3.1",
+    "@types/three": "^0.182.0",
     "@vueup/vue-quill": "1.2.0",
     "@vueuse/core": "13.3.0",
     "autofit.js": "^3.2.8",
@@ -32,6 +33,7 @@
     "nprogress": "0.2.0",
     "pinia": "3.0.2",
     "splitpanes": "4.0.4",
+    "three": "^0.182.0",
     "vue": "3.5.16",
     "vue-cropper": "1.1.1",
     "vue-router": "4.5.1",

+ 1152 - 0
RuoYi-Vue3/src/views/front/content/ModelPreview.vue

@@ -0,0 +1,1152 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    :title="modelName"
+    width="80%"
+    :before-close="handleClose"
+    destroy-on-close
+  >
+    <div class="model-preview-container">
+      <div ref="modelContainer" class="model-container"></div>
+      <div class="model-info">
+        <h3>模型信息</h3>
+        <el-descriptions :column="1" border>
+          <el-descriptions-item label="模型名称">{{ modelData.name }}</el-descriptions-item>
+          <el-descriptions-item label="模型类型">{{ getTypeDisplayName(modelData.type) }}</el-descriptions-item>
+          <el-descriptions-item label="经纬度">{{ modelData.location }}</el-descriptions-item>
+          <el-descriptions-item label="提交单位">{{ modelData.uploadUnit }}</el-descriptions-item>
+          <el-descriptions-item label="格式">{{ modelData.format }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ modelData.status }}</el-descriptions-item>
+          <el-descriptions-item label="创建时间">{{ modelData.createTime }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+    </div>
+    <template #footer>
+      <el-button @click="handleClose">关闭</el-button>
+    </template>
+  </el-dialog>
+</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 { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
+import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
+// 导入项目的request实例
+import request from '@/utils/request'
+
+const props = defineProps({
+  visible: {
+    type: Boolean,
+    default: false
+  },
+  modelData: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const emit = defineEmits(['update:visible', 'close'])
+
+const modelContainer = ref(null)
+let scene = null
+let camera = null
+let renderer = null
+let model = null
+let controls = null
+let animationId = null
+const dialogVisible = ref(false)
+
+// 获取类型显示名称
+const getTypeDisplayName = (type) => {
+  const typeMapping = {
+    'RESERVOIR': '水库',
+    'HYDROPOWER': '水电站', 
+    'IRRIGATION': '灌溉',
+    'FLOOD_CONTROL': '防洪',
+    'CANAL': '渠道',
+    'PUMPING_STATION': '泵站'
+  }
+  return typeMapping[type] || type
+}
+
+// 初始化场景
+const initScene = () => {
+  if (!modelContainer.value) return
+
+  // 创建场景
+  scene = new THREE.Scene()
+  scene.background = new THREE.Color(0xf0f0f0)
+
+  // 创建相机
+  camera = new THREE.PerspectiveCamera(
+    75,
+    modelContainer.value.clientWidth / modelContainer.value.clientHeight,
+    0.1,
+    1000
+  )
+  // 增加相机初始位置,让相机离模型更远,以便能看到整个模型
+  camera.position.z = 10
+  camera.position.y = 2
+
+  // 创建渲染器
+  renderer = new THREE.WebGLRenderer({ antialias: true })
+  renderer.setSize(modelContainer.value.clientWidth, modelContainer.value.clientHeight)
+  modelContainer.value.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 gridHelper = new THREE.GridHelper(10, 10)
+  scene.add(gridHelper)
+
+  // 加载模型
+  if (props.modelData) {
+    loadModel()
+  }
+
+  // 添加鼠标控制
+  initControls()
+
+  // 添加三维坐标轴
+  addAxesHelper()
+
+  // 开始渲染
+  animate()
+
+  // 监听窗口大小变化
+  window.addEventListener('resize', handleResize)
+}
+
+// 初始化控制器
+const initControls = () => {
+  // 简单的鼠标控制实现 - 控制相机视角
+  let isDragging = false
+  let previousMousePosition = { x: 0, y: 0 }
+  
+  // 相机目标点(看向的点)
+  let cameraTarget = new THREE.Vector3(0, 0, 0)
+  
+  // 更新相机目标点为模型中心
+  const updateCameraTarget = () => {
+    if (model) {
+      model.updateMatrixWorld(true)
+      const box = new THREE.Box3().setFromObject(model)
+      cameraTarget = box.getCenter(new THREE.Vector3())
+      console.log('相机目标点已更新为模型中心:', cameraTarget)
+    }
+  }
+  
+  // 初始化时更新目标点
+  updateCameraTarget()
+
+  const container = modelContainer.value
+  container.addEventListener('mousedown', (e) => {
+    isDragging = true
+    previousMousePosition = { x: e.offsetX, y: e.offsetY }
+  })
+
+  container.addEventListener('mousemove', (e) => {
+    if (!isDragging) return
+
+    const deltaMove = {
+      x: e.offsetX - previousMousePosition.x,
+      y: e.offsetY - previousMousePosition.y
+    }
+
+    // 控制相机围绕目标点旋转
+    if (camera) {
+      // 计算相机到目标点的距离
+      const distance = camera.position.distanceTo(cameraTarget)
+      
+      // 限制垂直旋转范围,避免相机翻转
+      const currentPosition = new THREE.Vector3().copy(camera.position)
+      const direction = new THREE.Vector3().subVectors(currentPosition, cameraTarget).normalize()
+      
+      // 水平旋转(围绕Y轴)
+      const horizontalRotation = new THREE.Matrix4().makeRotationY(-deltaMove.x * 0.01)
+      // 垂直旋转(围绕X轴,限制范围)
+      const verticalRotation = new THREE.Matrix4().makeRotationX(-deltaMove.y * 0.005)
+      
+      // 应用旋转
+      const newDirection = direction.applyMatrix4(horizontalRotation).applyMatrix4(verticalRotation)
+      
+      // 设置新的相机位置
+      camera.position.copy(cameraTarget).add(newDirection.multiplyScalar(distance))
+      
+      // 相机看向目标点
+      camera.lookAt(cameraTarget)
+    }
+
+    previousMousePosition = {
+      x: e.offsetX,
+      y: e.offsetY
+    }
+  })
+
+  container.addEventListener('mouseup', () => {
+    isDragging = false
+  })
+
+  container.addEventListener('mouseleave', () => {
+    isDragging = false
+  })
+
+  // 滚轮缩放 - 控制相机距离目标点的距离
+  container.addEventListener('wheel', (e) => {
+    e.preventDefault()
+    if (camera) {
+      // 计算相机到目标点的向量
+      const direction = new THREE.Vector3().subVectors(camera.position, cameraTarget)
+      const distance = direction.length()
+      
+      // 限制最小和最大距离
+      const minDistance = 0.5
+      const maxDistance = 50
+      
+      // 计算新的距离
+      const newDistance = Math.max(minDistance, Math.min(maxDistance, distance + e.deltaY * 0.01))
+      
+      // 设置新的相机位置
+      direction.normalize().multiplyScalar(newDistance)
+      camera.position.copy(cameraTarget).add(direction)
+      
+      // 相机看向目标点
+      camera.lookAt(cameraTarget)
+    }
+  })
+}
+
+// 添加三维坐标轴
+const addAxesHelper = () => {
+  // 移除现有的坐标轴
+  if (scene) {
+    scene.traverse((object) => {
+      if (object.isAxesHelper) {
+        scene.remove(object)
+      }
+    })
+  }
+  
+  // 创建新的坐标轴辅助器
+  if (scene) {
+    const axesHelper = new THREE.AxesHelper(2)
+    // 设置坐标轴位置在世界空间的固定位置
+    // 这个位置会显示在视口左下角
+    axesHelper.position.set(-4, -4, 0)
+    axesHelper.scale.set(1, 1, 1)
+    
+    // 确保坐标轴不会受到场景其他变换的影响
+    axesHelper.updateMatrixWorld(true)
+    
+    scene.add(axesHelper)
+    console.log('三维坐标轴已添加到视口左下角')
+  }
+}
+
+// 加载模型
+const loadModel = () => {
+  console.log('========================================')
+  console.log('开始加载模型流程')
+  console.log('========================================')
+  
+  if (!props.modelData || !props.modelData.format) {
+    console.error('模型数据不完整:', { modelData: props.modelData })
+    console.log('========================================')
+    console.log('加载模型流程结束 - 数据不完整')
+    console.log('========================================')
+    return
+  }
+
+  console.log('模型基本信息:', {
+    modelId: props.modelData.id,
+    modelFormat: props.modelData.format,
+    modelName: props.modelData.name
+  })
+
+  // 打印完整的模型数据,查看是否包含文件路径或URL
+  console.log('完整模型数据:', props.modelData)
+  console.log('模型数据所有属性:', Object.keys(props.modelData))
+
+  const format = props.modelData.format.toUpperCase()
+  
+  // 清除现有模型
+  if (model) {
+    scene.remove(model)
+    model = null
+    console.log('已清除现有模型')
+  }
+
+  try {
+    // 检查模型数据中是否包含文件路径或URL
+    let modelUrl = null
+    
+    // 尝试从模型数据中获取文件路径或URL
+    if (props.modelData.path) {
+      modelUrl = props.modelData.path
+      console.log('✓ 从modelData.path获取模型路径:', modelUrl)
+    } else if (props.modelData.url) {
+      modelUrl = props.modelData.url
+      console.log('✓ 从modelData.url获取模型路径:', modelUrl)
+    } else if (props.modelData.filePath) {
+      // 后端返回的filePath是相对路径,需要添加/profile前缀
+      modelUrl = `/profile/${props.modelData.filePath}`
+      console.log('✓ 从modelData.filePath获取模型路径并添加前缀:', props.modelData.filePath, '→', modelUrl)
+    } else if (props.modelData.file_path) {
+      // 检查file_path是否已经包含/profile前缀
+      if (props.modelData.file_path.startsWith('/profile')) {
+        // 已经包含/profile前缀,直接使用
+        modelUrl = props.modelData.file_path
+        console.log('✓ 从modelData.file_path获取模型路径(已包含/profile前缀):', modelUrl)
+      } else {
+        // 后端返回的file_path是相对路径,需要添加/profile前缀
+        modelUrl = `/profile/${props.modelData.file_path}`
+        console.log('✓ 从modelData.file_path获取模型路径并添加前缀:', props.modelData.file_path, '→', modelUrl)
+      }
+    } else if (props.modelData.fileUrl) {
+      modelUrl = props.modelData.fileUrl
+      console.log('✓ 从modelData.fileUrl获取模型路径:', modelUrl)
+    } else if (props.modelData.storagePath) {
+      modelUrl = props.modelData.storagePath
+      console.log('✓ 从modelData.storagePath获取模型路径:', modelUrl)
+    } else if (props.modelData.filename) {
+      // 直接使用filename构建路径,添加/profile/upload前缀
+      modelUrl = `/profile/upload/${props.modelData.filename}`
+      console.log('✓ 从modelData.filename获取模型路径:', modelUrl)
+    } else if (props.modelData.fileName) {
+      // 直接使用fileName构建路径,添加/profile/upload前缀
+      modelUrl = `/profile/upload/${props.modelData.fileName}`
+      console.log('✓ 从modelData.fileName获取模型路径:', modelUrl)
+    } else if (props.modelData.id) {
+      // 如果没有找到文件路径,尝试使用ID构建路径
+      // 尝试多种可能的静态文件路径,添加/profile前缀
+      const modelId = props.modelData.id
+      const possiblePaths = [
+        `/profile/upload/${modelId}.${props.modelData.format.toLowerCase()}`,
+        `/profile/model/${modelId}.${props.modelData.format.toLowerCase()}`
+      ]
+      
+      // 尝试每个路径
+      console.log('开始尝试构建模型路径:')
+      for (const path of possiblePaths) {
+        console.log('  尝试路径:', path)
+      }
+      
+      // 使用第一个可能的路径
+      modelUrl = possiblePaths[0]
+      console.log('✓ 使用ID构建模型路径:', modelUrl)
+    }
+
+    // 如果找到了模型路径
+    if (modelUrl) {
+      // 确保路径是完整的URL
+      if (!modelUrl.startsWith('http://') && !modelUrl.startsWith('https://')) {
+        // 检查是否已经是绝对路径(以/开头)
+        if (!modelUrl.startsWith('/')) {
+          // 如果是相对路径,添加基础路径
+          const oldUrl = modelUrl
+          modelUrl = `/${modelUrl}`
+          console.log('✓ 添加斜杠前缀:', oldUrl, '→', modelUrl)
+        }
+        
+        // 检查是否需要添加/upload前缀
+        // 根据后端代码,FileUploadUtils.upload()返回的路径已经包含了upload前缀
+        // 所以这里不需要再添加/upload/model/前缀
+        console.log('✓ 路径处理完成:', modelUrl)
+      }
+      
+      console.log('========================================')
+      console.log('最终确定的模型路径:', modelUrl)
+      console.log('========================================')
+      
+      // 根据模型格式加载
+      switch (format) {
+        case 'GLTF':
+        case 'GLB':
+          console.log('开始加载GLTF/GLB模型')
+          loadGLTFModel(modelUrl)
+          break
+        case 'OBJ':
+          console.log('开始加载OBJ模型')
+          loadOBJModel(modelUrl)
+          break
+        case 'FBX':
+          console.log('开始加载FBX模型')
+          loadFBXModel(modelUrl)
+          break
+        case 'STL':
+          console.log('开始加载STL模型')
+          loadSTLModel(modelUrl)
+          break
+        default:
+          console.error('不支持的模型格式:', format)
+          // 显示默认模型
+          createDefaultModel()
+          console.log('========================================')
+          console.log('加载模型流程结束 - 格式不支持')
+          console.log('========================================')
+      }
+    } else {
+      console.error('❌ 模型数据中未找到文件路径或URL')
+      // 显示默认模型
+      createDefaultModel()
+      console.log('========================================')
+      console.log('加载模型流程结束 - 未找到路径')
+      console.log('========================================')
+    }
+  } catch (error) {
+    console.error('❌ 加载模型出错:', error)
+    console.error('错误详情:', error.message)
+    console.error('错误堆栈:', error.stack)
+    // 显示默认模型
+    createDefaultModel()
+    console.log('========================================')
+    console.log('加载模型流程结束 - 发生错误')
+    console.log('========================================')
+  }
+}
+
+// 加载GLTF/GLB模型
+const loadGLTFModel = (url) => {
+  console.log('开始加载GLTF/GLB模型:', url)
+  
+  // 使用后端接口获取文件
+  // 直接传递包含/profile前缀的完整路径,后端会处理前缀
+  const resourcePath = url
+  const apiUrl = `/common/download/resource?resource=${encodeURIComponent(resourcePath)}`
+  console.log('使用后端接口获取文件:', apiUrl)
+  
+  request({
+    url: apiUrl,
+    method: 'get',
+    responseType: 'blob'
+  })
+    .then(response => {
+      const blob = response
+      console.log('响应是Blob,大小:', blob.size)
+      console.log('Blob类型:', blob.type)
+      
+      // 使用Blob创建临时URL
+      const blobUrl = URL.createObjectURL(blob)
+      console.log('创建Blob URL:', blobUrl)
+      
+      // 使用Blob URL加载模型
+      const loader = new GLTFLoader()
+      loader.load(
+        blobUrl,
+        (gltf) => {
+          console.log('GLTF/GLB模型加载成功:', gltf)
+          model = gltf.scene
+          scene.add(model)
+          // 使用模型自身的原始坐标,不进行中心调整
+          console.log('模型添加到场景,使用原始坐标')
+          console.log('模型原始位置:', model.position)
+          console.log('模型原始旋转:', model.rotation)
+          console.log('模型原始缩放:', model.scale)
+          // 调整相机位置,使模型在视角中心且完整可见
+          adjustCameraForModel()
+          console.log('相机位置调整完成')
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+        },
+        (xhr) => {
+          console.log('GLTF/GLB模型加载进度:', (xhr.loaded / xhr.total * 100) + '% loaded')
+        },
+        (error) => {
+          console.error('GLTF加载错误:', error)
+          console.error('错误详情:', error.message)
+          console.error('错误堆栈:', error.stack)
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+          createDefaultModel()
+        }
+      )
+    })
+    .catch(error => {
+      console.error('后端接口请求错误:', error)
+      console.error('错误详情:', error.message)
+      console.error('错误堆栈:', error.stack)
+      createDefaultModel()
+    })
+}
+
+// 加载OBJ模型
+const loadOBJModel = (url) => {
+  console.log('开始加载OBJ模型:', url)
+  
+  // 使用后端接口获取文件
+  // 直接传递包含/profile前缀的完整路径,后端会处理前缀
+  const resourcePath = url
+  const apiUrl = `/common/download/resource?resource=${encodeURIComponent(resourcePath)}`
+  console.log('使用后端接口获取文件:', apiUrl)
+  
+  request({
+    url: apiUrl,
+    method: 'get',
+    responseType: 'blob'
+  })
+    .then(response => {
+      const blob = response
+      console.log('OBJ模型响应是Blob,大小:', blob.size)
+      console.log('Blob类型:', blob.type)
+      
+      // 使用Blob创建临时URL
+      const blobUrl = URL.createObjectURL(blob)
+      console.log('创建OBJ Blob URL:', blobUrl)
+      
+      // 使用Blob URL加载模型
+      const loader = new OBJLoader()
+      loader.load(
+        blobUrl,
+        (obj) => {
+          console.log('OBJ模型加载成功:', obj)
+          model = obj
+          // 添加材质
+          const material = new THREE.MeshStandardMaterial({ color: 0x409eff })
+          obj.traverse((child) => {
+            if (child.isMesh) {
+              child.material = material
+            }
+          })
+          scene.add(model)
+          // 使用模型自身的原始坐标,不进行中心调整
+          console.log('模型添加到场景,使用原始坐标')
+          console.log('模型原始位置:', model.position)
+          console.log('模型原始旋转:', model.rotation)
+          console.log('模型原始缩放:', model.scale)
+          // 调整相机位置,使模型在视角中心且完整可见
+          adjustCameraForModel()
+          console.log('相机位置调整完成')
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+        },
+        (xhr) => {
+          console.log('OBJ模型加载进度:', (xhr.loaded / xhr.total * 100) + '% loaded')
+        },
+        (error) => {
+          console.error('OBJ加载错误:', error)
+          console.error('错误详情:', error.message)
+          console.error('错误堆栈:', error.stack)
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+          createDefaultModel()
+        }
+      )
+    })
+    .catch(error => {
+      console.error('后端接口请求错误:', error)
+      console.error('错误详情:', error.message)
+      console.error('错误堆栈:', error.stack)
+      createDefaultModel()
+    })
+}
+
+// 处理FBX模型的特殊问题
+const processFBXModel = (fbxModel) => {
+  console.log('开始专业处理FBX模型')
+  
+  // 1. FBX模型坐标系转换
+  console.log('处理FBX坐标系...')
+  
+  // FBX通常使用Y-up坐标系,Three.js使用Y-up,所以不需要翻转
+  // 但需要确保缩放和位置正确
+  
+  // 2. 统一模型缩放
+  console.log('处理FBX模型缩放...')
+  console.log('原始模型缩放:', fbxModel.scale)
+  
+  // 计算模型的整体缩放因子
+  let maxScale = 1
+  fbxModel.traverse((child) => {
+    if (child.isMesh) {
+      maxScale = Math.max(maxScale, child.scale.x, child.scale.y, child.scale.z)
+    }
+  })
+  
+  console.log('最大缩放因子:', maxScale)
+  
+  // 如果缩放因子过大或过小,进行统一调整
+  if (maxScale > 10 || maxScale < 0.1) {
+    const scaleFactor = 1 / maxScale
+    console.log('需要调整缩放,缩放因子:', scaleFactor)
+    
+    fbxModel.traverse((child) => {
+      if (child.isMesh) {
+        child.scale.multiplyScalar(scaleFactor)
+      }
+    })
+    
+    console.log('缩放调整完成')
+  }
+  
+  // 3. 强制更新所有矩阵
+  console.log('强制更新所有模型矩阵...')
+  fbxModel.traverse((child) => {
+    if (child.isObject3D) {
+      child.updateMatrix()
+      child.updateMatrixWorld(true)
+    }
+  })
+  
+  // 4. 计算模型边界框
+  console.log('计算FBX模型边界框...')
+  const box = new THREE.Box3().setFromObject(fbxModel)
+  const size = box.getSize(new THREE.Vector3())
+  const center = box.getCenter(new THREE.Vector3())
+  
+  console.log('FBX模型边界框大小:', size)
+  console.log('FBX模型边界框中心:', center)
+  
+  // 5. 处理材质
+  console.log('处理FBX模型材质...')
+  let materialCount = 0
+  
+  fbxModel.traverse((child) => {
+    if (child.isMesh) {
+      console.log('处理网格:', child.name)
+      
+      try {
+        if (!child.material || 
+            child.material.type === 'unknown' || 
+            child.material.type === 'MeshPhongMaterial') {
+          console.log('为', child.name, '添加默认材质')
+          const defaultMaterial = new THREE.MeshStandardMaterial({
+            color: 0x409eff,
+            metalness: 0.3,
+            roughness: 0.7,
+            transparent: false,
+            opacity: 1
+          })
+          child.material = defaultMaterial
+          materialCount++
+        }
+      } catch (error) {
+        console.error('处理材质时出错:', error.message)
+        const fallbackMaterial = new THREE.MeshStandardMaterial({
+          color: 0x409eff,
+          metalness: 0.3,
+          roughness: 0.7
+        })
+        child.material = fallbackMaterial
+        materialCount++
+      }
+    }
+  })
+  
+  console.log('材质处理完成,添加了', materialCount, '个默认材质')
+  
+  // 6. 最终矩阵更新
+  fbxModel.updateMatrixWorld(true)
+  console.log('FBX模型处理完成')
+  
+  return {
+    model: fbxModel,
+    box: box,
+    size: size,
+    center: center
+  }
+}
+
+// 加载FBX模型
+const loadFBXModel = (url) => {
+  console.log('开始加载FBX模型:', url)
+  
+  // 使用后端接口获取文件
+  // 直接传递包含/profile前缀的完整路径,后端会处理前缀
+  const resourcePath = url
+  const apiUrl = `/common/download/resource?resource=${encodeURIComponent(resourcePath)}`
+  console.log('使用后端接口获取文件:', apiUrl)
+  
+  request({
+    url: apiUrl,
+    method: 'get',
+    responseType: 'blob'
+  })
+    .then(response => {
+      const blob = response
+      console.log('FBX模型响应是Blob,大小:', blob.size)
+      console.log('Blob类型:', blob.type)
+      
+      // 使用Blob创建临时URL
+      const blobUrl = URL.createObjectURL(blob)
+      console.log('创建FBX Blob URL:', blobUrl)
+      
+      // 使用Blob URL加载模型
+      const loader = new FBXLoader()
+      loader.load(
+        blobUrl,
+        (fbx) => {
+          console.log('FBX模型加载成功:', fbx)
+          
+          // 专业处理FBX模型
+          const processed = processFBXModel(fbx)
+          model = processed.model
+          
+          // 添加到场景
+          scene.add(model)
+          console.log('FBX模型添加到场景')
+          
+          // 计算最终边界框
+          console.log('计算最终模型边界框...')
+          model.updateMatrixWorld(true)
+          const finalBox = new THREE.Box3().setFromObject(model)
+          const finalSize = finalBox.getSize(new THREE.Vector3())
+          const finalCenter = finalBox.getCenter(new THREE.Vector3())
+          
+          console.log('最终模型边界框大小:', finalSize)
+          console.log('最终模型边界框中心:', finalCenter)
+          console.log('模型实际位置:', model.position)
+          
+          // 调整相机位置
+          console.log('调整相机位置...')
+          adjustCameraForModel()
+          console.log('相机位置调整完成')
+          
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+          console.log('FBX模型加载和处理完成')
+        },
+        (xhr) => {
+          console.log('FBX模型加载进度:', (xhr.loaded / xhr.total * 100) + '% loaded')
+        },
+        (error) => {
+          console.error('FBX加载错误:', error)
+          console.error('错误详情:', error.message)
+          console.error('错误堆栈:', error.stack)
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+          createDefaultModel()
+        }
+      )
+    })
+    .catch(error => {
+      console.error('后端接口请求错误:', error)
+      console.error('错误详情:', error.message)
+      console.error('错误堆栈:', error.stack)
+      createDefaultModel()
+    })
+}
+
+// 加载STL模型
+const loadSTLModel = (url) => {
+  console.log('开始加载STL模型:', url)
+  
+  // 使用后端接口获取文件
+  // 直接传递包含/profile前缀的完整路径,后端会处理前缀
+  const resourcePath = url
+  const apiUrl = `/common/download/resource?resource=${encodeURIComponent(resourcePath)}`
+  console.log('使用后端接口获取文件:', apiUrl)
+  
+  request({
+    url: apiUrl,
+    method: 'get',
+    responseType: 'blob'
+  })
+    .then(response => {
+      const blob = response
+      console.log('STL模型响应是Blob,大小:', blob.size)
+      console.log('Blob类型:', blob.type)
+      
+      // 使用Blob创建临时URL
+      const blobUrl = URL.createObjectURL(blob)
+      console.log('创建STL Blob URL:', blobUrl)
+      
+      // 使用Blob URL加载模型
+      const loader = new STLLoader()
+      loader.load(
+        blobUrl,
+        (geometry) => {
+          console.log('STL模型加载成功,几何体顶点数:', geometry.attributes.position.count)
+          const material = new THREE.MeshStandardMaterial({ color: 0x409eff })
+          const mesh = new THREE.Mesh(geometry, material)
+          model = mesh
+          scene.add(mesh)
+          // 使用模型自身的原始坐标,不进行中心调整
+          console.log('模型添加到场景,使用原始坐标')
+          console.log('模型原始位置:', model.position)
+          console.log('模型原始旋转:', model.rotation)
+          console.log('模型原始缩放:', model.scale)
+          // 调整相机位置,使模型在视角中心且完整可见
+          adjustCameraForModel()
+          console.log('相机位置调整完成')
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+        },
+        (xhr) => {
+          console.log('STL模型加载进度:', (xhr.loaded / xhr.total * 100) + '% loaded')
+        },
+        (error) => {
+          console.error('STL加载错误:', error)
+          console.error('错误详情:', error.message)
+          console.error('错误堆栈:', error.stack)
+          // 释放Blob URL
+          URL.revokeObjectURL(blobUrl)
+          createDefaultModel()
+        }
+      )
+    })
+    .catch(error => {
+      console.error('后端接口请求错误:', error)
+      console.error('错误详情:', error.message)
+      console.error('错误堆栈:', error.stack)
+      createDefaultModel()
+    })
+}
+
+// 创建默认模型
+const createDefaultModel = () => {
+  // 创建一个简单的立方体作为默认模型
+  const geometry = new THREE.BoxGeometry(2, 2, 2)
+  const material = new THREE.MeshStandardMaterial({ 
+    color: 0x409eff,
+    wireframe: true
+  })
+  model = new THREE.Mesh(geometry, material)
+  scene.add(model)
+}
+
+// 调整相机位置,使模型在视角中心且完整可见
+const adjustCameraForModel = () => {
+  if (!model || !camera) return
+
+  console.log('=======================================')
+  console.log('开始调整相机位置')
+  console.log('当前模型位置:', model.position)
+  console.log('当前相机位置:', camera.position)
+
+  // 增强:强制更新所有模型和子对象的矩阵
+  console.log('强制更新模型和子对象矩阵...')
+  model.traverse((child) => {
+    if (child.isObject3D) {
+      child.updateMatrix()
+      child.updateMatrixWorld(true)
+    }
+  })
+  console.log('模型矩阵更新完成')
+
+  // 计算模型边界框
+  const box = new THREE.Box3().setFromObject(model)
+  const size = box.getSize(new THREE.Vector3())
+  const center = box.getCenter(new THREE.Vector3())
+
+  console.log('模型边界框大小:', size)
+  console.log('模型边界框中心:', center)
+  console.log('边界框最小值:', box.min)
+  console.log('边界框最大值:', box.max)
+
+  // 增强:检查边界框是否有效
+  if (box.isEmpty()) {
+    console.warn('模型边界框为空,使用默认相机位置')
+    // 使用默认相机位置
+    camera.position.set(5, 5, 5)
+    camera.lookAt(0, 0, 0)
+    camera.updateProjectionMatrix()
+    console.log('使用默认相机位置:', camera.position)
+    console.log('相机位置调整完成')
+    console.log('=======================================')
+    return
+  }
+
+  // 计算模型对角线长度
+  const modelDiagonal = Math.sqrt(
+    size.x * size.x + 
+    size.y * size.y + 
+    size.z * size.z
+  )
+  
+  console.log('模型对角线长度:', modelDiagonal)
+
+  // 根据模型大小计算合适的相机距离
+  const fov = camera.fov * (Math.PI / 180) // 转换为弧度
+  
+  // 增强:使用动态安全系数,根据模型大小调整
+  let safetyFactor = 1.3
+  if (modelDiagonal < 5) {
+    safetyFactor = 1.5 // 小模型使用更大的安全系数
+  } else if (modelDiagonal > 20) {
+    safetyFactor = 1.1 // 大模型使用更小的安全系数
+  }
+  
+  console.log('使用的安全系数:', safetyFactor)
+  const cameraDistance = (modelDiagonal / 2) / Math.tan(fov / 2) * safetyFactor
+  
+  console.log('计算的相机距离:', cameraDistance)
+  console.log('当前相机到模型中心距离:', camera.position.distanceTo(center))
+
+  // 增强:确保相机距离不会太小
+  const minCameraDistance = Math.max(2, modelDiagonal * 0.5)
+  const finalCameraDistance = Math.max(minCameraDistance, cameraDistance)
+  console.log('最终相机距离:', finalCameraDistance, '(最小距离:', minCameraDistance, ')')
+
+  // 增强:对于FBX模型,确保相机位置计算考虑模型的实际位置
+  console.log('模型实际位置:', model.position)
+  console.log('边界框中心相对于模型位置:', center.clone().sub(model.position))
+
+  // 设置相机位置,从斜上方看向模型中心
+  // 这样可以获得更好的视角
+  const cameraHeight = finalCameraDistance * 0.4 // 相机高度,40%的距离
+  
+  // 增强:考虑模型的实际位置
+  const cameraPosition = new THREE.Vector3(
+    center.x + finalCameraDistance * 0.2,  // 稍微偏右
+    center.y + cameraHeight,               // 上方
+    center.z + finalCameraDistance * 0.9    // 前方
+  )
+  
+  console.log('计算的相机位置:', cameraPosition)
+  camera.position.copy(cameraPosition)
+  
+  // 相机看向模型中心
+  camera.lookAt(center)
+  
+  console.log('调整后相机位置:', camera.position)
+  console.log('相机看向:', center)
+  console.log('调整后相机到模型中心距离:', camera.position.distanceTo(center))
+
+  // 确保相机矩阵更新
+  camera.updateProjectionMatrix()
+  console.log('相机投影矩阵已更新')
+
+  // 增强:重置相机的旋转和缩放
+  console.log('重置相机旋转和缩放...')
+  camera.rotation.set(0, 0, 0)
+  camera.scale.set(1, 1, 1)
+  console.log('相机旋转和缩放已重置')
+
+  console.log('相机位置调整完成,现在应该能完整看到模型')
+  console.log('=======================================')
+}
+
+// 自动调整模型位置和大小(保留但不再使用)
+const centerModel = () => {
+  if (!model || !camera) return
+
+  console.log('=======================================')
+  console.log('开始调整模型位置和大小')
+  console.log('调整前模型位置:', model.position)
+  console.log('调整前模型缩放:', model.scale)
+  console.log('调整前模型旋转:', model.rotation)
+  console.log('调整前模型世界矩阵:', model.matrixWorld.elements)
+
+  // 重置模型旋转,确保计算边界框时不受旋转影响
+  model.rotation.set(0, 0, 0)
+  console.log('重置模型旋转后:', model.rotation)
+
+  // 确保模型矩阵更新
+  model.updateMatrixWorld(true)
+  console.log('模型矩阵已更新')
+  console.log('重置旋转后模型世界矩阵:', model.matrixWorld.elements)
+
+  // 计算模型边界框
+  const box = new THREE.Box3().setFromObject(model)
+  const size = box.getSize(new THREE.Vector3())
+  const center = box.getCenter(new THREE.Vector3())
+
+  console.log('模型边界框大小:', size)
+  console.log('模型边界框中心:', center)
+  console.log('边界框最小值:', box.min)
+  console.log('边界框最大值:', box.max)
+
+  // 直接设置模型位置到原点,确保在世界中心
+  console.log('准备调整模型位置到原点...')
+  console.log('计算的位置调整值:', -center.x, -center.y, -center.z)
+  model.position.set(-center.x, -center.y, -center.z)
+  console.log('模型调整后位置:', model.position)
+  console.log('模型位置是否接近原点:', 
+    Math.abs(model.position.x) < 0.001 && 
+    Math.abs(model.position.y) < 0.001 && 
+    Math.abs(model.position.z) < 0.001
+  )
+
+  // 确保模型矩阵更新
+  model.updateMatrixWorld(true)
+  console.log('位置调整后模型矩阵已更新')
+  console.log('位置调整后模型世界矩阵:', model.matrixWorld.elements)
+  
+  // 重新计算调整后的边界框,验证位置是否正确
+  const positionAdjustedBox = new THREE.Box3().setFromObject(model)
+  const positionAdjustedCenter = positionAdjustedBox.getCenter(new THREE.Vector3())
+  console.log('位置调整后模型边界框中心:', positionAdjustedCenter)
+  console.log('边界框中心是否接近原点:', 
+    Math.abs(positionAdjustedCenter.x) < 0.001 && 
+    Math.abs(positionAdjustedCenter.y) < 0.001 && 
+    Math.abs(positionAdjustedCenter.z) < 0.001
+  )
+
+  // 自动调整模型大小 - 使用更大的基准值,确保模型不会太小
+  const maxSize = Math.max(size.x, size.y, size.z)
+  if (maxSize > 0) {
+    // 使用 5 作为基准值,而不是 3,让模型更大一些
+    const scale = 5 / maxSize
+    console.log('准备调整模型缩放...')
+    console.log('计算的缩放比例:', scale)
+    model.scale.set(scale, scale, scale)
+    console.log('模型调整后缩放:', model.scale)
+    console.log('调整后模型最大尺寸:', maxSize * scale)
+  }
+
+  // 确保模型矩阵更新
+  model.updateMatrixWorld(true)
+  console.log('模型缩放后矩阵已更新')
+  console.log('缩放后模型世界矩阵:', model.matrixWorld.elements)
+  
+  // 重新计算调整后的边界框
+  const adjustedBox = new THREE.Box3().setFromObject(model)
+  const adjustedSize = adjustedBox.getSize(new THREE.Vector3())
+  const adjustedCenter = adjustedBox.getCenter(new THREE.Vector3())
+  console.log('调整后模型边界框大小:', adjustedSize)
+  console.log('调整后模型边界框中心:', adjustedCenter)
+  console.log('最终边界框中心是否接近原点:', 
+    Math.abs(adjustedCenter.x) < 0.001 && 
+    Math.abs(adjustedCenter.y) < 0.001 && 
+    Math.abs(adjustedCenter.z) < 0.001
+  )
+
+  // 动态调整相机位置,确保相机能够完整看到模型
+  const modelDiagonal = Math.sqrt(
+    adjustedSize.x * adjustedSize.x + 
+    adjustedSize.y * adjustedSize.y + 
+    adjustedSize.z * adjustedSize.z
+  )
+  
+  // 根据模型对角线长度计算合适的相机距离
+  const fov = camera.fov * (Math.PI / 180) // 转换为弧度
+  const cameraDistance = (modelDiagonal / 2) / Math.tan(fov / 2) * 1.5 // 1.5 是安全系数
+  
+  console.log('模型对角线长度:', modelDiagonal)
+  console.log('计算的相机距离:', cameraDistance)
+
+  // 设置相机位置,确保从适当的距离和角度观察模型
+  camera.position.set(0, cameraDistance * 0.3, cameraDistance)
+  camera.lookAt(0, 0, 0)
+  
+  console.log('调整后相机位置:', camera.position)
+  console.log('相机看向:', new THREE.Vector3(0, 0, 0))
+  console.log('相机到原点距离:', camera.position.distanceTo(new THREE.Vector3(0, 0, 0)))
+
+  // 确保相机矩阵更新
+  camera.updateProjectionMatrix()
+  console.log('相机投影矩阵已更新')
+
+  console.log('模型位置调整完成,现在应该在世界坐标系原点')
+  console.log('=======================================')
+}
+
+// 渲染动画
+const animate = () => {
+  animationId = requestAnimationFrame(animate)
+  if (renderer && scene && camera) {
+    renderer.render(scene, camera)
+  }
+}
+
+// 处理窗口大小变化
+const handleResize = () => {
+  if (!camera || !renderer || !modelContainer.value) return
+
+  const width = modelContainer.value.clientWidth
+  const height = modelContainer.value.clientHeight
+
+  camera.aspect = width / height
+  camera.updateProjectionMatrix()
+  renderer.setSize(width, height)
+}
+
+// 处理关闭
+const handleClose = () => {
+  dialogVisible.value = false
+  emit('update:visible', false)
+  emit('close')
+}
+
+// 监听可见性变化
+watch(() => props.visible, (newVal) => {
+  dialogVisible.value = newVal
+  if (newVal) {
+    // 延迟初始化,确保DOM已更新
+    setTimeout(() => {
+      initScene()
+    }, 100)
+  }
+})
+
+// 组件挂载时初始化
+onMounted(() => {
+  dialogVisible.value = props.visible
+  if (props.visible) {
+    initScene()
+  }
+})
+
+// 组件卸载时清理
+onUnmounted(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId)
+  }
+  if (renderer) {
+    renderer.dispose()
+  }
+  if (modelContainer.value) {
+    window.removeEventListener('resize', handleResize)
+  }
+})
+
+// 计算属性
+const modelName = ref('')
+watch(() => props.modelData, (newVal) => {
+  if (newVal) {
+    modelName.value = newVal.name || '模型预览'
+  }
+}, { immediate: true })
+</script>
+
+<style scoped>
+.model-preview-container {
+  display: flex;
+  gap: 20px;
+  height: 600px;
+}
+
+.model-container {
+  flex: 1;
+  background-color: #fafafa;
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.model-info {
+  width: 300px;
+  background-color: #ffffff;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+  overflow-y: auto;
+}
+
+.model-info h3 {
+  margin-top: 0;
+  margin-bottom: 20px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+@media (max-width: 768px) {
+  .model-preview-container {
+    flex-direction: column;
+  }
+
+  .model-info {
+    width: 100%;
+    max-height: 300px;
+  }
+}
+</style>

+ 36 - 2
RuoYi-Vue3/src/views/front/content/ShuiliGongcheng.vue

@@ -220,6 +220,13 @@
         <el-button type="primary" @click="submitForm">确定</el-button>
       </template>
     </el-dialog>
+
+    <!-- 模型预览弹窗 -->
+    <ModelPreview
+      v-model:visible="previewDialogVisible"
+      :model-data="selectedModel"
+      @close="handlePreviewClose"
+    />
   </div>
 </template>
 
@@ -231,9 +238,13 @@ import { ElMessage, ElMessageBox, ElInputNumber } from 'element-plus'
 import request from '@/utils/request'
 // 导入用户store,用于获取当前登录用户信息
 import useUserStore from '@/store/modules/user'
+// 导入模型预览组件
+import ModelPreview from './ModelPreview.vue'
 
 const modelData = ref([])
 const uploadDialogVisible = ref(false)
+const previewDialogVisible = ref(false)
+const selectedModel = ref({})
 const uploadKey = ref(0)
 const currentPage = ref(1)
 const pageSize = ref(10)
@@ -325,6 +336,12 @@ const fetchModels = async () => {
     const models = data.rows || []
     total.value = data.total || 0
     
+    // 打印完整的模型数据,查看是否包含文件路径或文件名
+    console.log('完整模型数据列表:', models)
+    if (models.length > 0) {
+      console.log('第一个模型的完整数据:', models[0])
+    }
+    
     // 确保models是数组才调用map方法
     if (Array.isArray(models)) {
       modelData.value = models.map(model => ({
@@ -335,7 +352,9 @@ const fetchModels = async () => {
           uploadUnit: model.uploadUnit || '未知单位',
           format: model.format || '未知格式',
           status: model.status === 'NORMAL' ? '正常' : '维护中',
-          createTime: model.created_at ? model.created_at.split(' ')[0].replace(/-/g, '/') : ''
+          createTime: model.created_at ? model.created_at.split(' ')[0].replace(/-/g, '/') : '',
+          // 保存完整的模型数据,以便查看是否包含文件路径或文件名
+          originalData: model
         }))
       console.log('处理后的模型数据:', modelData.value)
       console.log('总数据量:', total.value)
@@ -440,7 +459,22 @@ const submitForm = async () => {
 
 // 查看模型
 const viewModel = (row) => {
-  ElMessage.info(`查看模型: ${row.name}`)
+  console.log('查看模型,原始行数据:', row)
+  // 传递完整的原始模型数据,包括可能的文件路径或文件名
+  if (row.originalData) {
+    selectedModel.value = row.originalData
+    console.log('使用原始模型数据:', selectedModel.value)
+  } else {
+    selectedModel.value = row
+    console.log('使用处理后的模型数据:', selectedModel.value)
+  }
+  previewDialogVisible.value = true
+}
+
+// 处理预览弹窗关闭
+const handlePreviewClose = () => {
+  previewDialogVisible.value = false
+  selectedModel.value = {}
 }
 
 // 删除模型