library-picturebook-activity/frontend/src/views/model/ModelViewer.vue

2366 lines
60 KiB
Vue
Raw Normal View History

2026-01-09 18:14:35 +08:00
<template>
<div class="model-viewer-page">
2026-01-14 14:29:16 +08:00
<!-- 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>
2026-01-09 18:14:35 +08:00
<!-- Header -->
2026-01-15 09:28:22 +08:00
<div class="page-header">
2026-01-09 18:14:35 +08:00
<div class="header-left">
2026-01-13 16:41:12 +08:00
<a-button type="text" class="back-btn" @click="handleBack">
2026-01-09 18:14:35 +08:00
<template #icon><ArrowLeftOutlined /></template>
</a-button>
<span class="title">3D 模型预览</span>
2026-01-15 09:28:22 +08:00
<span class="live-badge">
<span class="pulse-dot"></span>
LIVE
</span>
2026-01-09 18:14:35 +08:00
</div>
<div class="header-right">
2026-01-13 16:41:12 +08:00
<a-button type="text" class="action-btn" @click="resetCamera">
2026-01-09 18:14:35 +08:00
<template #icon><AimOutlined /></template>
重置视角
</a-button>
2026-01-13 16:41:12 +08:00
<a-button type="text" class="action-btn" @click="toggleFullscreen">
2026-01-14 10:06:08 +08:00
<template #icon
><FullscreenOutlined v-if="!isFullscreen" /><FullscreenExitOutlined
v-else
/></template>
{{ isFullscreen ? "退出全屏" : "全屏" }}
2026-01-09 18:14:35 +08:00
</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">
2026-01-13 16:41:12 +08:00
<div class="loader">
<div class="loader-ring"></div>
<div class="loader-ring"></div>
<div class="loader-ring"></div>
<span class="loader-text">正在加载模型...</span>
</div>
2026-01-09 18:14:35 +08:00
</div>
<!-- Error -->
<div v-if="error" class="error-overlay">
2026-01-13 16:41:12 +08:00
<div class="error-card">
<div class="error-icon">!</div>
<h3>模型加载失败</h3>
<p>{{ error }}</p>
<div class="error-actions">
2026-01-14 10:06:08 +08:00
<a-button type="primary" class="gradient-btn" @click="handleRetry"
>重试</a-button
>
2026-01-13 16:41:12 +08:00
<a-button class="outline-btn" @click="handleBack">返回</a-button>
</div>
</div>
2026-01-09 18:14:35 +08:00
</div>
2026-01-13 16:41:12 +08:00
<!-- 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>
2026-01-09 18:14:35 +08:00
</div>
</div>
2026-01-16 11:07:56 +08:00
<!-- 右侧面板容器 -->
<div v-if="!loading && !error" class="right-panels">
<!-- Scene Settings Panel -->
<div 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>
2026-01-13 16:41:12 +08:00
2026-01-16 11:07:56 +08:00
<div v-show="settingsPanelOpen" class="settings-body">
2026-01-13 16:41:12 +08:00
<!-- 背景设置 -->
<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"
2026-01-14 10:06:08 +08:00
:min="0"
:max="2"
:step="0.1"
2026-01-13 16:41:12 +08:00
@change="updateAmbientLight"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value">{{
sceneSettings.ambientLight.intensity.toFixed(1)
}}</span>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="0"
:max="3"
:step="0.1"
2026-01-13 16:41:12 +08:00
@change="updateMainLight"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value">{{
sceneSettings.mainLight.intensity.toFixed(1)
}}</span>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="0"
:max="360"
:step="5"
2026-01-13 16:41:12 +08:00
@change="updateMainLightPosition"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value"
>{{ sceneSettings.mainLight.horizontalAngle }}°</span
>
2026-01-13 16:41:12 +08:00
</div>
</div>
<div class="setting-item">
<span class="setting-label">垂直角度</span>
<div class="slider-wrapper">
<a-slider
v-model:value="sceneSettings.mainLight.verticalAngle"
2026-01-14 10:06:08 +08:00
:min="0"
:max="90"
:step="5"
2026-01-13 16:41:12 +08:00
@change="updateMainLightPosition"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value"
>{{ sceneSettings.mainLight.verticalAngle }}°</span
>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="0"
:max="2"
:step="0.1"
2026-01-13 16:41:12 +08:00
@change="updateFillLight"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value">{{
sceneSettings.fillLight.intensity.toFixed(1)
}}</span>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="0"
:max="3"
:step="0.1"
2026-01-13 16:41:12 +08:00
@change="updateSpotLight"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value">{{
sceneSettings.spotLight.intensity.toFixed(1)
}}</span>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="10"
:max="90"
:step="5"
2026-01-13 16:41:12 +08:00
@change="updateSpotLight"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value"
>{{ sceneSettings.spotLight.angle }}°</span
>
2026-01-13 16:41:12 +08:00
</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"
2026-01-14 10:06:08 +08:00
:min="0.1"
:max="3"
:step="0.1"
2026-01-13 16:41:12 +08:00
@change="updateRenderSettings"
/>
2026-01-14 10:06:08 +08:00
<span class="slider-value">{{
sceneSettings.render.exposure.toFixed(1)
}}</span>
2026-01-13 16:41:12 +08:00
</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>
2026-01-16 11:07:56 +08:00
<!-- 重置按钮 -->
<div v-show="settingsPanelOpen" class="settings-footer">
<a-button size="small" class="reset-btn" @click="resetSettings"
>重置默认</a-button
>
</div>
</div>
<!-- 下载区域 - 独立面板在场景设置下方20px -->
<div class="download-panel">
<a-select
v-model:value="selectedFormat"
class="format-select"
:disabled="downloadingFormat !== null"
>
<a-select-option
v-for="format in downloadFormats"
:key="format.key"
:value="format.key"
2026-01-14 10:06:08 +08:00
>
2026-01-16 11:07:56 +08:00
{{ format.name }}
</a-select-option>
</a-select>
<a-button
type="primary"
class="download-btn"
:loading="downloadingFormat !== null"
@click="handleDownload(selectedFormat)"
>
<template #icon><DownloadOutlined /></template>
下载
</a-button>
2026-01-09 18:14:35 +08:00
</div>
2026-01-13 16:41:12 +08:00
</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>
2026-01-09 18:14:35 +08:00
</div>
</div>
2026-01-16 11:07:56 +08:00
<!-- 底部控制栏 -->
<div v-if="!loading && !error" class="bottom-controls">
<!-- 左箭头 - 切换模型 -->
<button
v-if="modelUrls.length > 1"
class="model-nav-btn"
:disabled="currentModelIndex === 0"
@click="switchModel(-1)"
>
<LeftOutlined />
</button>
<!-- 渲染模式选择器 -->
<div class="render-mode-bar">
<!-- 自动旋转 -->
<div
class="mode-item"
:class="{ active: autoRotate }"
title="自动旋转"
@click="toggleAutoRotate"
>
<div class="mode-icon auto-rotate-icon">
<SyncOutlined :spin="autoRotate" />
</div>
</div>
<!-- 渲染模式 -->
<div
v-for="mode in renderModes"
:key="mode.key"
class="mode-item"
:class="{ active: currentRenderMode === mode.key }"
:title="mode.label"
@click="switchRenderMode(mode.key)"
>
<div class="mode-icon" :style="{ background: mode.preview }">
</div>
</div>
</div>
<!-- 右箭头 - 切换模型 -->
<button
v-if="modelUrls.length > 1"
class="model-nav-btn"
:disabled="currentModelIndex >= modelUrls.length - 1"
@click="switchModel(1)"
>
<RightOutlined />
</button>
</div>
2026-01-09 18:14:35 +08:00
</div>
</div>
</template>
<script setup lang="ts">
2026-01-13 16:41:12 +08:00
import { ref, reactive, onMounted, onUnmounted } from "vue"
2026-01-09 18:14:35 +08:00
import { useRouter, useRoute } from "vue-router"
import {
ArrowLeftOutlined,
AimOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
2026-01-13 16:41:12 +08:00
SettingOutlined,
RightOutlined,
2026-01-16 11:07:56 +08:00
LeftOutlined,
2026-01-14 10:06:08 +08:00
AppstoreOutlined,
2026-01-16 11:07:56 +08:00
DownloadOutlined,
LoadingOutlined,
SyncOutlined,
2026-01-09 18:14:35 +08:00
} 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
2026-01-14 16:14:15 +08:00
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
// @ts-ignore
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
// @ts-ignore
2026-01-09 18:14:35 +08:00
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
2026-01-16 11:07:56 +08:00
// @ts-ignore
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js"
// @ts-ignore
import { OBJExporter } from "three/examples/jsm/exporters/OBJExporter.js"
// @ts-ignore
import { STLExporter } from "three/examples/jsm/exporters/STLExporter.js"
import { message } from "ant-design-vue"
2026-01-09 18:14:35 +08:00
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)
2026-01-14 10:06:08 +08:00
const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(
null
)
2026-01-13 16:41:12 +08:00
const settingsPanelOpen = ref(true)
2026-01-16 11:07:56 +08:00
// 多模型和渲染模式相关
const modelUrls = ref<string[]>([])
const currentModelIndex = ref(0)
const currentRenderMode = ref("textured")
const autoRotate = ref(true) // 默认开启自动旋转
// 渲染模式配置4种素模、材质、法线、PBR
const renderModes = [
{
key: "clay",
label: "素模",
preview: "linear-gradient(135deg, #c9c9c9 0%, #e8e8e8 100%)",
},
{
key: "textured",
label: "材质贴图",
preview: "linear-gradient(135deg, #8b7355 0%, #d4c4b0 100%)",
},
{
key: "normal",
label: "法线贴图",
preview: "linear-gradient(135deg, #7b68ee 0%, #87ceeb 100%)",
},
{
key: "pbr",
label: "PBR渲染",
preview: "linear-gradient(135deg, #ff6b9d 0%, #c44cff 50%, #6b5bff 100%)",
},
]
// 切换自动旋转
const toggleAutoRotate = () => {
autoRotate.value = !autoRotate.value
if (controls) {
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = 2.0
}
}
// 下载格式配置
const downloadFormats = [
{ key: "glb", name: "GLB" },
{ key: "gltf", name: "GLTF" },
{ key: "obj", name: "OBJ" },
{ key: "stl", name: "STL" },
]
// 下载状态
const selectedFormat = ref("glb")
const downloadingFormat = ref<string | null>(null)
// 下载模型
const handleDownload = async (format: string) => {
if (!model || downloadingFormat.value) return
downloadingFormat.value = format
try {
let blob: Blob
let filename = `model_${Date.now()}`
// 恢复原始材质后再导出
const currentMode = currentRenderMode.value
if (currentMode !== "textured") {
applyRenderMode("textured")
}
switch (format) {
case "glb":
blob = await exportGLB()
filename += ".glb"
break
case "gltf":
blob = await exportGLTF()
filename += ".gltf"
break
case "obj":
blob = exportOBJ()
filename += ".obj"
break
case "stl":
blob = exportSTL()
filename += ".stl"
break
default:
throw new Error("不支持的格式")
}
// 恢复渲染模式
if (currentMode !== "textured") {
applyRenderMode(currentMode)
}
// 触发下载
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
message.success(`${format.toUpperCase()} 文件下载成功`)
} catch (err: any) {
console.error("导出失败:", err)
message.error(`导出失败: ${err.message}`)
} finally {
downloadingFormat.value = null
}
}
// 导出 GLB
const exportGLB = (): Promise<Blob> => {
return new Promise((resolve, reject) => {
const exporter = new GLTFExporter()
exporter.parse(
model!,
(result: ArrayBuffer) => {
resolve(new Blob([result], { type: "model/gltf-binary" }))
},
(error: Error) => reject(error),
{ binary: true }
)
})
}
// 导出 GLTF
const exportGLTF = (): Promise<Blob> => {
return new Promise((resolve, reject) => {
const exporter = new GLTFExporter()
exporter.parse(
model!,
(result: object) => {
const json = JSON.stringify(result, null, 2)
resolve(new Blob([json], { type: "application/json" }))
},
(error: Error) => reject(error),
{ binary: false }
)
})
}
// 导出 OBJ
const exportOBJ = (): Blob => {
const exporter = new OBJExporter()
const result = exporter.parse(model!)
return new Blob([result], { type: "text/plain" })
}
// 导出 STL
const exportSTL = (): Blob => {
const exporter = new STLExporter()
const result = exporter.parse(model!, { binary: true })
return new Blob([result], { type: "application/octet-stream" })
}
// 保存原始材质
let originalMaterials: Map<THREE.Mesh, THREE.Material | THREE.Material[]> = new Map()
2026-01-13 16:41:12 +08:00
// 场景设置默认值
const defaultSettings = {
2026-01-14 14:29:16 +08:00
backgroundColor: "#f5f5f5",
showGrid: false,
2026-01-13 16:41:12 +08:00
ambientLight: {
intensity: 0.4,
2026-01-14 10:06:08 +08:00
color: "#ffffff",
2026-01-13 16:41:12 +08:00
},
mainLight: {
intensity: 1.5,
color: "#ffffff",
horizontalAngle: 45,
2026-01-14 10:06:08 +08:00
verticalAngle: 60,
2026-01-13 16:41:12 +08:00
},
fillLight: {
intensity: 0.8,
2026-01-14 10:06:08 +08:00
color: "#ffffff",
2026-01-13 16:41:12 +08:00
},
spotLight: {
enabled: false,
intensity: 1.0,
color: "#ffffff",
2026-01-14 10:06:08 +08:00
angle: 30,
2026-01-13 16:41:12 +08:00
},
render: {
exposure: 1.2,
2026-01-14 10:06:08 +08:00
toneMapping: "ACES",
},
2026-01-13 16:41:12 +08:00
}
// 场景设置
const sceneSettings = reactive(JSON.parse(JSON.stringify(defaultSettings)))
2026-01-09 18:14:35 +08:00
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
2026-01-13 16:41:12 +08:00
// 灯光引用
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
2026-01-16 11:07:56 +08:00
// 获取模型 URL - 支持刷新页面保持状态,支持多模型
2026-01-15 18:00:42 +08:00
const SESSION_KEY = "model-viewer-url"
2026-01-16 11:07:56 +08:00
const SESSION_KEY_URLS = "model-viewer-urls"
const SESSION_KEY_INDEX = "model-viewer-index"
const initModelUrls = () => {
// 优先从 query 获取支持多个URL用逗号分隔
2026-01-15 18:00:42 +08:00
const queryUrl = route.query.url as string
2026-01-16 11:07:56 +08:00
const queryUrls = route.query.urls as string
const queryIndex = route.query.index as string
if (queryUrls) {
// 多个URL从query
const urls = queryUrls.split(",").filter(Boolean)
sessionStorage.setItem(SESSION_KEY_URLS, JSON.stringify(urls))
modelUrls.value = urls
// 设置初始索引
if (queryIndex) {
currentModelIndex.value = Math.min(parseInt(queryIndex) || 0, urls.length - 1)
}
} else if (queryUrl) {
// 单个URL从query
2026-01-15 18:00:42 +08:00
sessionStorage.setItem(SESSION_KEY, queryUrl)
2026-01-16 11:07:56 +08:00
modelUrls.value = [queryUrl]
} else {
// 从 sessionStorage 恢复
const storedUrls = sessionStorage.getItem(SESSION_KEY_URLS)
const storedUrl = sessionStorage.getItem(SESSION_KEY)
const storedIndex = sessionStorage.getItem(SESSION_KEY_INDEX)
if (storedUrls) {
modelUrls.value = JSON.parse(storedUrls)
// 恢复索引
if (storedIndex) {
currentModelIndex.value = Math.min(
parseInt(storedIndex) || 0,
modelUrls.value.length - 1
)
}
} else if (storedUrl) {
modelUrls.value = [storedUrl]
}
2026-01-15 18:00:42 +08:00
}
2026-01-16 11:07:56 +08:00
}
const modelUrl = ref("")
const updateCurrentModelUrl = () => {
modelUrl.value = modelUrls.value[currentModelIndex.value] || ""
}
// 切换模型
const switchModel = (direction: number) => {
const newIndex = currentModelIndex.value + direction
if (newIndex >= 0 && newIndex < modelUrls.value.length) {
currentModelIndex.value = newIndex
updateCurrentModelUrl()
// 重置渲染模式为默认
currentRenderMode.value = "textured"
// 重置自动旋转
autoRotate.value = false
if (controls) {
controls.autoRotate = false
}
// 重新加载模型
loadModel()
2026-01-15 18:00:42 +08:00
}
}
2026-01-16 11:07:56 +08:00
// 切换渲染模式
const switchRenderMode = (mode: string) => {
if (currentRenderMode.value === mode || !model) return
currentRenderMode.value = mode
applyRenderMode(mode)
}
// 应用渲染模式
const applyRenderMode = (mode: string) => {
if (!model) return
model.traverse((child: THREE.Object3D) => {
if (child instanceof THREE.Mesh) {
// 如果没保存过原始材质,先保存
if (!originalMaterials.has(child)) {
originalMaterials.set(child, child.material)
}
const originalMaterial = originalMaterials.get(child)
switch (mode) {
case "wireframe":
// 线框模式
child.material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
wireframe: true,
})
break
case "clay":
// 素模 - 灰色无贴图
child.material = new THREE.MeshStandardMaterial({
color: 0xcccccc,
metalness: 0.1,
roughness: 0.8,
})
break
case "textured":
// 恢复原始材质
if (originalMaterial) {
child.material = originalMaterial
}
break
case "normal":
// 法线贴图可视化
child.material = new THREE.MeshNormalMaterial()
break
case "pbr":
// PBR渲染 - 增强金属感
if (originalMaterial && !Array.isArray(originalMaterial)) {
const mat = originalMaterial as THREE.MeshStandardMaterial
child.material = new THREE.MeshStandardMaterial({
color: mat.color || 0xffffff,
map: mat.map || null,
metalness: 0.8,
roughness: 0.2,
envMapIntensity: 1.5,
})
} else {
child.material = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.8,
roughness: 0.2,
})
}
break
}
}
})
}
console.log("模型查看器初始化")
2026-01-09 18:14:35 +08:00
// 返回上一页
const handleBack = () => {
2026-01-16 11:07:56 +08:00
// 清除 sessionStorage 中的 URL 和索引,防止下次从其他入口进入时使用旧数据
2026-01-15 18:00:42 +08:00
sessionStorage.removeItem(SESSION_KEY)
2026-01-16 11:07:56 +08:00
sessionStorage.removeItem(SESSION_KEY_URLS)
sessionStorage.removeItem(SESSION_KEY_INDEX)
2026-01-15 18:00:42 +08:00
// 检查是否有历史记录可以返回
// 如果是从新窗口打开的(无历史),则关闭窗口
if (window.history.length <= 1) {
window.close()
} else {
router.back()
}
2026-01-09 18:14:35 +08:00
}
// 重置相机视角
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
}
}
2026-01-13 16:41:12 +08:00
// 更新背景色
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> = {
2026-01-14 10:06:08 +08:00
ACES: THREE.ACESFilmicToneMapping,
Linear: THREE.LinearToneMapping,
Reinhard: THREE.ReinhardToneMapping,
Cineon: THREE.CineonToneMapping,
2026-01-13 16:41:12 +08:00
}
2026-01-14 10:06:08 +08:00
renderer.toneMapping =
toneMappingMap[sceneSettings.render.toneMapping] ||
THREE.ACESFilmicToneMapping
2026-01-13 16:41:12 +08:00
}
}
// 重置设置
const resetSettings = () => {
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
2026-01-15 16:35:00 +08:00
// 重置为透明背景
if (scene) {
scene.background = null
}
2026-01-13 16:41:12 +08:00
updateGridVisibility()
updateAmbientLight()
updateMainLight()
updateMainLightPosition()
updateFillLight()
updateSpotLight()
updateRenderSettings()
}
2026-01-09 18:14:35 +08:00
// 初始化场景
const initScene = () => {
if (!containerRef.value) return
2026-01-15 16:35:00 +08:00
// 创建场景 - 使用透明背景,让页面渐变效果显示
2026-01-09 18:14:35 +08:00
scene = new THREE.Scene()
2026-01-15 16:35:00 +08:00
scene.background = null
2026-01-09 18:14:35 +08:00
// 创建相机
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",
2026-01-14 10:06:08 +08:00
precision: "highp",
2026-01-09 18:14:35 +08:00
})
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.toneMapping = THREE.ACESFilmicToneMapping
2026-01-13 16:41:12 +08:00
renderer.toneMappingExposure = sceneSettings.render.exposure
2026-01-09 18:14:35 +08:00
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
2026-01-16 11:07:56 +08:00
// 自动旋转
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = 2.0
2026-01-09 18:14:35 +08:00
// 添加半球光 - 更自然的环境光
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
hemiLight.position.set(0, 100, 0)
scene.add(hemiLight)
// 添加环境光
2026-01-13 16:41:12 +08:00
ambientLight = new THREE.AmbientLight(
sceneSettings.ambientLight.color,
sceneSettings.ambientLight.intensity
)
2026-01-09 18:14:35 +08:00
scene.add(ambientLight)
2026-01-13 16:41:12 +08:00
// 添加主方向光
mainLight = new THREE.DirectionalLight(
sceneSettings.mainLight.color,
sceneSettings.mainLight.intensity
)
updateMainLightPosition()
2026-01-09 18:14:35 +08:00
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)
// 添加补光 - 从侧面
2026-01-13 16:41:12 +08:00
fillLight = new THREE.DirectionalLight(
sceneSettings.fillLight.color,
sceneSettings.fillLight.intensity
)
2026-01-09 18:14:35 +08:00
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)
2026-01-14 14:29:16 +08:00
// 添加网格 - 使用蓝色调
gridHelper = new THREE.GridHelper(100, 100, 0x0958d9, 0x1677ff)
2026-01-13 16:41:12 +08:00
gridHelper.visible = sceneSettings.showGrid
2026-01-09 18:14:35 +08:00
scene.add(gridHelper)
// 开始渲染
animate()
}
// 加载模型
const loadModel = async () => {
if (!scene || !modelUrl.value) {
error.value = "模型 URL 不存在"
loading.value = false
2026-01-14 10:06:08 +08:00
console.error("模型加载失败: URL为空", {
scene: !!scene,
url: modelUrl.value,
})
2026-01-13 11:11:49 +08:00
return
}
2026-01-14 10:06:08 +08:00
// 检查文件扩展名支持GLB、GLTF和ZIP格式ZIP会在后端解压
// 从URL中提取文件扩展名忽略查询参数
const urlWithoutQuery = modelUrl.value.split("?")[0].toLowerCase()
2026-01-14 16:14:15 +08:00
const supportedExtensions = [".glb", ".gltf", ".zip", ".obj"]
2026-01-14 10:06:08 +08:00
const isSupported = supportedExtensions.some((ext) =>
urlWithoutQuery.endsWith(ext)
)
2026-01-13 11:11:49 +08:00
if (!isSupported) {
2026-01-14 16:14:15 +08:00
error.value = `不支持的文件格式,目前仅支持 GLB/GLTF/ZIP/OBJ 格式`
2026-01-13 11:11:49 +08:00
loading.value = false
console.error("不支持的文件格式:", modelUrl.value)
2026-01-09 18:14:35 +08:00
return
}
loading.value = true
error.value = null
try {
console.log("开始加载模型URL:", modelUrl.value)
2026-01-14 16:14:15 +08:00
// 判断是本地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)
2026-01-09 18:14:35 +08:00
}
// 移除旧模型
if (model && scene) {
scene.remove(model)
disposeModel(model)
}
2026-01-14 16:14:15 +08:00
// 判断模型类型
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
}
}
2026-01-09 18:14:35 +08:00
// 计算模型信息
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(),
2026-01-14 10:06:08 +08:00
faces: Math.round(faceCount).toLocaleString(),
2026-01-09 18:14:35 +08:00
}
// 居中模型
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
// 计算需要的距离:确保模型在垂直和水平方向都能完整显示
2026-01-14 10:06:08 +08:00
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)
2026-01-09 18:14:35 +08:00
2026-01-14 14:29:16 +08:00
// 取最大距离并添加适当的边距1.8倍,让模型显示更大)
2026-01-14 10:06:08 +08:00
const baseDistance = Math.max(
distanceForHeight,
distanceForWidth,
distanceForDepth
)
2026-01-14 14:29:16 +08:00
const cameraDistance = Math.max(baseDistance * 1.8, maxDim * 1.8, 5)
2026-01-09 18:14:35 +08:00
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)
2026-01-14 10:06:08 +08:00
// 提取更详细的错误信息
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
2026-01-09 18:14:35 +08:00
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
}
2026-01-13 16:41:12 +08:00
if (spotLight && scene) {
scene.remove(spotLight)
scene.remove(spotLight.target)
spotLight.dispose()
spotLight = null
}
2026-01-09 18:14:35 +08:00
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
2026-01-13 16:41:12 +08:00
ambientLight = null
mainLight = null
fillLight = null
gridHelper = null
2026-01-16 11:07:56 +08:00
// 清理材质缓存
originalMaterials.clear()
2026-01-09 18:14:35 +08:00
}
// 重试
const handleRetry = () => {
error.value = null
loadModel()
}
onMounted(() => {
window.addEventListener("resize", handleResize)
document.addEventListener("fullscreenchange", handleFullscreenChange)
2026-01-16 11:07:56 +08:00
// 初始化多模型URL
initModelUrls()
updateCurrentModelUrl()
2026-01-09 18:14:35 +08:00
initScene()
loadModel()
})
onUnmounted(() => {
window.removeEventListener("resize", handleResize)
document.removeEventListener("fullscreenchange", handleFullscreenChange)
disposeScene()
})
</script>
<style scoped lang="scss">
2026-01-13 16:41:12 +08:00
// ==========================================
2026-01-14 14:29:16 +08:00
// 蓝色主题色彩方案 - 与系统统一
2026-01-13 16:41:12 +08:00
// ==========================================
2026-01-14 14:29:16 +08:00
$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%);
2026-01-13 16:41:12 +08:00
2026-01-09 18:14:35 +08:00
.model-viewer-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
2026-01-13 16:41:12 +08:00
background: $background;
2026-01-09 18:14:35 +08:00
z-index: 1000;
2026-01-13 16:41:12 +08:00
overflow: hidden;
2026-01-14 14:29:16 +08:00
}
// ==========================================
// 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;
}
2026-01-13 16:41:12 +08:00
2026-01-14 14:29:16 +08:00
&.bg-gradient-3 {
width: 400px;
height: 400px;
background: $secondary;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation-delay: -20s;
opacity: 0.1;
2026-01-13 16:41:12 +08:00
}
}
2026-01-14 14:29:16 +08:00
@keyframes float {
2026-01-14 10:06:08 +08:00
0%,
100% {
2026-01-14 14:29:16 +08:00
transform: translate(0, 0) scale(1);
2026-01-14 10:06:08 +08:00
}
2026-01-14 14:29:16 +08:00
33% {
transform: translate(30px, -30px) scale(1.05);
}
66% {
transform: translate(-20px, 20px) scale(0.95);
2026-01-14 10:06:08 +08:00
}
2026-01-09 18:14:35 +08:00
}
2026-01-13 16:41:12 +08:00
// ==========================================
// Header
// ==========================================
2026-01-15 09:28:22 +08:00
.page-header {
2026-01-15 16:35:00 +08:00
position: absolute;
top: 0;
left: 0;
right: 0;
2026-01-15 09:28:22 +08:00
height: 64px;
padding: 0 24px;
2026-01-09 18:14:35 +08:00
display: flex;
align-items: center;
2026-01-15 09:28:22 +08:00
justify-content: space-between;
2026-01-13 16:41:12 +08:00
z-index: 10;
2026-01-15 16:35:00 +08:00
background: transparent;
2026-01-15 09:28:22 +08:00
}
2026-01-09 18:14:35 +08:00
2026-01-15 09:28:22 +08:00
.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;
2026-01-09 18:14:35 +08:00
display: flex;
align-items: center;
2026-01-15 09:28:22 +08:00
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);
2026-01-13 16:41:12 +08:00
}
2026-01-15 09:28:22 +08:00
}
2026-01-13 16:41:12 +08:00
2026-01-15 09:28:22 +08:00
.title {
font-size: 20px;
font-weight: 600;
background: $gradient-primary;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
2026-01-09 18:14:35 +08:00
}
2026-01-15 09:28:22 +08:00
.live-badge {
2026-01-09 18:14:35 +08:00
display: flex;
2026-01-15 09:28:22 +08:00
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;
}
2026-01-09 18:14:35 +08:00
}
2026-01-13 16:41:12 +08:00
}
2026-01-09 18:14:35 +08:00
2026-01-15 09:28:22 +08:00
.header-right {
display: flex;
gap: 8px;
}
2026-01-13 16:41:12 +08:00
@keyframes pulse {
2026-01-14 10:06:08 +08:00
0%,
100% {
opacity: 1;
2026-01-15 09:28:22 +08:00
transform: scale(1);
2026-01-14 10:06:08 +08:00
}
50% {
2026-01-15 09:28:22 +08:00
opacity: 0.5;
transform: scale(0.8);
2026-01-09 18:14:35 +08:00
}
}
2026-01-14 14:29:16 +08:00
.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);
}
}
2026-01-13 16:41:12 +08:00
// ==========================================
// Content Area
// ==========================================
2026-01-09 18:14:35 +08:00
.viewer-content {
2026-01-15 16:35:00 +08:00
position: absolute;
inset: 0;
2026-01-09 18:14:35 +08:00
overflow: hidden;
2026-01-13 16:41:12 +08:00
z-index: 1;
2026-01-09 18:14:35 +08:00
}
.model-canvas {
width: 100%;
height: 100%;
:deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
}
2026-01-13 16:41:12 +08:00
// ==========================================
// Loading State
// ==========================================
.loading-overlay {
2026-01-09 18:14:35 +08:00
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
2026-01-14 14:29:16 +08:00
background: $background;
2026-01-09 18:14:35 +08:00
z-index: 10;
2026-01-13 16:41:12 +08:00
}
2026-01-09 18:14:35 +08:00
2026-01-13 16:41:12 +08:00
.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;
2026-01-09 18:14:35 +08:00
}
2026-01-13 16:41:12 +08:00
&:nth-child(2) {
width: 60px;
height: 60px;
2026-01-14 14:29:16 +08:00
border-right-color: $primary-light;
2026-01-13 16:41:12 +08:00
animation-delay: 0.2s;
animation-direction: reverse;
2026-01-09 18:14:35 +08:00
}
2026-01-13 16:41:12 +08:00
&:nth-child(3) {
width: 40px;
height: 40px;
2026-01-14 14:29:16 +08:00
border-bottom-color: $secondary;
2026-01-13 16:41:12 +08:00
animation-delay: 0.4s;
2026-01-09 18:14:35 +08:00
}
}
2026-01-13 16:41:12 +08:00
.loader-text {
margin-top: 100px;
color: $text-muted;
font-size: 14px;
2026-01-09 18:14:35 +08:00
}
2026-01-13 16:41:12 +08:00
@keyframes spin {
2026-01-14 10:06:08 +08:00
to {
transform: rotate(360deg);
}
2026-01-13 16:41:12 +08:00
}
// ==========================================
// Error State
// ==========================================
.error-overlay {
position: absolute;
inset: 0;
2026-01-09 18:14:35 +08:00
display: flex;
2026-01-13 16:41:12 +08:00
align-items: center;
justify-content: center;
2026-01-14 14:29:16 +08:00
background: $background;
2026-01-13 16:41:12 +08:00
z-index: 10;
}
.error-card {
text-align: center;
padding: 40px;
2026-01-14 14:29:16 +08:00
background: $surface;
border: 1px solid #e8e8e8;
2026-01-13 16:41:12 +08:00
border-radius: 16px;
2026-01-14 14:29:16 +08:00
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
2026-01-13 16:41:12 +08:00
.error-icon {
width: 60px;
height: 60px;
margin: 0 auto 20px;
2026-01-09 18:14:35 +08:00
display: flex;
align-items: center;
2026-01-13 16:41:12 +08:00
justify-content: center;
font-size: 32px;
font-weight: bold;
color: #fff;
2026-01-14 14:29:16 +08:00
background: $error;
2026-01-13 16:41:12 +08:00
border-radius: 50%;
}
h3 {
color: $text;
font-size: 20px;
margin-bottom: 8px;
}
p {
color: $text-muted;
font-size: 14px;
margin-bottom: 24px;
2026-01-09 18:14:35 +08:00
}
}
2026-01-13 16:41:12 +08:00
.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;
2026-01-14 14:29:16 +08:00
border: 1px solid #e8e8e8 !important;
2026-01-13 16:41:12 +08:00
color: $text !important;
&:hover {
border-color: $primary !important;
color: $primary-light !important;
}
}
// ==========================================
// Model Info (Left Side)
// ==========================================
2026-01-09 18:14:35 +08:00
.model-info {
position: absolute;
2026-01-15 16:35:00 +08:00
top: 84px;
2026-01-13 16:41:12 +08:00
left: 20px;
background: rgba($surface, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 12px;
overflow: hidden;
2026-01-09 18:14:35 +08:00
z-index: 5;
2026-01-13 16:41:12 +08:00
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;
}
2026-01-09 18:14:35 +08:00
.info-item {
display: flex;
2026-01-13 16:41:12 +08:00
justify-content: space-between;
align-items: center;
padding: 8px 0;
2026-01-09 18:14:35 +08:00
2026-01-13 16:41:12 +08:00
&:not(:last-child) {
border-bottom: 1px solid rgba($primary, 0.1);
2026-01-09 18:14:35 +08:00
}
.label {
2026-01-13 16:41:12 +08:00
color: $text-muted;
font-size: 13px;
2026-01-09 18:14:35 +08:00
}
.value {
2026-01-13 16:41:12 +08:00
color: $text;
font-size: 13px;
2026-01-09 18:14:35 +08:00
font-weight: 500;
2026-01-13 16:41:12 +08:00
&.highlight {
color: $secondary;
}
}
}
}
// ==========================================
2026-01-16 11:07:56 +08:00
// Right Panels Container
2026-01-13 16:41:12 +08:00
// ==========================================
2026-01-16 11:07:56 +08:00
.right-panels {
2026-01-13 16:41:12 +08:00
position: absolute;
2026-01-15 16:35:00 +08:00
top: 84px;
2026-01-13 16:41:12 +08:00
right: 20px;
width: 280px;
2026-01-15 16:35:00 +08:00
max-height: calc(100% - 104px);
2026-01-16 11:07:56 +08:00
display: flex;
flex-direction: column;
gap: 20px;
z-index: 10;
}
// ==========================================
// Scene Settings
// ==========================================
.scene-settings {
2026-01-13 16:41:12 +08:00
background: rgba($surface, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
2026-01-16 11:07:56 +08:00
max-height: calc(100vh - 200px);
2026-01-13 16:41:12 +08:00
}
.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;
2026-01-14 10:06:08 +08:00
&:hover,
&:focus {
2026-01-13 16:41:12 +08:00
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;
}
}
2026-01-16 11:07:56 +08:00
// ==========================================
// Download Panel (独立面板场景设置下方20px)
// ==========================================
.download-panel {
display: flex;
gap: 10px;
padding: 14px 16px;
background: rgba($surface, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3);
border-radius: 12px;
flex-shrink: 0;
.format-select {
flex: 1;
:deep(.ant-select-selector) {
background: rgba(40, 40, 55, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: 8px !important;
color: #fff !important;
height: 38px !important;
.ant-select-selection-item {
line-height: 36px !important;
font-weight: 500;
font-size: 13px;
}
}
:deep(.ant-select-arrow) {
color: rgba(255, 255, 255, 0.5);
}
&:hover :deep(.ant-select-selector) {
border-color: rgba(255, 255, 255, 0.25) !important;
}
&.ant-select-focused :deep(.ant-select-selector) {
border-color: $primary !important;
box-shadow: none !important;
}
}
.download-btn {
height: 38px;
padding: 0 18px;
border-radius: 8px;
font-weight: 500;
font-size: 13px;
background: $gradient-primary !important;
border: none !important;
box-shadow: 0 2px 8px rgba($primary, 0.3);
&:hover {
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba($primary, 0.4);
}
}
}
2026-01-13 16:41:12 +08:00
// 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;
2026-01-09 18:14:35 +08:00
}
}
}
2026-01-16 11:07:56 +08:00
// ==========================================
// Bottom Controls (底部控制栏容器)
// ==========================================
.bottom-controls {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
z-index: 15;
}
// ==========================================
// Model Navigation Buttons (左右箭头)
// ==========================================
.model-nav-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(40, 40, 50, 0.9);
backdrop-filter: blur(10px);
border: none;
border-radius: 50%;
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: rgba(60, 60, 70, 0.95);
color: #fff;
transform: scale(1.05);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
// ==========================================
// Render Mode Bar (渲染模式选择器)
// ==========================================
.render-mode-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(40, 40, 50, 0.9);
backdrop-filter: blur(20px);
border-radius: 30px;
}
.mode-item {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
transform: scale(1.1);
&::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
font-size: 11px;
white-space: nowrap;
border-radius: 4px;
margin-bottom: 8px;
pointer-events: none;
}
}
&.active {
.mode-icon {
box-shadow: 0 0 0 2px rgba($primary, 0.6);
}
.auto-rotate-icon {
background: rgba($primary, 0.3);
}
}
.mode-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.auto-rotate-icon {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
&:hover {
background: rgba(255, 255, 255, 0.15);
}
}
}
2026-01-09 18:14:35 +08:00
</style>