library-picturebook-activity/frontend/src/views/model/ModelViewer.vue
aid 418aa57ea8 Day4: 超管端设计优化 + UGC绘本创作社区P0实现
一、超管端设计优化
- 文档管理SOP体系建立,docs目录重组
- 统一用户管理:跨租户全局视角,合并用户管理+公众用户
- 活动监管全模块重构:全部活动(统计卡片+阶段筛选+SuperDetail详情页)、报名数据/作品数据/评审进度(两层合一扁平列表)、成果发布(去Tab+统计+隐藏写操作)
- 菜单精简:移除评委管理/评审规则/通知管理
- Bug修复:租户编辑丢失隐藏菜单、pageSize限制、主色统一

二、UGC绘本创作社区P0
- 数据库:10张新表(user_works/user_work_pages/work_tags等)
- 子女账号独立化:Child升级为独立User,家长切换+独立登录
- 用户作品库:CRUD+发布审核,8个API
- AI创作流程:提交→生成→保存到作品库,4个API
- 作品广场:首页改造为推荐流,标签+搜索+排序
- 内容审核(超管端):作品审核+作品管理+标签管理
- 活动联动:WorkSelector作品选择器
- 布局改造:底部5Tab(发现/创作/活动/作品库/我的)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:20:25 +08:00

2361 lines
62 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>
<!-- 右侧面板容器 -->
<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>
<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>
<!-- 下载区域 - 独立面板在场景设置下方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"
>
{{ 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>
</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 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>
</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,
LeftOutlined,
AppstoreOutlined,
DownloadOutlined,
LoadingOutlined,
SyncOutlined,
} 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"
// @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"
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 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()
// 场景设置默认值
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 SESSION_KEY = "model-viewer-url"
const SESSION_KEY_URLS = "model-viewer-urls"
const SESSION_KEY_INDEX = "model-viewer-index"
const initModelUrls = () => {
// 优先从 query 获取支持多个URL用逗号分隔
const queryUrl = route.query.url as string
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
sessionStorage.setItem(SESSION_KEY, queryUrl)
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]
}
}
}
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"
// 重新加载模型
loadModel()
}
}
// 切换渲染模式
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("模型查看器初始化")
// 返回上一页
const handleBack = () => {
// 清除 sessionStorage 中的 URL 和索引,防止下次从其他入口进入时使用旧数据
sessionStorage.removeItem(SESSION_KEY)
sessionStorage.removeItem(SESSION_KEY_URLS)
sessionStorage.removeItem(SESSION_KEY_INDEX)
// 检查是否有历史记录可以返回
// 如果是从新窗口打开的(无历史),则关闭窗口
if (window.history.length <= 1) {
window.close()
} else {
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)))
// 重置为透明背景
if (scene) {
scene.background = null
}
updateGridVisibility()
updateAmbientLight()
updateMainLight()
updateMainLightPosition()
updateFillLight()
updateSpotLight()
updateRenderSettings()
}
// 初始化场景
const initScene = () => {
if (!containerRef.value) return
// 创建场景 - 使用透明背景,让页面渐变效果显示
scene = new THREE.Scene()
scene.background = null
// 创建相机
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
// 自动旋转
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = 2.0
// 添加半球光 - 更自然的环境光
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)
// 取最大距离并添加适当的边距0.9倍,让模型显示更大更近)
const baseDistance = Math.max(
distanceForHeight,
distanceForWidth,
distanceForDepth
)
const cameraDistance = Math.max(baseDistance * 0.9, maxDim * 0.9, 2)
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
// 清理材质缓存
originalMaterials.clear()
}
// 重试
const handleRetry = () => {
error.value = null
loadModel()
}
onMounted(() => {
window.addEventListener("resize", handleResize)
document.addEventListener("fullscreenchange", handleFullscreenChange)
// 初始化多模型URL
initModelUrls()
updateCurrentModelUrl()
initScene()
loadModel()
})
onUnmounted(() => {
window.removeEventListener("resize", handleResize)
document.removeEventListener("fullscreenchange", handleFullscreenChange)
disposeScene()
})
</script>
<style scoped lang="scss">
// ==========================================
// 蓝色主题色彩方案 - 统一色系
// ==========================================
$primary: #1890ff;
$primary-dark: #0958d9;
$primary-light: #40a9ff;
$secondary: #4096ff;
$accent: #40a9ff;
$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-dark 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 {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 10;
background: transparent;
}
.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 {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 1;
}
.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: 84px;
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;
}
}
}
}
// ==========================================
// Right Panels Container
// ==========================================
.right-panels {
position: absolute;
top: 84px;
right: 20px;
width: 280px;
max-height: calc(100% - 104px);
display: flex;
flex-direction: column;
gap: 20px;
z-index: 10;
}
// ==========================================
// Scene Settings
// ==========================================
.scene-settings {
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;
max-height: calc(100vh - 200px);
}
.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;
}
}
// ==========================================
// 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);
}
}
}
// 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;
}
}
}
// ==========================================
// 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);
}
}
}
</style>