library-picturebook-activity/frontend/src/views/model/ModelViewer.vue
2026-01-15 09:28:22 +08:00

1766 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="model-viewer-page">
<!-- Animated Background -->
<div class="bg-animation">
<div class="bg-gradient bg-gradient-1"></div>
<div class="bg-gradient bg-gradient-2"></div>
<div class="bg-gradient bg-gradient-3"></div>
</div>
<!-- Header -->
<div class="page-header">
<div class="header-left">
<a-button type="text" class="back-btn" @click="handleBack">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span class="title">3D 模型预览</span>
<span class="live-badge">
<span class="pulse-dot"></span>
LIVE
</span>
</div>
<div class="header-right">
<a-button type="text" class="action-btn" @click="resetCamera">
<template #icon><AimOutlined /></template>
重置视角
</a-button>
<a-button type="text" class="action-btn" @click="toggleFullscreen">
<template #icon
><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined
v-else
/></template>
{{ isFullscreen ? "退出全屏" : "全屏" }}
</a-button>
</div>
</div>
<!-- 3D Viewer -->
<div class="viewer-content">
<div ref="containerRef" class="model-canvas"></div>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="loader">
<div class="loader-ring"></div>
<div class="loader-ring"></div>
<div class="loader-ring"></div>
<span class="loader-text">正在加载模型...</span>
</div>
</div>
<!-- Error -->
<div v-if="error" class="error-overlay">
<div class="error-card">
<div class="error-icon">!</div>
<h3>模型加载失败</h3>
<p>{{ error }}</p>
<div class="error-actions">
<a-button type="primary" class="gradient-btn" @click="handleRetry"
>重试</a-button
>
<a-button class="outline-btn" @click="handleBack">返回</a-button>
</div>
</div>
</div>
<!-- Model Info (左侧) -->
<div v-if="!loading && !error && modelInfo" class="model-info">
<div class="info-header">
<span class="info-icon"><AppstoreOutlined /></span>
<span class="info-title">模型信息</span>
</div>
<div class="info-body">
<div class="info-item">
<span class="label">尺寸</span>
<span class="value">{{ modelInfo.size }}</span>
</div>
<div class="info-item">
<span class="label">顶点数</span>
<span class="value highlight">{{ modelInfo.vertices }}</span>
</div>
<div class="info-item">
<span class="label">面数</span>
<span class="value highlight">{{ modelInfo.faces }}</span>
</div>
</div>
</div>
<!-- Scene Settings Panel (右侧) -->
<div v-if="!loading && !error" class="scene-settings">
<div
class="settings-header"
@click="settingsPanelOpen = !settingsPanelOpen"
>
<span class="settings-title"> <SettingOutlined /> 场景设置 </span>
<span class="collapse-icon" :class="{ 'is-open': settingsPanelOpen }">
<RightOutlined />
</span>
</div>
<div v-show="settingsPanelOpen" class="settings-body">
<!-- 背景设置 -->
<div class="settings-group">
<div class="group-title">背景</div>
<div class="setting-item">
<span class="setting-label">背景色</span>
<input
type="color"
v-model="sceneSettings.backgroundColor"
@input="updateBackgroundColor"
class="color-picker"
/>
</div>
<div class="setting-item">
<span class="setting-label">显示网格</span>
<a-switch
v-model:checked="sceneSettings.showGrid"
size="small"
@change="updateGridVisibility"
/>
</div>
</div>
<!-- 环境光设置 -->
<div class="settings-group">
<div class="group-title">环境光</div>
<div class="setting-item">
<span class="setting-label">强度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.ambientLight.intensity"
:min="0"
:max="2"
:step="0.1"
@change="updateAmbientLight"
/>
<span class="slider-value">{{
sceneSettings.ambientLight.intensity.toFixed(1)
}}</span>
</div>
</div>
<div class="setting-item">
<span class="setting-label">颜色</span>
<input
type="color"
v-model="sceneSettings.ambientLight.color"
@input="updateAmbientLight"
class="color-picker"
/>
</div>
</div>
<!-- 主光源设置 -->
<div class="settings-group">
<div class="group-title">主光源</div>
<div class="setting-item">
<span class="setting-label">强度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.mainLight.intensity"
:min="0"
:max="3"
:step="0.1"
@change="updateMainLight"
/>
<span class="slider-value">{{
sceneSettings.mainLight.intensity.toFixed(1)
}}</span>
</div>
</div>
<div class="setting-item">
<span class="setting-label">颜色</span>
<input
type="color"
v-model="sceneSettings.mainLight.color"
@input="updateMainLight"
class="color-picker"
/>
</div>
<div class="setting-item">
<span class="setting-label">水平角度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.mainLight.horizontalAngle"
:min="0"
:max="360"
:step="5"
@change="updateMainLightPosition"
/>
<span class="slider-value"
>{{ sceneSettings.mainLight.horizontalAngle }}°</span
>
</div>
</div>
<div class="setting-item">
<span class="setting-label">垂直角度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.mainLight.verticalAngle"
:min="0"
:max="90"
:step="5"
@change="updateMainLightPosition"
/>
<span class="slider-value"
>{{ sceneSettings.mainLight.verticalAngle }}°</span
>
</div>
</div>
</div>
<!-- 补光设置 -->
<div class="settings-group">
<div class="group-title">补光</div>
<div class="setting-item">
<span class="setting-label">强度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.fillLight.intensity"
:min="0"
:max="2"
:step="0.1"
@change="updateFillLight"
/>
<span class="slider-value">{{
sceneSettings.fillLight.intensity.toFixed(1)
}}</span>
</div>
</div>
<div class="setting-item">
<span class="setting-label">颜色</span>
<input
type="color"
v-model="sceneSettings.fillLight.color"
@input="updateFillLight"
class="color-picker"
/>
</div>
</div>
<!-- 聚光灯设置 -->
<div class="settings-group">
<div class="group-title">聚光灯</div>
<div class="setting-item">
<span class="setting-label">启用</span>
<a-switch
v-model:checked="sceneSettings.spotLight.enabled"
size="small"
@change="updateSpotLight"
/>
</div>
<template v-if="sceneSettings.spotLight.enabled">
<div class="setting-item">
<span class="setting-label">强度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.spotLight.intensity"
:min="0"
:max="3"
:step="0.1"
@change="updateSpotLight"
/>
<span class="slider-value">{{
sceneSettings.spotLight.intensity.toFixed(1)
}}</span>
</div>
</div>
<div class="setting-item">
<span class="setting-label">颜色</span>
<input
type="color"
v-model="sceneSettings.spotLight.color"
@input="updateSpotLight"
class="color-picker"
/>
</div>
<div class="setting-item">
<span class="setting-label">光束角度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.spotLight.angle"
:min="10"
:max="90"
:step="5"
@change="updateSpotLight"
/>
<span class="slider-value"
>{{ sceneSettings.spotLight.angle }}°</span
>
</div>
</div>
</template>
</div>
<!-- 渲染设置 -->
<div class="settings-group">
<div class="group-title">渲染</div>
<div class="setting-item">
<span class="setting-label">曝光度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.render.exposure"
:min="0.1"
:max="3"
:step="0.1"
@change="updateRenderSettings"
/>
<span class="slider-value">{{
sceneSettings.render.exposure.toFixed(1)
}}</span>
</div>
</div>
<div class="setting-item">
<span class="setting-label">色调映射</span>
<a-select
v-model:value="sceneSettings.render.toneMapping"
size="small"
style="width: 120px"
@change="updateRenderSettings"
>
<a-select-option value="ACES">ACES</a-select-option>
<a-select-option value="Linear">线性</a-select-option>
<a-select-option value="Reinhard">Reinhard</a-select-option>
<a-select-option value="Cineon">Cineon</a-select-option>
</a-select>
</div>
</div>
</div>
<!-- 重置按钮 -->
<div v-show="settingsPanelOpen" class="settings-footer">
<a-button size="small" class="reset-btn" @click="resetSettings"
>重置默认</a-button
>
</div>
</div>
<!-- Controls Info -->
<div v-if="!loading && !error" class="controls-info">
<div class="control-hint">
<span class="hint-item"><span class="key">左键</span>旋转</span>
<span class="hint-item"><span class="key">右键</span>平移</span>
<span class="hint-item"><span class="key">滚轮</span>缩放</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import {
ArrowLeftOutlined,
AimOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
SettingOutlined,
RightOutlined,
AppstoreOutlined,
} from "@ant-design/icons-vue"
// @ts-ignore
import * as THREE from "three"
// @ts-ignore
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
// @ts-ignore
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
// @ts-ignore
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
// @ts-ignore
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
// @ts-ignore
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
const router = useRouter()
const route = useRoute()
const containerRef = ref<HTMLDivElement | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const isFullscreen = ref(false)
const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(
null
)
const settingsPanelOpen = ref(true)
// 场景设置默认值
const defaultSettings = {
backgroundColor: "#f5f5f5",
showGrid: false,
ambientLight: {
intensity: 0.4,
color: "#ffffff",
},
mainLight: {
intensity: 1.5,
color: "#ffffff",
horizontalAngle: 45,
verticalAngle: 60,
},
fillLight: {
intensity: 0.8,
color: "#ffffff",
},
spotLight: {
enabled: false,
intensity: 1.0,
color: "#ffffff",
angle: 30,
},
render: {
exposure: 1.2,
toneMapping: "ACES",
},
}
// 场景设置
const sceneSettings = reactive(JSON.parse(JSON.stringify(defaultSettings)))
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
let controls: OrbitControls | null = null
let model: THREE.Group | null = null
let animationId: number | null = null
let dracoLoader: DRACOLoader | null = null
let initialCameraPosition: THREE.Vector3 | null = null
// 灯光引用
let ambientLight: THREE.AmbientLight | null = null
let mainLight: THREE.DirectionalLight | null = null
let fillLight: THREE.DirectionalLight | null = null
let spotLight: THREE.SpotLight | null = null
let gridHelper: THREE.GridHelper | null = null
// 获取模型 URL
const modelUrl = ref((route.query.url as string) || "")
console.log("模型查看器 - URL:", modelUrl.value)
// 返回上一页
const handleBack = () => {
router.back()
}
// 重置相机视角
const resetCamera = () => {
if (camera && initialCameraPosition && controls) {
camera.position.copy(initialCameraPosition)
controls.target.set(0, 0, 0)
controls.update()
}
}
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 更新背景色
const updateBackgroundColor = () => {
if (scene) {
scene.background = new THREE.Color(sceneSettings.backgroundColor)
}
}
// 更新网格显示
const updateGridVisibility = () => {
if (gridHelper) {
gridHelper.visible = sceneSettings.showGrid
}
}
// 更新环境光
const updateAmbientLight = () => {
if (ambientLight) {
ambientLight.intensity = sceneSettings.ambientLight.intensity
ambientLight.color.set(sceneSettings.ambientLight.color)
}
}
// 更新主光源
const updateMainLight = () => {
if (mainLight) {
mainLight.intensity = sceneSettings.mainLight.intensity
mainLight.color.set(sceneSettings.mainLight.color)
}
}
// 更新主光源位置
const updateMainLightPosition = () => {
if (mainLight) {
const distance = 30
const hAngle = (sceneSettings.mainLight.horizontalAngle * Math.PI) / 180
const vAngle = (sceneSettings.mainLight.verticalAngle * Math.PI) / 180
mainLight.position.set(
distance * Math.cos(vAngle) * Math.cos(hAngle),
distance * Math.sin(vAngle),
distance * Math.cos(vAngle) * Math.sin(hAngle)
)
}
}
// 更新补光
const updateFillLight = () => {
if (fillLight) {
fillLight.intensity = sceneSettings.fillLight.intensity
fillLight.color.set(sceneSettings.fillLight.color)
}
}
// 更新聚光灯
const updateSpotLight = () => {
if (!scene) return
if (sceneSettings.spotLight.enabled) {
if (!spotLight) {
spotLight = new THREE.SpotLight(
sceneSettings.spotLight.color,
sceneSettings.spotLight.intensity
)
spotLight.position.set(0, 30, 0)
spotLight.angle = (sceneSettings.spotLight.angle * Math.PI) / 180
spotLight.penumbra = 0.3
spotLight.castShadow = true
scene.add(spotLight)
// 添加聚光灯目标
spotLight.target.position.set(0, 0, 0)
scene.add(spotLight.target)
} else {
spotLight.intensity = sceneSettings.spotLight.intensity
spotLight.color.set(sceneSettings.spotLight.color)
spotLight.angle = (sceneSettings.spotLight.angle * Math.PI) / 180
}
} else {
if (spotLight) {
scene.remove(spotLight)
scene.remove(spotLight.target)
spotLight.dispose()
spotLight = null
}
}
}
// 更新渲染设置
const updateRenderSettings = () => {
if (renderer) {
renderer.toneMappingExposure = sceneSettings.render.exposure
const toneMappingMap: Record<string, THREE.ToneMapping> = {
ACES: THREE.ACESFilmicToneMapping,
Linear: THREE.LinearToneMapping,
Reinhard: THREE.ReinhardToneMapping,
Cineon: THREE.CineonToneMapping,
}
renderer.toneMapping =
toneMappingMap[sceneSettings.render.toneMapping] ||
THREE.ACESFilmicToneMapping
}
}
// 重置设置
const resetSettings = () => {
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
updateBackgroundColor()
updateGridVisibility()
updateAmbientLight()
updateMainLight()
updateMainLightPosition()
updateFillLight()
updateSpotLight()
updateRenderSettings()
}
// 初始化场景
const initScene = () => {
if (!containerRef.value) return
// 创建场景
scene = new THREE.Scene()
scene.background = new THREE.Color(sceneSettings.backgroundColor)
// 创建相机
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000)
camera.position.set(10, 10, 10)
camera.lookAt(0, 0, 0)
// 创建渲染器 - 高质量设置
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
precision: "highp",
})
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = sceneSettings.render.exposure
renderer.outputColorSpace = THREE.SRGBColorSpace
containerRef.value.appendChild(renderer.domElement)
// 创建控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.minDistance = 0.1
controls.maxDistance = 1000
controls.enablePan = true
controls.panSpeed = 1
controls.rotateSpeed = 1
// 添加半球光 - 更自然的环境光
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
hemiLight.position.set(0, 100, 0)
scene.add(hemiLight)
// 添加环境光
ambientLight = new THREE.AmbientLight(
sceneSettings.ambientLight.color,
sceneSettings.ambientLight.intensity
)
scene.add(ambientLight)
// 添加主方向光
mainLight = new THREE.DirectionalLight(
sceneSettings.mainLight.color,
sceneSettings.mainLight.intensity
)
updateMainLightPosition()
mainLight.castShadow = true
mainLight.shadow.mapSize.width = 4096
mainLight.shadow.mapSize.height = 4096
mainLight.shadow.camera.near = 0.1
mainLight.shadow.camera.far = 500
mainLight.shadow.bias = -0.0001
scene.add(mainLight)
// 添加补光 - 从侧面
fillLight = new THREE.DirectionalLight(
sceneSettings.fillLight.color,
sceneSettings.fillLight.intensity
)
fillLight.position.set(-10, 10, -10)
scene.add(fillLight)
// 添加背光 - 增加立体感
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -20)
scene.add(backLight)
// 添加底部补光
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
scene.add(bottomLight)
// 添加网格 - 使用蓝色调
gridHelper = new THREE.GridHelper(100, 100, 0x0958d9, 0x1677ff)
gridHelper.visible = sceneSettings.showGrid
scene.add(gridHelper)
// 开始渲染
animate()
}
// 加载模型
const loadModel = async () => {
if (!scene || !modelUrl.value) {
error.value = "模型 URL 不存在"
loading.value = false
console.error("模型加载失败: URL为空", {
scene: !!scene,
url: modelUrl.value,
})
return
}
// 检查文件扩展名支持GLB、GLTF和ZIP格式ZIP会在后端解压
// 从URL中提取文件扩展名忽略查询参数
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
const supportedExtensions = [".glb", ".gltf", ".zip", ".obj"]
const isSupported = supportedExtensions.some((ext) =>
urlWithoutQuery.endsWith(ext)
)
if (!isSupported) {
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP/OBJ 格式`
loading.value = false
console.error("不支持的文件格式:", modelUrl.value)
return
}
loading.value = true
error.value = null
try {
console.log("开始加载模型URL:", modelUrl.value)
// 判断是本地URL还是外部URL
// 本地URL: /api/uploads/... 或相对路径
// 外部URL: http:// 或 https:// 开头的腾讯云COS链接
const isLocalUrl =
modelUrl.value.startsWith("/api/") ||
modelUrl.value.startsWith("/uploads/") ||
(!modelUrl.value.startsWith("http://") &&
!modelUrl.value.startsWith("https://"))
let loadUrl: string
if (isLocalUrl) {
// 本地URL直接使用不需要代理
loadUrl = modelUrl.value
console.log("使用本地URL:", loadUrl)
} else {
// 外部URL使用后端代理解决CORS问题
loadUrl = `/api/ai-3d/proxy-model?url=${encodeURIComponent(modelUrl.value)}`
console.log("使用代理URL:", loadUrl)
}
// 移除旧模型
if (model && scene) {
scene.remove(model)
disposeModel(model)
}
// 判断模型类型
let modelType: "glb" | "gltf" | "obj" = "glb" // 默认类型
// 先根据 URL 扩展名判断
if (urlWithoutQuery.endsWith(".obj")) {
modelType = "obj"
} else if (urlWithoutQuery.endsWith(".gltf")) {
modelType = "gltf"
}
// 如果是 ZIP 或代理请求,需要获取实际类型
const needsTypeDetection =
urlWithoutQuery.endsWith(".zip") || loadUrl.includes("proxy-model")
if (needsTypeDetection) {
// 对于 ZIP 或代理请求,先获取数据并检查类型头
console.log("获取模型数据以检测类型...")
const response = await fetch(loadUrl)
if (!response.ok) {
throw new Error(`模型加载失败: ${response.status} ${response.statusText}`)
}
// 检查 X-Model-Type 头部
const xModelType = response.headers.get("X-Model-Type")
if (xModelType === "obj") {
modelType = "obj"
} else if (xModelType === "gltf") {
modelType = "gltf"
}
console.log("检测到模型类型:", modelType)
// 获取数据
const arrayBuffer = await response.arrayBuffer()
if (modelType === "obj") {
// OBJ 是文本格式
const decoder = new TextDecoder()
const objText = decoder.decode(arrayBuffer)
const objLoader = new OBJLoader()
model = objLoader.parse(objText)
// 为 OBJ 模型添加默认材质
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (
!child.material ||
(child.material as THREE.Material).type === "MeshBasicMaterial"
) {
child.material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.3,
roughness: 0.7,
})
}
}
})
} else {
// GLB/GLTF 格式
const gltfLoader = new GLTFLoader()
if (!dracoLoader) {
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
)
}
gltfLoader.setDRACOLoader(dracoLoader)
// 使用 parse 方法解析已获取的数据
const gltf = await new Promise<any>((resolve, reject) => {
gltfLoader.parse(arrayBuffer, "", resolve, reject)
})
model = gltf.scene
}
} else {
// 非 ZIP 且非代理,直接使用 loader 加载
console.log("检测到模型类型:", modelType)
if (modelType === "obj") {
const objLoader = new OBJLoader()
model = await objLoader.loadAsync(loadUrl)
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
if (
!child.material ||
(child.material as THREE.Material).type === "MeshBasicMaterial"
) {
child.material = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.3,
roughness: 0.7,
})
}
}
})
} else {
const gltfLoader = new GLTFLoader()
if (!dracoLoader) {
dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
)
}
gltfLoader.setDRACOLoader(dracoLoader)
const gltf = await gltfLoader.loadAsync(loadUrl)
model = gltf.scene
}
}
// 计算模型信息
let vertexCount = 0
let faceCount = 0
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
const geometry = child.geometry
if (geometry.attributes.position) {
vertexCount += geometry.attributes.position.count
}
if (geometry.index) {
faceCount += geometry.index.count / 3
} else if (geometry.attributes.position) {
faceCount += geometry.attributes.position.count / 3
}
}
})
// 计算边界框
const box = new THREE.Box3().setFromObject(model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
// 设置模型信息
modelInfo.value = {
size: `${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)}`,
vertices: vertexCount.toLocaleString(),
faces: Math.round(faceCount).toLocaleString(),
}
// 居中模型
model.position.sub(center)
// 计算相机距离,确保模型完整显示
if (camera && containerRef.value) {
const maxDim = Math.max(size.x, size.y, size.z)
// 根据容器宽高比调整
const containerWidth = containerRef.value.clientWidth
const containerHeight = containerRef.value.clientHeight
const aspect = containerWidth / containerHeight
// 使用 FOV 计算理想距离
const fovRad = (camera.fov * Math.PI) / 180
// 计算需要的距离:确保模型在垂直和水平方向都能完整显示
const distanceForHeight = size.y / 2 / Math.tan(fovRad / 2)
const distanceForWidth = size.x / 2 / Math.tan(fovRad / 2) / aspect
const distanceForDepth = size.z / 2 / Math.tan(fovRad / 2)
// 取最大距离并添加适当的边距1.8倍,让模型显示更大)
const baseDistance = Math.max(
distanceForHeight,
distanceForWidth,
distanceForDepth
)
const cameraDistance = Math.max(baseDistance * 1.8, maxDim * 1.8, 5)
console.log("模型尺寸:", size)
console.log("计算的相机距离:", cameraDistance)
// 设置相机位置从斜上方45度角观看
const angle = Math.PI / 4
camera.position.set(
cameraDistance * Math.cos(angle),
cameraDistance * 0.5,
cameraDistance * Math.sin(angle)
)
camera.lookAt(0, 0, 0)
// 保存初始位置
initialCameraPosition = camera.position.clone()
// 更新控制器
if (controls) {
controls.target.set(0, 0, 0)
controls.update()
}
}
scene.add(model)
loading.value = false
console.log("模型加载成功")
} catch (err: any) {
console.error("模型加载失败:", err)
// 提取更详细的错误信息
let errorMessage = "无法加载模型文件"
if (err.message) {
errorMessage = err.message
} else if (err.response?.data?.message) {
errorMessage = err.response.data.message
} else if (typeof err === "string") {
errorMessage = err
}
// 如果是网络错误或代理错误,提供更友好的提示
if (errorMessage.includes("代理请求失败") || errorMessage.includes("ZIP")) {
errorMessage = `模型文件处理失败: ${errorMessage}`
} else if (
errorMessage.includes("timeout") ||
errorMessage.includes("超时")
) {
errorMessage = "模型文件下载超时,请检查网络连接或稍后重试"
} else if (
errorMessage.includes("404") ||
errorMessage.includes("不存在")
) {
errorMessage = "模型文件不存在或已被删除"
}
error.value = errorMessage
loading.value = false
}
}
// 释放模型资源
const disposeModel = (object: THREE.Object3D) => {
object.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose()
if (Array.isArray(child.material)) {
child.material.forEach((mat: THREE.Material) => mat.dispose())
} else {
child.material.dispose()
}
}
})
}
// 动画循环
const animate = () => {
const render = () => {
if (controls && camera && renderer && scene) {
controls.update()
renderer.render(scene, camera)
animationId = requestAnimationFrame(render)
}
}
render()
}
// 窗口大小变化
const handleResize = () => {
if (!containerRef.value || !camera || !renderer) return
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
// 全屏变化
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
setTimeout(handleResize, 100)
}
// 释放场景
const disposeScene = () => {
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
if (model && scene) {
disposeModel(model)
scene.remove(model)
model = null
}
if (spotLight && scene) {
scene.remove(spotLight)
scene.remove(spotLight.target)
spotLight.dispose()
spotLight = null
}
if (controls) {
controls.dispose()
controls = null
}
if (renderer && containerRef.value) {
renderer.dispose()
if (renderer.domElement.parentNode) {
containerRef.value.removeChild(renderer.domElement)
}
renderer = null
}
if (dracoLoader) {
dracoLoader.dispose()
dracoLoader = null
}
scene = null
camera = null
ambientLight = null
mainLight = null
fillLight = null
gridHelper = null
}
// 重试
const handleRetry = () => {
error.value = null
loadModel()
}
onMounted(() => {
window.addEventListener("resize", handleResize)
document.addEventListener("fullscreenchange", handleFullscreenChange)
initScene()
loadModel()
})
onUnmounted(() => {
window.removeEventListener("resize", handleResize)
document.removeEventListener("fullscreenchange", handleFullscreenChange)
disposeScene()
})
</script>
<style scoped lang="scss">
// ==========================================
// 蓝色主题色彩方案 - 与系统统一
// ==========================================
$primary: #0958d9;
$primary-light: #1677ff;
$primary-dark: #003eb3;
$secondary: #4096ff;
$accent: #1677ff;
$success: #52c41a;
$warning: #faad14;
$error: #ff4d4f;
// 背景色 - 模型查看器保持深色以更好展示3D模型
$background: #f5f5f5;
$surface: #ffffff;
$surface-dark: #1a1a2e;
$text: rgba(0, 0, 0, 0.85);
$text-secondary: rgba(0, 0, 0, 0.65);
$text-muted: rgba(0, 0, 0, 0.45);
$text-light: #ffffff;
// 渐变
$gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
.model-viewer-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: $background;
z-index: 1000;
overflow: hidden;
}
// ==========================================
// Animated Background
// ==========================================
.bg-animation {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.bg-gradient {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
animation: float 30s ease-in-out infinite;
&.bg-gradient-1 {
width: 600px;
height: 600px;
background: $primary;
top: -200px;
left: -100px;
}
&.bg-gradient-2 {
width: 500px;
height: 500px;
background: $primary-light;
bottom: -150px;
right: -100px;
animation-delay: -10s;
}
&.bg-gradient-3 {
width: 400px;
height: 400px;
background: $secondary;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -20s;
opacity: 0.1;
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -30px) scale(1.05);
}
66% {
transform: translate(-20px, 20px) scale(0.95);
}
}
// ==========================================
// Header
// ==========================================
.page-header {
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
color: $text !important;
width: 40px;
height: 40px;
border-radius: 10px !important;
border: 1px solid rgba($primary, 0.3) !important;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s !important;
flex-shrink: 0;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
.title {
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.live-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba($success, 0.1);
border: 1px solid rgba($success, 0.3);
border-radius: 20px;
color: $success;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
.pulse-dot {
width: 6px;
height: 6px;
background: $success;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
}
}
.header-right {
display: flex;
gap: 8px;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.action-btn {
color: $text !important;
border: 1px solid rgba($primary, 0.2) !important;
border-radius: 8px !important;
transition: all 0.3s ease !important;
&:hover {
color: $primary !important;
background: rgba($primary, 0.1) !important;
border-color: $primary !important;
transform: translateY(-1px);
}
}
// ==========================================
// Content Area
// ==========================================
.viewer-content {
flex: 1;
position: relative;
overflow: hidden;
z-index: 1;
background: rgba($surface, 0.3);
backdrop-filter: blur(10px);
}
.model-canvas {
width: 100%;
height: 100%;
:deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
}
// ==========================================
// Loading State
// ==========================================
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: $background;
z-index: 10;
}
.loader {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.loader-ring {
position: absolute;
width: 80px;
height: 80px;
border: 3px solid transparent;
border-radius: 50%;
animation: spin 1.5s linear infinite;
&:nth-child(1) {
border-top-color: $primary;
animation-delay: 0s;
}
&:nth-child(2) {
width: 60px;
height: 60px;
border-right-color: $primary-light;
animation-delay: 0.2s;
animation-direction: reverse;
}
&:nth-child(3) {
width: 40px;
height: 40px;
border-bottom-color: $secondary;
animation-delay: 0.4s;
}
}
.loader-text {
margin-top: 100px;
color: $text-muted;
font-size: 14px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// ==========================================
// Error State
// ==========================================
.error-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: $background;
z-index: 10;
}
.error-card {
text-align: center;
padding: 40px;
background: $surface;
border: 1px solid #e8e8e8;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
.error-icon {
width: 60px;
height: 60px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: bold;
color: #fff;
background: $error;
border-radius: 50%;
}
h3 {
color: $text;
font-size: 20px;
margin-bottom: 8px;
}
p {
color: $text-muted;
font-size: 14px;
margin-bottom: 24px;
}
}
.error-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.gradient-btn {
background: $gradient-primary !important;
border: none !important;
color: #fff !important;
font-weight: 500 !important;
&:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
}
.outline-btn {
background: transparent !important;
border: 1px solid #e8e8e8 !important;
color: $text !important;
&:hover {
border-color: $primary !important;
color: $primary-light !important;
}
}
// ==========================================
// Model Info (Left Side)
// ==========================================
.model-info {
position: absolute;
top: 20px;
left: 20px;
background: rgba($surface, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 12px;
overflow: hidden;
z-index: 5;
min-width: 200px;
.info-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: rgba($primary, 0.1);
border-bottom: 1px solid rgba($primary, 0.2);
.info-icon {
color: $primary-light;
font-size: 16px;
}
.info-title {
color: $text;
font-size: 14px;
font-weight: 600;
}
}
.info-body {
padding: 12px 16px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
&:not(:last-child) {
border-bottom: 1px solid rgba($primary, 0.1);
}
.label {
color: $text-muted;
font-size: 13px;
}
.value {
color: $text;
font-size: 13px;
font-weight: 500;
&.highlight {
color: $secondary;
}
}
}
}
// ==========================================
// Scene Settings (Right Side)
// ==========================================
.scene-settings {
position: absolute;
top: 20px;
right: 20px;
width: 280px;
max-height: calc(100% - 40px);
background: rgba($surface, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 12px;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
cursor: pointer;
flex-shrink: 0;
background: rgba($primary, 0.1);
&:hover {
background: rgba($primary, 0.15);
}
.settings-title {
display: flex;
align-items: center;
gap: 10px;
color: $text;
font-size: 14px;
font-weight: 600;
}
.collapse-icon {
color: $text-muted;
font-size: 12px;
transition: transform 0.3s;
&.is-open {
transform: rotate(90deg);
}
}
}
.settings-body {
flex: 1;
overflow-y: auto;
padding: 8px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba($primary, 0.3);
border-radius: 2px;
&:hover {
background: rgba($primary, 0.5);
}
}
}
.settings-group {
padding: 12px 16px;
border-bottom: 1px solid rgba($primary, 0.1);
&:last-of-type {
border-bottom: none;
}
.group-title {
color: $primary-light;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.setting-label {
color: $text;
font-size: 13px;
flex-shrink: 0;
}
}
.slider-wrapper {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
margin-left: 12px;
:deep(.ant-slider) {
flex: 1;
margin: 0;
.ant-slider-rail {
background: rgba($primary, 0.2);
}
.ant-slider-track {
background: $gradient-primary;
}
.ant-slider-handle {
border-color: $primary;
background: $primary;
&:hover,
&:focus {
border-color: $primary-light;
box-shadow: 0 0 0 4px rgba($primary, 0.2);
}
}
}
.slider-value {
color: $text-muted;
font-size: 12px;
min-width: 36px;
text-align: right;
}
}
.color-picker {
width: 36px;
height: 28px;
padding: 0;
border: 2px solid rgba($primary, 0.3);
border-radius: 6px;
cursor: pointer;
background: transparent;
transition: border-color 0.3s;
&:hover {
border-color: $primary;
}
&::-webkit-color-swatch-wrapper {
padding: 2px;
}
&::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
}
.settings-footer {
padding: 12px 16px;
border-top: 1px solid rgba($primary, 0.2);
flex-shrink: 0;
background: rgba($primary, 0.05);
}
.reset-btn {
width: 100%;
background: transparent !important;
border: 1px solid rgba($primary, 0.5) !important;
color: $primary-light !important;
font-weight: 500 !important;
transition: all 0.3s !important;
&:hover {
background: rgba($primary, 0.2) !important;
border-color: $primary !important;
color: #fff !important;
}
}
// Select 样式
:deep(.ant-select) {
.ant-select-selector {
background: rgba($primary, 0.1) !important;
border-color: rgba($primary, 0.3) !important;
color: $text !important;
border-radius: 6px !important;
}
.ant-select-arrow {
color: $text-muted;
}
&:hover .ant-select-selector {
border-color: $primary !important;
}
&.ant-select-focused .ant-select-selector {
border-color: $primary !important;
box-shadow: 0 0 0 2px rgba($primary, 0.2) !important;
}
}
// Switch 样式
:deep(.ant-switch) {
background: rgba($text-muted, 0.3);
&.ant-switch-checked {
background: $gradient-primary;
}
}
// ==========================================
// Controls Info
// ==========================================
.controls-info {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
}
.control-hint {
display: flex;
gap: 20px;
padding: 12px 24px;
background: rgba($surface, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 50px;
.hint-item {
display: flex;
align-items: center;
gap: 8px;
color: $text-muted;
font-size: 13px;
.key {
padding: 2px 8px;
background: rgba($primary, 0.2);
border: 1px solid rgba($primary, 0.3);
border-radius: 4px;
color: $primary-light;
font-size: 11px;
font-weight: 600;
}
}
}
</style>