dusenyao vor 2 Tagen
Ursprung
Commit
ae5d5a159d

+ 11 - 0
package-lock.json

@@ -22,6 +22,7 @@
         "md5": "^2.3.0",
         "nprogress": "^0.2.0",
         "simple-mind-map": "^0.14.0-fix.1",
+        "three": "^0.178.0",
         "tinymce": "^5.10.9",
         "vue": "^2.6.14",
         "vue-esign": "^1.1.4",
@@ -19335,6 +19336,11 @@
         "url": "https://opencollective.com/webpack"
       }
     },
+    "node_modules/three": {
+      "version": "0.178.0",
+      "resolved": "https://registry.npmmirror.com/three/-/three-0.178.0.tgz",
+      "integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ=="
+    },
     "node_modules/throttle-debounce": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-1.1.0.tgz",
@@ -35346,6 +35352,11 @@
         }
       }
     },
+    "three": {
+      "version": "0.178.0",
+      "resolved": "https://registry.npmmirror.com/three/-/three-0.178.0.tgz",
+      "integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ=="
+    },
     "throttle-debounce": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-1.1.0.tgz",

+ 1 - 0
package.json

@@ -33,6 +33,7 @@
     "md5": "^2.3.0",
     "nprogress": "^0.2.0",
     "simple-mind-map": "^0.14.0-fix.1",
+    "three": "^0.178.0",
     "tinymce": "^5.10.9",
     "vue": "^2.6.14",
     "vue-esign": "^1.1.4",

+ 3 - 1
src/api/app.js

