修改3D模型页面

This commit is contained in:
zhangxiaohua 2026-01-16 11:07:56 +08:00
parent 252ba92266
commit ffd1d6bbe5
6 changed files with 657 additions and 40 deletions

View File

@ -1076,6 +1076,7 @@ model AI3DTask {
userId Int @map("user_id") /// 用户ID任务归属用户 userId Int @map("user_id") /// 用户ID任务归属用户
inputType String @map("input_type") /// 输入类型text | image inputType String @map("input_type") /// 输入类型text | image
inputContent String @map("input_content") @db.Text /// 输入内容文字描述或图片URL 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 status String @default("pending") /// 任务状态pending | processing | completed | failed | timeout
resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL单个结果兼容旧数据 resultUrl String? @map("result_url") @db.Text /// 生成的3D模型URL单个结果兼容旧数据
previewUrl String? @map("preview_url") @db.Text /// 预览图URL单个结果兼容旧数据 previewUrl String? @map("preview_url") @db.Text /// 预览图URL单个结果兼容旧数据

View File

@ -55,6 +55,7 @@ export class AI3DService {
tenantId, tenantId,
inputType: dto.inputType, inputType: dto.inputType,
inputContent: dto.inputContent, inputContent: dto.inputContent,
generateType: dto.generateType || 'Normal',
status: 'pending', status: 'pending',
}, },
}); });

View File

