修改3D模型页面
This commit is contained in:
parent
252ba92266
commit
ffd1d6bbe5
@ -1076,6 +1076,7 @@ model AI3DTask {
|
||||
userId Int @map("user_id") /// 用户ID(任务归属用户)
|
||||
inputType String @map("input_type") /// 输入类型:text | image
|
||||
inputContent String @map("input_content") @db.Text /// 输入内容:文字描述或图片URL
|
||||
generateType String @default("Normal") @map("generate_type") /// 生成类型:Normal(带纹理) | Geometry(白模) | LowPoly(低多边形) | Sketch(草图)
|
||||
status String @default("pending") /// 任务状态:pending | processing | completed | failed | timeout
|
||||
resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL(单个结果,兼容旧数据)
|
||||
previewUrl String? @map("preview_url") @db.Text /// 预览图URL(单个结果,兼容旧数据)
|
||||
|
||||
@ -55,6 +55,7 @@ export class AI3DService {
|
||||
tenantId,
|
||||
inputType: dto.inputType,
|
||||
inputContent: dto.inputContent,
|
||||
generateType: dto.generateType || 'Normal',
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
@ -85,19 +85,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Settings Panel (右侧) -->
|
||||
<div v-if="!loading && !error" class="scene-settings">
|
||||
<div
|
||||
class="settings-header"
|
||||
@click="settingsPanelOpen = !settingsPanelOpen"
|
||||
>
|
||||
<span class="settings-title"> <SettingOutlined /> 场景设置 </span>
|
||||
<span class="collapse-icon" :class="{ 'is-open': settingsPanelOpen }">
|
||||
<RightOutlined />
|
||||
</span>
|
||||
</div>
|
||||
<!-- 右侧面板容器 -->
|
||||
<div v-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 v-show="settingsPanelOpen" class="settings-body">
|
||||
<!-- 背景设置 -->
|
||||
<div class="settings-group">
|
||||
<div class="group-title">背景</div>
|
||||
@ -326,11 +328,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<div v-show="settingsPanelOpen" class="settings-footer">
|
||||
<a-button size="small" class="reset-btn" @click="resetSettings"
|
||||
>重置默认</a-button
|
||||
<!-- 重置按钮 -->
|
||||
<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>
|
||||
|
||||
@ -342,6 +371,57 @@
|
||||
<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>
|
||||
@ -356,7 +436,11 @@ import {
|
||||
FullscreenExitOutlined,
|
||||
SettingOutlined,
|
||||
RightOutlined,
|
||||
LeftOutlined,
|
||||
AppstoreOutlined,
|
||||
DownloadOutlined,
|
||||
LoadingOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
|
||||
// @ts-ignore
|
||||
@ -371,6 +455,13 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
|
||||
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()
|
||||
@ -384,6 +475,166 @@ const modelInfo = ref<{ size: string; vertices: string; faces: string } | 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",
|
||||
@ -433,30 +684,156 @@ let fillLight: THREE.DirectionalLight | null = null
|
||||
let spotLight: THREE.SpotLight | null = null
|
||||
let gridHelper: THREE.GridHelper | null = null
|
||||
|
||||
// 获取模型 URL - 支持刷新页面保持状态
|
||||
// 获取模型 URL - 支持刷新页面保持状态,支持多模型
|
||||
const SESSION_KEY = "model-viewer-url"
|
||||
const getModelUrl = (): string => {
|
||||
// 优先从 query 获取
|
||||
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
|
||||
if (queryUrl) {
|
||||
// 保存到 sessionStorage,以便刷新页面时恢复
|
||||
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)
|
||||
return 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]
|
||||
}
|
||||
}
|
||||
// 如果 query 没有,尝试从 sessionStorage 恢复
|
||||
const storedUrl = sessionStorage.getItem(SESSION_KEY)
|
||||
if (storedUrl) {
|
||||
return storedUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
const modelUrl = ref(getModelUrl())
|
||||
console.log("模型查看器 - URL:", modelUrl.value)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换渲染模式
|
||||
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,防止下次从其他入口进入时使用旧的 URL
|
||||
// 清除 sessionStorage 中的 URL 和索引,防止下次从其他入口进入时使用旧数据
|
||||
sessionStorage.removeItem(SESSION_KEY)
|
||||
sessionStorage.removeItem(SESSION_KEY_URLS)
|
||||
sessionStorage.removeItem(SESSION_KEY_INDEX)
|
||||
// 检查是否有历史记录可以返回
|
||||
// 如果是从新窗口打开的(无历史),则关闭窗口
|
||||
if (window.history.length <= 1) {
|
||||
@ -647,6 +1024,9 @@ const initScene = () => {
|
||||
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)
|
||||
@ -1077,6 +1457,9 @@ const disposeScene = () => {
|
||||
mainLight = null
|
||||
fillLight = null
|
||||
gridHelper = null
|
||||
|
||||
// 清理材质缓存
|
||||
originalMaterials.clear()
|
||||
}
|
||||
|
||||
// 重试
|
||||
@ -1089,6 +1472,10 @@ onMounted(() => {
|
||||
window.addEventListener("resize", handleResize)
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange)
|
||||
|
||||
// 初始化多模型URL
|
||||
initModelUrls()
|
||||
updateCurrentModelUrl()
|
||||
|
||||
initScene()
|
||||
loadModel()
|
||||
})
|
||||
@ -1526,22 +1913,32 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Scene Settings (Right Side)
|
||||
// Right Panels Container
|
||||
// ==========================================
|
||||
.scene-settings {
|
||||
.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;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
@ -1720,6 +2117,67 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 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 {
|
||||
@ -1790,4 +2248,118 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 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>
|
||||
|
||||
@ -262,11 +262,30 @@ const handleCardClick = (index: number) => {
|
||||
}
|
||||
|
||||
if (card.status === "completed" && card.resultUrl) {
|
||||
// Navigate to model viewer (使用路由导航,保持全屏模式)
|
||||
// 收集所有有效的模型URLs
|
||||
const allResultUrls = modelCards.value
|
||||
.filter((c) => c.status === "completed" && c.resultUrl)
|
||||
.map((c) => c.resultUrl)
|
||||
|
||||
// 存储到 sessionStorage(避免URL过长)
|
||||
if (allResultUrls.length > 1) {
|
||||
sessionStorage.setItem(
|
||||
"model-viewer-urls",
|
||||
JSON.stringify(allResultUrls)
|
||||
)
|
||||
sessionStorage.setItem("model-viewer-index", String(index))
|
||||
// 清除单URL存储
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", card.resultUrl)
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
// Navigate to model viewer
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: card.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,11 +294,23 @@ const handleViewTask = (task: AI3DTask) => {
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview = (task: AI3DTask) => {
|
||||
if (task.resultUrl) {
|
||||
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const urls = task.resultUrls || [task.resultUrl]
|
||||
|
||||
// 存储到 sessionStorage(支持多模型)
|
||||
if (urls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls))
|
||||
sessionStorage.setItem("model-viewer-index", "0")
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", urls[0] || "")
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: task.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -612,11 +612,23 @@ const stopPolling = () => {
|
||||
|
||||
// 预览3D模型
|
||||
const handlePreview = (task: AI3DTask) => {
|
||||
if (task.resultUrl) {
|
||||
if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
|
||||
const tenantCode = route.params.tenantCode as string
|
||||
const urls = task.resultUrls || [task.resultUrl]
|
||||
|
||||
// 存储到 sessionStorage(支持多模型)
|
||||
if (urls.length > 1) {
|
||||
sessionStorage.setItem("model-viewer-urls", JSON.stringify(urls))
|
||||
sessionStorage.setItem("model-viewer-index", "0")
|
||||
sessionStorage.removeItem("model-viewer-url")
|
||||
} else {
|
||||
sessionStorage.setItem("model-viewer-url", urls[0] || "")
|
||||
sessionStorage.removeItem("model-viewer-urls")
|
||||
sessionStorage.removeItem("model-viewer-index")
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/${tenantCode}/workbench/model-viewer`,
|
||||
query: { url: task.resultUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user