@@ -19,6 +19,8 @@ export function GetFileURLMap(data) {
 
 /**
  * 得到文件存储信息
+ * @param {object} data 请求数据
+ * @param {string} data.file_id 文件 ID
  */
 export function GetFileStoreInfo(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=file_store_manager-GetFileStoreInfo`, data);
@@ -88,4 +90,4 @@ export function GetBookWebSIContent(MethodName, data) {
  */
 export function H5StartupFile(data) {
   return http.post(`/FileServer/SI?MethodName=file_store_manager-UnzipH5GamePackCreateStartupFile`, data);
-}
+}

+ 1 - 0
src/icons/svg/3d.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1752147045385" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6314" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16"><path d="M864.757183 264.990257l-319.895751-191.573154c-10.116405-6.057973-21.48943-9.086959-32.861432-9.086959a63.915705 63.915705 0 0 0-32.861432 9.086959l-319.895751 191.573154a63.961754 63.961754 0 0 0-31.099298 54.873771v390.954133a63.960731 63.960731 0 0 0 31.980365 55.391565l319.895751 184.692442c9.894348 5.713119 20.937868 8.569166 31.980365 8.569166s22.086018-2.856048 31.980365-8.569166l319.895751-184.692442a63.959707 63.959707 0 0 0 31.980365-55.391565v-390.954133a63.959707 63.959707 0 0 0-31.099298-54.873771zM512 128.289851l288.778032 172.937725-289.062511 173.228343-288.603047-173.161828 288.887526-173.00424z m-319.895751 228.989322l287.621697 172.573427v347.022575L192.104249 710.817138V357.279173zM543.686676 877.214912V529.862833l288.209075-172.71669v353.670995L543.686676 877.214912z" p-id="6315"></path></svg>

+ 1 - 0
src/icons/svg/exit-fullscreen.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="16px" height="16.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#707070" d="M298.666667 298.666667V85.333333h85.333333v298.666667H85.333333V298.666667h213.333334z m426.666666-213.333334v213.333334h213.333334v85.333333h-298.666667V85.333333h85.333333zM298.666667 938.666667v-213.333334H85.333333v-85.333333h298.666667v298.666667H298.666667z m426.666666 0h-85.333333v-298.666667h298.666667v85.333333h-213.333334v213.333334z"  /></svg>

+ 1 - 0
src/icons/svg/fullscreen.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="16px" height="16.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#707070" d="M368.896 192H224a32 32 0 0 0-32 32v137.888a32 32 0 0 0 64 0V256h112.896a32 32 0 0 0 0-64zM784.864 192H640a32 32 0 1 0 0 64h112.864v105.888a32 32 0 1 0 64 0V224a32 32 0 0 0-32-32zM368.896 777.92H256V672a32 32 0 1 0-64 0v137.92a32 32 0 0 0 32 32h144.896a32 32 0 1 0 0-64zM784.864 640a32 32 0 0 0-32 32v105.92H640a32 32 0 1 0 0 64h144.864a32 32 0 0 0 32-32V672a32 32 0 0 0-32-32z"  /><path fill="#707070" d="M912 48h-800c-35.296 0-64 28.704-64 64v800c0 35.296 28.704 64 64 64h800c35.296 0 64-28.704 64-64v-800c0-35.296-28.704-64-64-64z m-800 864v-800h800l0.064 800H112z"  /></svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 0
src/icons/svg/loading.svg


+ 1 - 0
src/icons/svg/three-model.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1752147497625" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6476" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16"><path d="M177.493333 389.36381a48.761905 48.761905 0 0 1 24.380953 6.534095l249.222095 143.896381a73.142857 73.142857 0 0 1 36.571429 63.341714v287.792762a48.761905 48.761905 0 0 1-45.104762 48.615619l-3.657143 0.146286a48.761905 48.761905 0 0 1-24.380953-6.534096L165.302857 789.26019a73.142857 73.142857 0 0 1-36.571428-63.341714V438.125714a48.761905 48.761905 0 0 1 45.104761-48.64l3.657143-0.121904z m669.135238 0a48.761905 48.761905 0 0 1 48.615619 45.104761l0.146286 3.657143v287.792762a73.142857 73.142857 0 0 1-32.426666 60.757334l-4.144762 2.58438-249.246477 143.896381a48.761905 48.761905 0 0 1-20.114285 6.339048l-4.266667 0.195048a48.761905 48.761905 0 0 1-48.615619-45.104762l-0.146286-3.657143V603.136a73.142857 73.142857 0 0 1 32.426667-60.757333l4.144762-2.584381 249.246476-143.896381a48.761905 48.761905 0 0 1 20.114286-6.339048l4.266666-0.195047zM201.849905 480.353524l0.024381 245.564952 212.650666 122.758095V603.136L201.874286 480.353524z m620.373333 0l-210.553905 121.539047-2.096762 1.243429V848.700952l210.578286-121.514666 2.096762-1.243429-0.024381-245.564952zM544.085333 122.441143l4.608 2.438095 249.222096 143.896381a48.761905 48.761905 0 0 1 3.242666 82.407619l-3.242666 2.048-249.222096 143.896381a73.142857 73.142857 0 0 1-68.559238 2.438095l-4.583619-2.438095-249.246476-143.896381a48.761905 48.761905 0 0 1-3.218286-82.407619l3.218286-2.048 249.246476-143.896381a73.142857 73.142857 0 0 1 68.534857-2.438095zM512.048762 188.220952l-212.601905 122.758096 211.748572 122.270476 0.950857 0.487619 212.626285-122.758095-211.309714-122.002286-1.414095-0.75581z" p-id="6477"></path></svg>

+ 15 - 0
src/styles/mixin.scss

@@ -197,3 +197,18 @@
     box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
   }
 }
+
+// 旋转动画
+@mixin spin($second: 3s) {
+  animation: spin $second linear infinite;
+
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+}

+ 39 - 0
src/utils/common.js

@@ -94,3 +94,42 @@ export function handleToneValue(valItem) {
 export function isTrue(value) {
   return value === 'true';
 }
+
+// 全屏方法兼容
+export function fullScreenCompatibility(dom) {
+  if (dom.requestFullscreen) {
+    return dom.requestFullscreen();
+  } else if (dom.webkitRequestFullScreen) {
+    return dom.webkitRequestFullScreen();
+  } else if (dom.mozRequestFullScreen) {
+    return dom.mozRequestFullScreen();
+  }
+  return dom.msRequestFullscreen();
+}
+
+/**
+ * @description 退出全屏方法兼容
+ */
+export function exitFullScreenCompatibility() {
+  if (document.exitFullscreen) {
+    return document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    return document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    return document.mozCancelFullScreen();
+  }
+  return document.msExitFullscreen();
+}
+
+/**
+ * @description 切换全屏
+ * @param {HTMLElement} dom 需要全屏的元素
+ * @return {Promise<void>}
+ */
+export function toggleFullScreen(dom) {
+  if (document.fullscreenElement === null) {
+    fullScreenCompatibility(dom);
+  } else {
+    exitFullScreenCompatibility();
+  }
+}

+ 6 - 6
src/views/book/courseware/create/components/CreateCanvas.vue

@@ -495,7 +495,7 @@ export default {
           return;
         }
         grid.height = `${gridHeight + offsetY}px`;
-        col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
+        col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 6px ')} 0`;
         return;
       }
       // 高度为数字时
@@ -506,7 +506,7 @@ export default {
       }
       if (_h >= 50) {
         grid.height = `${_h}px`;
-        col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 16px ')} 0`;
+        col.grid_template_rows = `0 ${col.grid_list.map(({ height }) => height).join(' 6px ')} 0`;
       }
     },
 