@ -85,8 +85,10 @@
</div> </div>
</div> </div>
<!-- Scene Settings Panel (右侧) --> <!-- 右侧面板容器 -->
<div v-if="!loading && !error" class="scene-settings"> <div v-if="!loading && !error" class="right-panels">
<!-- Scene Settings Panel -->
<div class="scene-settings">
<div <div
class="settings-header" class="settings-header"
@click="settingsPanelOpen = !settingsPanelOpen" @click="settingsPanelOpen = !settingsPanelOpen"
@ -334,6 +336,33 @@
</div> </div>
</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 --> <!-- Controls Info -->
<div v-if="!loading && !error" class="controls-info"> <div v-if="!loading && !error" class="controls-info">
<div class="control-hint"> <div class="control-hint">
@ -342,6 +371,57 @@
<span class="hint-item"><span class="key">滚轮</span>缩放</span> <span class="hint-item"><span class="key">滚轮</span>缩放</span>
</div> </div>
</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>
</div> </div>
</template> </template>
@ -356,7 +436,11 @@ import {
FullscreenExitOutlined, FullscreenExitOutlined,
SettingOutlined, SettingOutlined,
RightOutlined, RightOutlined,
LeftOutlined,
AppstoreOutlined, AppstoreOutlined,
DownloadOutlined,
LoadingOutlined,
SyncOutlined,
} from "@ant-design/icons-vue" } from "@ant-design/icons-vue"
// @ts-ignore // @ts-ignore
@ -371,6 +455,13 @@ import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js" import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader.js"
// @ts-ignore // @ts-ignore
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js" 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 router = useRouter()
const route = useRoute() const route = useRoute()
@ -384,6 +475,166 @@ const modelInfo = ref<{ size: string; vertices: string; faces: string } | null>(
) )
const settingsPanelOpen = ref(true) 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 = { const defaultSettings = {
backgroundColor: "#f5f5f5", backgroundColor: "#f5f5f5",
@ -433,30 +684,156 @@ let fillLight: THREE.DirectionalLight | null = null
let spotLight: THREE.SpotLight | null = null let spotLight: THREE.SpotLight | null = null
let gridHelper: THREE.GridHelper | null = null let gridHelper: THREE.GridHelper | null = null
// URL - // URL -
const SESSION_KEY = "model-viewer-url" const SESSION_KEY = "model-viewer-url"
const getModelUrl = (): string => { const SESSION_KEY_URLS = "model-viewer-urls"
// query const SESSION_KEY_INDEX = "model-viewer-index"
const initModelUrls = () => {
// query URL
const queryUrl = route.query.url as string const queryUrl = route.query.url as string
if (queryUrl) { const queryUrls = route.query.urls as string
// sessionStorage便 const queryIndex = route.query.index as string
if (queryUrls) {
// URLquery
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) {
// URLquery
sessionStorage.setItem(SESSION_KEY, queryUrl) sessionStorage.setItem(SESSION_KEY, queryUrl)
return queryUrl modelUrls.value = [queryUrl]
} } else {
// query sessionStorage // sessionStorage
const storedUrls = sessionStorage.getItem(SESSION_KEY_URLS)
const storedUrl = sessionStorage.getItem(SESSION_KEY) const storedUrl = sessionStorage.getItem(SESSION_KEY)
if (storedUrl) { const storedIndex = sessionStorage.getItem(SESSION_KEY_INDEX)
return storedUrl
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]
}
} }
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 = () => { const handleBack = () => {
// sessionStorage URL使 URL // sessionStorage URL 使
sessionStorage.removeItem(SESSION_KEY) sessionStorage.removeItem(SESSION_KEY)
sessionStorage.removeItem(SESSION_KEY_URLS)
sessionStorage.removeItem(SESSION_KEY_INDEX)
// //
// //
if (window.history.length <= 1) { if (window.history.length <= 1) {
@ -647,6 +1024,9 @@ const initScene = () => {
controls.enablePan = true controls.enablePan = true
controls.panSpeed = 1 controls.panSpeed = 1
controls.rotateSpeed = 1 controls.rotateSpeed = 1
//
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = 2.0
// - // -
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6) const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6)
@ -1077,6 +1457,9 @@ const disposeScene = () => {
mainLight = null mainLight = null
fillLight = null fillLight = null
gridHelper = null gridHelper = null
//
originalMaterials.clear()
} }
// //
@ -1089,6 +1472,10 @@ onMounted(() => {
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
document.addEventListener("fullscreenchange", handleFullscreenChange) document.addEventListener("fullscreenchange", handleFullscreenChange)
// URL
initModelUrls()
updateCurrentModelUrl()
initScene() initScene()
loadModel() 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; position: absolute;
top: 84px; top: 84px;
right: 20px; right: 20px;
width: 280px; width: 280px;
max-height: calc(100% - 104px); max-height: calc(100% - 104px);
display: flex;
flex-direction: column;
gap: 20px;
z-index: 10;
}
// ==========================================
// Scene Settings
// ==========================================
.scene-settings {
background: rgba($surface, 0.85); background: rgba($surface, 0.85);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border: 1px solid rgba($primary, 0.3); border: 1px solid rgba($primary, 0.3);
border-radius: 12px; border-radius: 12px;
z-index: 10;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 200px);
} }
.settings-header { .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 // Select
:deep(.ant-select) { :deep(.ant-select) {
.ant-select-selector { .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> </style>

View File

@ -262,11 +262,30 @@ const handleCardClick = (index: number) => {
} }
if (card.status === "completed" && card.resultUrl) { 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)
// sessionStorageURL
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 const tenantCode = route.params.tenantCode as string
router.push({ router.push({
path: `/${tenantCode}/workbench/model-viewer`, path: `/${tenantCode}/workbench/model-viewer`,
query: { url: card.resultUrl },
}) })
} }
} }

View File

@ -294,11 +294,23 @@ const handleViewTask = (task: AI3DTask) => {
// 3D // 3D
const handlePreview = (task: AI3DTask) => { const handlePreview = (task: AI3DTask) => {
if (task.resultUrl) { if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
const tenantCode = route.params.tenantCode as string 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({ router.push({
path: `/${tenantCode}/workbench/model-viewer`, path: `/${tenantCode}/workbench/model-viewer`,
query: { url: task.resultUrl },
}) })
} }
} }

View File

@ -612,11 +612,23 @@ const stopPolling = () => {
// 3D // 3D
const handlePreview = (task: AI3DTask) => { const handlePreview = (task: AI3DTask) => {
if (task.resultUrl) { if (task.resultUrl || (task.resultUrls && task.resultUrls.length > 0)) {
const tenantCode = route.params.tenantCode as string 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({ router.push({
path: `/${tenantCode}/workbench/model-viewer`, path: `/${tenantCode}/workbench/model-viewer`,
query: { url: task.resultUrl },
}) })
} }
} }