增加 3d 展示区块
This commit is contained in:
@@ -12,6 +12,7 @@ import LayoutTitleMedia from './layouts/LayoutTitleMedia.vue';
|
||||
import LayoutMosaic from './layouts/LayoutMosaic.vue';
|
||||
import LayoutMarquee from './layouts/LayoutMarquee.vue';
|
||||
import LayoutSpecTable from './layouts/LayoutSpecTable.vue';
|
||||
import LayoutModel3d from './layouts/LayoutModel3d.vue';
|
||||
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
@@ -26,7 +27,8 @@ const layoutComponents: Record<SectionLayout, unknown> = {
|
||||
title_media: LayoutTitleMedia,
|
||||
mosaic: LayoutMosaic,
|
||||
marquee: LayoutMarquee,
|
||||
spec_table: LayoutSpecTable
|
||||
spec_table: LayoutSpecTable,
|
||||
model3d: LayoutModel3d
|
||||
};
|
||||
|
||||
const layoutComponent = computed(() => layoutComponents[props.section.layout] ?? null);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import type { Material, Mesh } from 'three';
|
||||
import type { SectionBlock } from '~/types/site';
|
||||
import { resolveAssetUrl } from '~/utils/resolveAssetUrl';
|
||||
|
||||
/**
|
||||
* 3D 模型展示布局(model3d)
|
||||
*
|
||||
* config 字段:
|
||||
* - model_url:GLB 模型路径(素材库上传,.glb/.gltf)
|
||||
* - model_height:展示区高度 px(默认 480)
|
||||
* - auto_rotate:是否自动旋转(默认开)
|
||||
*
|
||||
* three.js 通过动态 import 懒加载:滚动到视口内才初始化,
|
||||
* 未命中 3D 区块的页面零开销。渲染器透明背景,跟随区块背景/主题。
|
||||
*/
|
||||
const props = defineProps<{ section: SectionBlock }>();
|
||||
|
||||
const { overline, title, subtitle, config, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
|
||||
|
||||
const modelUrl = computed(() => resolveAssetUrl((config.value.model_url as string) || ''));
|
||||
const viewerHeight = computed(() => {
|
||||
const h = Number(config.value.model_height);
|
||||
return `${Number.isFinite(h) && h >= 200 ? h : 480}px`;
|
||||
});
|
||||
const autoRotate = computed(() => (config.value.auto_rotate as boolean) !== false);
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
|
||||
// 可见后才动态加载 three 并初始化,避免拖慢首屏
|
||||
const { stop: stopObserve } = useIntersectionObserver(
|
||||
containerRef,
|
||||
entries => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && status.value === 'idle' && modelUrl.value) {
|
||||
status.value = 'loading';
|
||||
stopObserve();
|
||||
initViewer();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
async function initViewer() {
|
||||
if (!containerRef.value || !modelUrl.value) return;
|
||||
try {
|
||||
// 动态 import:three 相关代码拆分为独立 chunk,按需加载
|
||||
const [THREE, { GLTFLoader }, { OrbitControls }] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/loaders/GLTFLoader.js'),
|
||||
import('three/addons/controls/OrbitControls.js')
|
||||
]);
|
||||
|
||||
const container = containerRef.value;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(width, height);
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
|
||||
// 基础布光:半球光 + 主方向光 + 补光,无环境贴图也能呈现合理质感
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x333333, 2.2));
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 2.5);
|
||||
keyLight.position.set(3, 5, 4);
|
||||
scene.add(keyLight);
|
||||
const fillLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
fillLight.position.set(-4, 1, -3);
|
||||
scene.add(fillLight);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.loadAsync(modelUrl.value);
|
||||
const model = gltf.scene;
|
||||
scene.add(model);
|
||||
|
||||
// 按包围球自动居中并适配相机距离
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
||||
const radius = sphere.radius || 1;
|
||||
model.position.sub(sphere.center);
|
||||
const dist = radius * 2.8;
|
||||
camera.position.set(dist * 0.6, dist * 0.4, dist);
|
||||
camera.near = radius / 100;
|
||||
camera.far = radius * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = radius * 1.4;
|
||||
controls.maxDistance = radius * 6;
|
||||
controls.autoRotate = autoRotate.value;
|
||||
controls.autoRotateSpeed = 1.5;
|
||||
|
||||
status.value = 'ready';
|
||||
|
||||
let rafId = 0;
|
||||
const animate = () => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
// 容器尺寸变化时同步渲染器与相机
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
if (w > 0 && h > 0) {
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
// 记录清理句柄,卸载时释放 WebGL 资源
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
ro.disconnect();
|
||||
controls.dispose();
|
||||
model.traverse(obj => {
|
||||
const mesh = obj as Mesh;
|
||||
if (mesh.isMesh) {
|
||||
mesh.geometry.dispose();
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
mats.forEach((m: Material) => m.dispose());
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[model3d] 3D 模型加载失败', e);
|
||||
status.value = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
onBeforeUnmount(() => {
|
||||
stopObserve();
|
||||
cleanup?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-container py-[var(--sec-py,96px)]">
|
||||
<!-- 标题区 -->
|
||||
<div v-if="overline || title || subtitle" class="text-center max-w-640px mx-auto mb-56px">
|
||||
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
|
||||
{{ overline }}
|
||||
</p>
|
||||
<h2 class="mt-12px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 3D 展示区:透明背景跟随区块背景 -->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative w-full overflow-hidden rounded-16px touch-none select-none"
|
||||
:style="{ height: viewerHeight }"
|
||||
>
|
||||
<!-- 加载占位 -->
|
||||
<div v-if="status !== 'ready'" class="absolute inset-0 flex flex-col items-center justify-center gap-12px">
|
||||
<template v-if="status === 'error'">
|
||||
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">3D 模型加载失败</span>
|
||||
</template>
|
||||
<template v-else-if="!modelUrl">
|
||||
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">未配置 3D 模型</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-24px w-24px animate-spin rounded-full border-2px border-gray-300 border-t-transparent" />
|
||||
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">模型加载中…</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 交互提示 -->
|
||||
<div
|
||||
v-if="status === 'ready'"
|
||||
class="absolute bottom-12px left-1/2 -translate-x-1/2 rounded-full bg-black/40 px-12px py-4px text-12px text-white backdrop-blur"
|
||||
>
|
||||
拖拽旋转 · 滚轮缩放
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,7 +86,8 @@ export type SectionLayout =
|
||||
| 'title_media'
|
||||
| 'mosaic'
|
||||
| 'marquee'
|
||||
| 'spec_table';
|
||||
| 'spec_table'
|
||||
| 'model3d';
|
||||
|
||||
export interface SpecItem {
|
||||
id: number;
|
||||
@@ -163,6 +164,12 @@ export interface SectionConfig {
|
||||
marquee_items?: MarqueeItem[];
|
||||
/** 跑马灯切换间隔(秒) */
|
||||
marquee_interval?: number;
|
||||
/** 3D 模型文件路径(model3d 布局,素材库上传的 .glb/.gltf) */
|
||||
model_url?: string;
|
||||
/** 3D 展示区高度 px(默认 480) */
|
||||
model_height?: number;
|
||||
/** 3D 模型是否自动旋转(默认开) */
|
||||
auto_rotate?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
"@vueuse/nuxt": "^14.4.0",
|
||||
"nuxt": "^4.5.1",
|
||||
"pinia": "^4.0.2",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.185.4",
|
||||
"@unocss/nuxt": "^66.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"unocss": "^66.0.0",
|
||||
|
||||
Generated
+53
@@ -35,6 +35,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.8.2)(vue@3.5.40(typescript@5.8.2))
|
||||
three:
|
||||
specifier: ^0.185.1
|
||||
version: 0.185.1
|
||||
vue:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.40(typescript@5.8.2)
|
||||
@@ -42,6 +45,9 @@ importers:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.8.2)(vue@3.5.40(typescript@5.8.2)))(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@5.8.2))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23))
|
||||
devDependencies:
|
||||
'@types/three':
|
||||
specifier: ^0.185.4
|
||||
version: 0.185.4
|
||||
'@unocss/nuxt':
|
||||
specifier: ^66.0.0
|
||||
version: 66.7.5(esbuild@0.28.1)(magic-string@1.1.0)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.3)(unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23)))(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23))
|
||||
@@ -242,6 +248,9 @@ packages:
|
||||
'@devframes/hub':
|
||||
optional: true
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0':
|
||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||
|
||||
'@dxup/nuxt@0.5.5':
|
||||
resolution: {integrity: sha512-7GIUr0aD4klOyLjGMzLBNjsZiIbU6EPqO5de1Q1kTPdZXWMqamr5FqRCZycA/9n9lxsXRLFxENL7tcRAClMTrw==}
|
||||
|
||||
@@ -1748,6 +1757,9 @@ packages:
|
||||
'@speed-highlight/core@1.2.17':
|
||||
resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3':
|
||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
@@ -1769,9 +1781,18 @@ packages:
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
'@types/stats.js@0.17.4':
|
||||
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
|
||||
|
||||
'@types/three@0.185.4':
|
||||
resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webxr@0.5.24':
|
||||
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
|
||||
|
||||
'@typescript-eslint/project-service@8.65.0':
|
||||
resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -2802,6 +2823,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -3296,6 +3320,9 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
meshoptimizer@1.1.1:
|
||||
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
@@ -4173,6 +4200,9 @@ packages:
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
three@0.185.1:
|
||||
resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -4971,6 +5001,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@devframes/hub': 0.7.14(devframe@0.7.14(srvx@0.11.22)(typescript@5.8.2))
|
||||
|
||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||
|
||||
'@dxup/nuxt@0.5.5(esbuild@0.28.1)(magicast@0.5.3)(oxc-parser@0.140.0)(rolldown@1.2.0)(rollup@4.62.3)(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.1(cssnano@8.0.2(postcss@8.5.23))(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.23))':
|
||||
dependencies:
|
||||
'@dxup/unimport': 0.1.2
|
||||
@@ -6372,6 +6404,8 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.17': {}
|
||||
|
||||
'@tweenjs/tween.js@23.1.3': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -6391,8 +6425,21 @@ snapshots:
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/stats.js@0.17.4': {}
|
||||
|
||||
'@types/three@0.185.4':
|
||||
dependencies:
|
||||
'@dimforge/rapier3d-compat': 0.12.0
|
||||
'@tweenjs/tween.js': 23.1.3
|
||||
'@types/stats.js': 0.17.4
|
||||
'@types/webxr': 0.5.24
|
||||
fflate: 0.8.3
|
||||
meshoptimizer: 1.1.1
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webxr@0.5.24': {}
|
||||
|
||||
'@typescript-eslint/project-service@8.65.0(supports-color@10.0.0)(typescript@5.8.2)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.8.2)
|
||||
@@ -7691,6 +7738,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.0
|
||||
@@ -8165,6 +8214,8 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
meshoptimizer@1.1.1: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
@@ -9318,6 +9369,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
three@0.185.1: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyclip@0.1.15: {}
|
||||
|
||||
Reference in New Issue
Block a user