@@ -757,7 +757,7 @@ export default {
       });
       grid.forEach((item) => {
         if (item.row === max.row) {
-          gridTemCols += `${item.width} 8px 8px `;
+          gridTemCols += `${item.width} 4px 4px `;
         }
       });
 
@@ -919,7 +919,7 @@ export default {
         .map((item) => {
           return `${item}`;
         })
-        .join(' 16px ');
+        .join(' 6px ');
       let gridTemplateColumns = `0 ${str} 0`;
 
       return {
@@ -1115,7 +1115,7 @@ export default {
      * @returns {Object} 样式对象
      */
     computedGridStyle(grid) {
-      let marginTop = grid.row === 1 ? '0' : '16px';
+      let marginTop = grid.row === 1 ? '0' : '6px';
 
       return {
         gridArea: grid.grid_area,
@@ -1132,7 +1132,7 @@ export default {
   position: relative;
   display: flex;
   flex-direction: column;
-  row-gap: 6px;
+  row-gap: 8px;
   width: $courseware-width;
   min-height: calc(100% - 6px);
   padding: 24px;

+ 39 - 4
src/views/book/courseware/create/components/base/3d_model/3DModel.vue

@@ -1,14 +1,49 @@
 <template>
-  <div></div>
+  <ModuleBase :type="data.type">
+    <template #content>
+      <UploadFile
+        key="upload_games"
+        :courseware-id="courseware_id"
+        :component-id="id"
+        :type="data.type"
+        :total-size="500"
+        :file-list="data.model_list"
+        :file-id-list="data.model_id_list"
+        :label-text="labelText"
+        :accept-file-type="acceptFileType"
+        :icon-class="iconClass"
+        :limit="1"
+        :single-size="200"
+        @updateFileList="updateFileList"
+      />
+    </template>
+  </ModuleBase>
 </template>
 
 <script>
+import ModuleMixin from '../../common/ModuleMixin';
+import UploadFile from '../common/UploadFile.vue';
+import { get3DModelData } from '@/views/book/courseware/data/3dModel';
+
 export default {
-  name: '3DModelPage',
+  name: 'ThreeModelPage',
+  components: { UploadFile },
+  mixins: [ModuleMixin],
   data() {
-    return {};
+    return {
+      data: get3DModelData(),
+      labelText: '3D模型',
+      acceptFileType: '.fbx,.obj,.gltf,.glb',
+      iconClass: '3d',
+    };
+  },
+  methods: {
+    updateFileList({ file_list, file_id_list }) {
+      this.data.model_list = file_list;
+      this.data.model_id_list = file_id_list;
+      this.data.file_id_list = file_id_list;
+    },
   },
-  methods: {},
 };
 </script>
 

+ 32 - 0
src/views/book/courseware/create/components/base/3d_model/3DModelSetting.vue

@@ -0,0 +1,32 @@
+<template>
+  <div>
+    <el-form :model="property" label-width="72px" label-position="left">
+      <SerailNumber :property="property" />
+    </el-form>
+  </div>
+</template>
+
+<script>
+import SettingMixin from '@/views/book/courseware/create/components/common/SettingMixin';
+
+import { get3DModelProperty } from '@/views/book/courseware/data/3dModel';
+
+export default {
+  name: 'ThreeModelSetting',
+  mixins: [SettingMixin],
+  data() {
+    return {
+      property: get3DModelProperty(),
+    };
+  },
+  methods: {},
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.el-form {
+  @include setting-base;
+}
+</style>

+ 3 - 0
src/views/book/courseware/create/components/base/common/UploadFile.vue

@@ -276,6 +276,9 @@ export default {
       } else if (this.type === 'h5_games') {
         fileType = ['zip'];
         typeTip = 'H5游戏文件只能是 zip 格式!';
+      } else if (this.type === '3DModel') {
+        fileType = ['fbx', 'obj', 'gltf', 'glb'];
+        typeTip = '3D模型文件只能是 fbx、obj、gltf、glb 格式!';
       }
       const isNeedType = fileType.includes(suffix);
       if (!isNeedType) {

+ 4 - 1
src/views/book/courseware/data/3dModel.js

@@ -10,10 +10,13 @@ export function get3DModelData() {
   return {
     type: '3DModel',
     title: '3D模型',
+    min_height: 120,
     property: get3DModelProperty(),
-    model_url: '',
+    model_list: [],
+    model_id_list: [],
     mind_map: {
       node_list: [{ name: '3D模型' }],
     },
+    file_id_list: [],
   };
 }

+ 11 - 0
src/views/book/courseware/data/bookType.js

@@ -63,6 +63,8 @@ import Drawing from '../create/components/question/drawing/Drawing.vue';
 import DrawingSetting from '../create/components/question/drawing/DrawingSetting.vue';
 import VideoInteraction from '../create/components/question/video_interaction/VideoInteraction.vue';
 import VideoInteractionSetting from '../create/components/question/video_interaction/VideoInteractionSetting.vue';
+import ThreeModel from '../create/components/base/3d_model/3DModel.vue';
+import ThreeModelSetting from '../create/components/base/3d_model/3DModelSetting.vue';
 
 // 预览组件页面列表
 import AudioPreview from '@/views/book/courseware/preview/components/audio/AudioPreview.vue';
@@ -98,6 +100,7 @@ import ImageTextPreview from '../preview/components/image_text/ImageTextPreview.
 import H5GamesPreview from '../preview/components/h5_games/H5GamesPreview.vue';
 import DrawingPreview from '../preview/components/drawing/DrawingPreview.vue';
 import VideoInteractionPreview from '../preview/components/video_interaction/VideoInteractionPreview.vue';
+import ThreeModelPreview from '../preview/components/3d_model/3DModelPreview.vue';
 
 export const bookTypeOption = [
   {
@@ -209,6 +212,14 @@ export const bookTypeOption = [
         set: H5GamesSetting,
         preview: H5GamesPreview,
       },
+      {
+        value: '3DModel',
+        label: '3D模型',
+        icon: 'three-model',
+        component: ThreeModel,
+        set: ThreeModelSetting,
+        preview: ThreeModelPreview,
+      },
     ],
   },
   {

+ 352 - 0
src/views/book/courseware/preview/components/3d_model/3DModelPreview.vue

@@ -0,0 +1,352 @@
+<template>
+  <div class="three-preview" :style="getAreaStyle()">
+    <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
+
+    <div ref="three" class="three">
+      <SvgIcon
+        :class-name="iconName()"
+        :icon-class="icon()"
+        :size="isFullScreen ? '32px' : '16px'"
+        @click="handleToggleFullScreen"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import PreviewMixin from '../common/PreviewMixin';
+
+import * as THREE from 'three';
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
+import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js';
+import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+
+import { get3DModelData } from '@/views/book/courseware/data/3dModel';
+import { toggleFullScreen } from '@/utils/common';
+import { GetFileStoreInfo } from '@/api/app';
+
+export default {
+  name: 'ThreeModelPreview',
+  mixins: [PreviewMixin],
+  data() {
+    return {
+      data: get3DModelData(),
+      scene: null,
+      camera: null,
+      renderer: null,
+      controls: null,
+      animationId: null,
+      isFullScreen: false, // 是否全屏
+      loaded: false, // 是否加载完成
+    };
+  },
+  watch: {
+    'data.model_list': {
+      handler(val) {
+        if (val && val.length > 0) {
+          this.loadModel();
+        }
+      },
+      immediate: true,
+      deep: true,
+    },
+  },
+  mounted() {
+    this.initThree();
+    window.addEventListener('fullscreenchange', this.onWindowResize);
+  },
+  beforeDestroy() {
+    this.cleanup();
+    window.removeEventListener('fullscreenchange', this.onWindowResize);
+  },
+  methods: {
+    iconName() {
+      return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen-btn') : 'loading';
+    },
+    icon() {
+      return this.loaded ? (this.isFullScreen ? 'exit-fullscreen' : 'fullscreen') : 'loading';
+    },
+    initThree() {
+      const container = this.$refs.three;
+      if (!container) return;
+
+      // 创建场景
+      this.scene = new THREE.Scene();
+      this.scene.background = new THREE.Color(0xf0f0f0);
+
+      // 创建相机
+      const width = container.clientWidth;
+      const height = container.clientHeight;
+      this.camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
+      this.camera.position.set(5, 5, 5);
+
+      // 创建渲染器
+      this.renderer = new THREE.WebGLRenderer({ antialias: true });
+      this.renderer.setSize(width, height);
+      this.renderer.shadowMap.enabled = true;
+      this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+      container.appendChild(this.renderer.domElement);
+
+      // 添加光源
+      const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
+      this.scene.add(ambientLight);
+
+      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+      directionalLight.position.set(10, 10, 5);
+      directionalLight.castShadow = true;
+      this.scene.add(directionalLight);
+
+      // 添加控制器
+      this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+      this.controls.enableDamping = true;
+      this.controls.dampingFactor = 0.25;
+
+      // 开始渲染循环
+      this.animate();
+
+      // 监听窗口大小变化
+      window.addEventListener('resize', this.onWindowResize);
+    },
+
+    async loadModel() {
+      if (!this.data.model_list || this.data.model_list.length === 0) {
+        return;
+      }
+
+      const fileStore = await GetFileStoreInfo({ file_id: this.data.model_list[0].file_id });
+      if (!fileStore || fileStore.error) {
+        console.error('模型文件加载失败:', fileStore);
+        return;
+      }
+      const modelUrl = fileStore.file_url;
+      // 根据文件扩展名选择合适的加载器
+      const extension = modelUrl.split('.').pop().toLowerCase();
+      let loader = null;
+
+      switch (extension) {
+        case 'gltf':
+        case 'glb':
+          loader = new GLTFLoader();
+          this.loadGLTF(loader, modelUrl);
+          break;
+        case 'fbx':
+          loader = new FBXLoader();
+          this.loadFBX(loader, modelUrl);
+          break;
+        case 'obj':
+          loader = new OBJLoader();
+          this.loadOBJ(loader, modelUrl);
+          break;
+        default:
+          console.error('不支持的模型格式:', extension);
+      }
+    },
+
+    loadGLTF(loader, url) {
+      loader.load(
+        url,
+        (gltf) => {
+          const model = gltf.scene;
+          this.addModelToScene(model);
+
+          // 如果模型有动画,播放第一个动画
+          if (gltf.animations && gltf.animations.length > 0) {
+            this.mixer = new THREE.AnimationMixer(model);
+            const action = this.mixer.clipAction(gltf.animations[0]);
+            action.play();
+          }
+        },
+        (progress) => {
+          console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
+          if (progress.loaded >= progress.total) {
+            this.loaded = true;
+          }
+        },
+        (error) => {
+          console.error('GLTF模型加载失败:', error);
+        },
+      );
+    },
+
+    loadFBX(loader, url) {
+      loader.load(
+        url,
+        (fbx) => {
+          this.addModelToScene(fbx);
+        },
+        (progress) => {
+          console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
+          if (progress.loaded >= progress.total) {
+            this.loaded = true;
+          }
+        },
+        (error) => {
+          console.error('FBX模型加载失败:', error);
+        },
+      );
+    },
+
+    loadOBJ(loader, url) {
+      loader.load(
+        url,
+        (obj) => {
+          this.addModelToScene(obj);
+        },
+        (progress) => {
+          console.log('加载进度:', `${(progress.loaded / progress.total) * 100}%`);
+          if (progress.loaded >= progress.total) {
+            this.loaded = true;
+          }
+        },
+        (error) => {
+          console.error('OBJ模型加载失败:', error);
+        },
+      );
+    },
+
+    addModelToScene(model) {
+      // 计算模型的包围盒,用于自动调整相机位置
+      const box = new THREE.Box3().setFromObject(model);
+      const center = box.getCenter(new THREE.Vector3());
+      const size = box.getSize(new THREE.Vector3());
+
+      // 将模型移到原点
+      model.position.sub(center);
+      this.scene.add(model);
+
+      // 自动调整相机位置
+      const maxDim = Math.max(size.x, size.y, size.z);
+      const fov = this.camera.fov * (Math.PI / 180);
+      let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
+      cameraZ *= 1.5; // 稍微远一点以便观察
+
+      this.camera.position.set(cameraZ, cameraZ, cameraZ);
+      this.camera.lookAt(0, 0, 0);
+      this.controls.target.set(0, 0, 0);
+    },
+
+    animate() {
+      this.animationId = requestAnimationFrame(this.animate);
+
+      // 更新控制器
+      if (this.controls) {
+        this.controls.update();
+      }
+
+      // 更新动画混合器
+      if (this.mixer) {
+        this.mixer.update(0.016); // 假设60fps
+      }
+
+      // 渲染场景
+      if (this.renderer && this.scene && this.camera) {
+        this.renderer.render(this.scene, this.camera);
+      }
+    },
+
+    onWindowResize() {
+      const container = this.$refs.three;
+      this.isFullScreen = document.fullscreenElement !== null;
+      if (!container || !this.camera || !this.renderer) return;
+
+      let height = 0;
+      let width = 0;
+
+      // 检查是否在全屏状态
+      if (document.fullscreenElement) {
+        // 全屏状态下使用整个屏幕尺寸
+        width = window.innerWidth;
+        height = window.innerHeight;
+      } else {
+        // 非全屏状态下使用容器尺寸
+        width = container.clientWidth;
+        height = container.clientHeight;
+      }
+
+      // 更新相机宽高比
+      this.camera.aspect = width / height;
+      this.camera.updateProjectionMatrix();
+
+      // 更新渲染器尺寸
+      this.renderer.setSize(width, height);
+    },
+
+    cleanup() {
+      // 停止动画循环
+      if (this.animationId) {
+        cancelAnimationFrame(this.animationId);
+      }
+
+      // 移除事件监听器
+      window.removeEventListener('resize', this.onWindowResize);
+
+      // 清理Three.js资源
+      if (this.renderer) {
+        this.renderer.dispose();
+        const container = this.$refs.three;
+        if (container && container.contains(this.renderer.domElement)) {
+          container.removeChild(this.renderer.domElement);
+        }
+      }
+
+      if (this.scene) {
+        this.scene.clear();
+      }
+    },
+
+    handleToggleFullScreen() {
+      if (!this.$refs.three) return;
+      if (!this.loaded) {
+        this.$message.warning('请等待模型加载完成后,再切换全屏');
+        return;
+      }
+      toggleFullScreen(this.$refs.three);
+      // 延迟一帧来确保DOM已经更新
+      this.$nextTick(() => {
+        setTimeout(() => {
+          this.onWindowResize();
+        }, 100);
+      });
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.three-preview {
+  @include preview-base;
+
+  .three {
+    position: relative;
+    overflow: hidden;
+
+    .loading {
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      color: #999;
+
+      @include spin;
+    }
+
+    .fullscreen-btn {
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      z-index: 999;
+      cursor: pointer;
+    }
+
+    .exit-fullscreen {
+      position: fixed;
+      top: 24px;
+      right: 24px;
+      z-index: 999;
+      cursor: pointer;
+    }
+  }
+}
+</style>

+ 0 - 0
src/views/book/courseware/preview/components/3d_model/3DModelPreview_fixed.vue


+ 1 - 1
src/views/book/courseware/preview/components/h5_games/H5GamesPreview.vue

@@ -6,8 +6,8 @@
       <el-button
         type="primary"
         :class="[full_type ? 'exit-btn' : '']"
-        @click="toggleFullScreen"
         style="margin-bottom: 10px"
+        @click="toggleFullScreen"
         >{{ full_type ? '退出全屏' : '进入全屏' }}</el-button
       >
       <iframe :src="games_url" width="100%" :height="full_type ? '100%' : '580px'" style="border: none"></iframe>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.