feat(design): 添加360视频和3D模型生成功能支持

- 在Design模型中新增video_url字段用于存储360度展示视频URL
- 在DesignImage模型中新增model_3d_url字段用于存储3D模型URL
- 设计路由新增生成视频接口,调用火山引擎即梦3.0 Pro API生成展示视频
- 设计路由新增生成3D模型接口,调用腾讯混元3D服务生成.glb格式3D模型
- 新增本地文件删除工具,支持强制重新生成时清理旧文件
- 设计响应Schema中添加video_url和model_3d_url字段支持前后端数据传递
- 前端设计详情页新增360度旋转3D模型展示区,支持生成、重新生成和下载3D模型
- 实现录制3D模型展示视频功能,支持捕获model-viewer旋转画面逐帧合成WebM文件下载
- 引入@google/model-viewer库作为3D模型Web组件展示支持
- 管理后台新增即梦视频生成和腾讯混元3D模型生成配置界面,方便服务密钥管理
- 前端API增加生成视频和生成3D模型接口请求方法,超时设置为10分钟以支持长时间处理
- 优化UI交互提示,新增生成中状态显示和错误提示,提升用户体验和操作反馈
This commit is contained in:
2026-03-27 23:26:56 +08:00
parent a1f56b1f8e
commit 8f5a86418e
17 changed files with 1517 additions and 6 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@google/model-viewer": "^4.2.0",
"axios": "^1.13.6",
"element-plus": "^2.13.6",
"pinia": "^3.0.4",
@@ -148,12 +149,55 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@google/model-viewer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.2.0.tgz",
"integrity": "sha512-RjpAI5cLs9CdvPcMRsOs8Bea/lNmGTTyaPyl16o9Fv6Qn8VSpgBMmXFr/11yb0hTrsojp2dOACEcY77R8hVUVA==",
"license": "Apache-2.0",
"dependencies": {
"@monogrid/gainmap-js": "^3.1.0",
"lit": "^3.2.1"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"three": "^0.182.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
"integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0"
}
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -800,6 +844,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -1457,6 +1507,12 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -1489,6 +1545,12 @@
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -1501,6 +1563,15 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1762,6 +1833,37 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lit": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.5.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
"integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -1956,6 +2058,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2074,6 +2186,13 @@
"node": ">=16"
}
},
"node_modules/three": {
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT",
"peer": true
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@google/model-viewer": "^4.2.0",
"axios": "^1.13.6",
"element-plus": "^2.13.6",
"pinia": "^3.0.4",

View File

@@ -18,6 +18,7 @@ export interface DesignImage {
model_used: string | null
prompt_used: string | null
sort_order: number
model_3d_url: string | null
}
export interface Design {
@@ -34,6 +35,7 @@ export interface Design {
surface_finish: string | null
usage_scene: string | null
image_url: string | null
video_url: string | null
images: DesignImage[]
status: string
created_at: string
@@ -84,3 +86,15 @@ export function deleteDesignApi(id: number) {
export function getDesignDownloadUrl(id: number) {
return `/designs/${id}/download`
}
// 生成 360 度展示视频
export function generateVideoApi(id: number, force: boolean = false) {
const query = force ? '?force=true' : ''
return request.post<any, Design>(`/designs/${id}/generate-video${query}`, {}, { timeout: 600000 })
}
// 生成 3D 模型
export function generate3DModelApi(id: number, force: boolean = false) {
const query = force ? '?force=true' : ''
return request.post<any, Design>(`/designs/${id}/generate-3d${query}`, {}, { timeout: 600000 })
}

View File

@@ -60,7 +60,44 @@
</div>
</div>
<!-- 设计信息 -->
<!-- 360° 旋转展示区基于3D模型 -->
<div class="model3d-section" v-if="has3DModel || generating3D">
<h4 class="section-title">360° 旋转展示</h4>
<div v-if="generating3D" class="generating-state">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>正在生成 3D 模型请稍候...</span>
</div>
<div v-else-if="current3DModelUrl" class="model-viewer-wrapper">
<model-viewer
ref="modelViewerRef"
:src="current3DModelUrl"
alt="玉雕 3D 模型"
auto-rotate
auto-rotate-delay="0"
rotation-per-second="36deg"
camera-controls
shadow-intensity="1"
environment-image="neutral"
exposure="1.2"
style="width: 100%; height: 500px;"
/>
</div>
<!-- 3D 模型已生成时显示操作按钮 -->
<div v-if="current3DModelUrl && !generating3D" class="model3d-actions">
<button class="model3d-action-btn" @click="handleDownload3DModel">
<el-icon><Download /></el-icon>
<span>下载3D模型</span>
</button>
<button class="model3d-action-btn" @click="handleRecordVideo" :disabled="recording">
<el-icon><VideoCameraFilled /></el-icon>
<span>{{ recording ? `录制中... ${recordProgress}%` : '录制展示视频' }}</span>
</button>
<button class="model3d-action-btn" @click="handleRegen3D">
<el-icon><RefreshRight /></el-icon>
<span>重新生成</span>
</button>
</div>
</div>
<div class="design-info">
<h4 class="info-title">设计详情</h4>
<div class="info-grid">
@@ -93,6 +130,16 @@
<el-icon><Download /></el-icon>
<span>{{ downloading ? '下载中...' : '下载设计图' }}</span>
</button>
<!-- 生成 3D 模型按钮 -->
<button
v-if="!has3DModel && !generating3D"
class="action-btn model3d-btn"
@click="handleGenerate3D"
:disabled="generating3D"
>
<el-icon><Platform /></el-icon>
<span>生成3D模型</span>
</button>
<button class="action-btn secondary-btn" @click="goToUserCenter">
<el-icon><User /></el-icon>
<span>查看我的设计</span>
@@ -104,10 +151,10 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue'
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User, Platform, VideoCameraFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Design } from '@/stores/design'
import { getDesignDownloadUrl } from '@/api/design'
import { getDesignDownloadUrl, generate3DModelApi } from '@/api/design'
import request from '@/api/request'
const props = defineProps<{
@@ -116,12 +163,22 @@ const props = defineProps<{
const router = useRouter()
// model-viewer ref
const modelViewerRef = ref<any>(null)
// 录制状态
const recording = ref(false)
const recordProgress = ref(0)
// 当前视角索引
const activeViewIndex = ref(0)
// 缩放比例
const scale = ref(1)
// 3D 模型生成状态
const generating3D = ref(false)
// 是否有多视角图片
const hasMultipleViews = computed(() => {
return props.design.images && props.design.images.length > 1
@@ -135,6 +192,66 @@ const activeViewName = computed(() => {
return ''
})
// 是否有 3D 模型
const has3DModel = computed(() => {
if (props.design.images && props.design.images.length > 0) {
return props.design.images.some(img => !!img.model_3d_url)
}
return false
})
// 当前视角的 3D 模型 URL
const current3DModelUrl = computed(() => {
if (props.design.images && props.design.images.length > 0) {
const img = props.design.images[activeViewIndex.value]
if (img?.model_3d_url) return img.model_3d_url
// fallback 到第一张有 3D 模型的图
const withModel = props.design.images.find(i => !!i.model_3d_url)
return withModel?.model_3d_url || ''
}
return ''
})
// 生成 3D 模型
const handleGenerate3D = async () => {
generating3D.value = true
try {
const updated = await generate3DModelApi(props.design.id)
if (updated.images) {
updated.images.forEach((img: any, idx: number) => {
if (props.design.images[idx]) {
props.design.images[idx].model_3d_url = img.model_3d_url
}
})
}
ElMessage.success('3D 模型生成成功!')
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '3D 模型生成失败,请重试')
} finally {
generating3D.value = false
}
}
// 重新生成 3D 模型
const handleRegen3D = async () => {
generating3D.value = true
try {
const updated = await generate3DModelApi(props.design.id, true)
if (updated.images) {
updated.images.forEach((img: any, idx: number) => {
if (props.design.images[idx]) {
props.design.images[idx].model_3d_url = img.model_3d_url
}
})
}
ElMessage.success('3D 模型重新生成成功!')
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '3D 模型重新生成失败')
} finally {
generating3D.value = false
}
}
// 获取图片URL
const toImageUrl = (url: string | null): string => {
if (!url) return ''
@@ -231,6 +348,168 @@ const resetZoom = () => {
scale.value = 1
}
// 下载 3D 模型 zip 包
const handleDownload3DModel = async () => {
const glbUrl = current3DModelUrl.value
if (!glbUrl) {
ElMessage.error('3D 模型不存在')
return
}
const category = props.design.category?.name || '设计'
const subType = props.design.sub_type?.name || ''
// 先尝试下载 zip 包(同名不同后缀),注意不能用 request会加 /api 前缀)
const zipUrl = glbUrl.replace(/\.glb$/, '.zip')
try {
const zipRes = await fetch(zipUrl)
if (zipRes.ok) {
const blob = await zipRes.blob()
const filename = `${category}${subType ? '-' + subType : ''}-3D模型-${props.design.id}.zip`
_downloadBlob(blob, filename)
ElMessage.success('3D 模型包下载成功')
return
}
} catch {
// zip 不存在,继续尝试 glb
}
// zip 不存在,下载 glb
try {
const glbRes = await fetch(glbUrl)
if (!glbRes.ok) throw new Error('下载失败')
const blob = await glbRes.blob()
const filename = `${category}${subType ? '-' + subType : ''}-3D模型-${props.design.id}.glb`
_downloadBlob(blob, filename)
ElMessage.success('3D 模型下载成功')
} catch {
ElMessage.error('3D 模型下载失败')
}
}
// 通用 Blob 下载工具
const _downloadBlob = (blobData: any, filename: string) => {
const blob = new Blob([blobData])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
// 录制 model-viewer 旋转一圈的展示视频
// 程序化控制旋转角度 + toDataURL 逐帧截图WebGL canvas 无法直接 captureStream/drawImage
const handleRecordVideo = async () => {
const mv = modelViewerRef.value as any
if (!mv) {
ElMessage.error('3D 模型未加载')
return
}
recording.value = true
recordProgress.value = 0
ElMessage.info('正在录制展示视频,请稍候...')
try {
// 获取画布尺寸
const srcCanvas = mv.shadowRoot?.querySelector('canvas') as HTMLCanvasElement
const width = srcCanvas?.width || 800
const height = srcCanvas?.height || 600
// 创建 2D 中转 canvas
const recordCanvas = document.createElement('canvas')
recordCanvas.width = width
recordCanvas.height = height
const ctx = recordCanvas.getContext('2d')!
// 视频参数
const totalFrames = 180 // 6秒 x 30fps
const fps = 30
const stream = recordCanvas.captureStream(fps)
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm'
const recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 5000000
})
const chunks: Blob[] = []
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data)
}
recorder.onstop = () => {
// 恢复自动旋转
mv.setAttribute('auto-rotate', '')
mv.removeAttribute('interaction-prompt')
const blob = new Blob(chunks, { type: mimeType })
if (blob.size < 1000) {
ElMessage.error('视频录制失败,文件为空')
recording.value = false
return
}
const category = props.design.category?.name || '设计'
const subType = props.design.sub_type?.name || ''
const filename = `${category}${subType ? '-' + subType : ''}-360度展示-${props.design.id}.webm`
_downloadBlob(blob, filename)
recording.value = false
recordProgress.value = 0
ElMessage.success('展示视频已下载')
}
// 暂停自动旋转,由程序控制角度
mv.removeAttribute('auto-rotate')
mv.setAttribute('interaction-prompt', 'none')
// 开始录制
recorder.start(100)
// 逐帧旋转 + 截图
for (let frame = 0; frame < totalFrames; frame++) {
const angle = (frame / totalFrames) * 360
// 设置相机轨道位置(水平角度 垂直角度 距离)
mv.cameraOrbit = `${angle}deg 75deg auto`
// 等待渲染
await new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve())
})
})
// 用 model-viewer 的 toDataURL 截图(内部会处理 preserveDrawingBuffer
try {
const dataUrl = mv.toDataURL('image/jpeg', 0.85)
const img = new Image()
img.src = dataUrl
await img.decode()
ctx.drawImage(img, 0, 0, width, height)
} catch {
// 忽略单帧失败
}
recordProgress.value = Math.round(((frame + 1) / totalFrames) * 100)
}
// 录制完成
recorder.stop()
} catch (e: any) {
// 恢复自动旋转
mv.setAttribute('auto-rotate', '')
mv.removeAttribute('interaction-prompt')
ElMessage.error(e.message || '录制视频失败')
recording.value = false
recordProgress.value = 0
}
}
// 跳转到用户中心
const goToUserCenter = () => {
ElMessage.success('设计已自动保存到您的设计历史中')
@@ -503,4 +782,89 @@ $text-light: #999999;
color: #fff;
}
}
.model3d-btn {
background: #8E44AD;
color: #fff;
border: none;
&:hover:not(:disabled) {
background: #7D3C98;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(#8E44AD, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.model3d-section {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: 15px;
font-weight: 500;
color: $text-primary;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid $border-color;
letter-spacing: 1px;
}
.generating-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 0;
color: $text-secondary;
font-size: 14px;
}
.model3d-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 0;
flex-wrap: wrap;
}
.model3d-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
font-size: 12px;
color: #666;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: $primary-color;
border-color: $primary-color;
background: rgba($primary-color, 0.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
color: $secondary-color;
border-color: $secondary-color;
}
}
.model-viewer-wrapper {
border-radius: 8px;
overflow: hidden;
background: $bg-color;
}
</style>

View File

@@ -6,6 +6,9 @@ import App from './App.vue'
import router from './router'
import './assets/styles/theme.scss'
// 注册 model-viewer Web Component用于 3D 模型展示)
import '@google/model-viewer'
const app = createApp(App)
app.use(createPinia())

View File

@@ -97,6 +97,103 @@
</el-form>
</div>
<!-- 即梦视频生成配置卡片 -->
<div class="section-card">
<div class="card-header">
<div class="card-title-row">
<h3 class="section-title">即梦 3.0 Pro 视频生成</h3>
<el-tag :type="volcVideoStatus" size="small">
{{ volcVideoStatusText }}
</el-tag>
</div>
<p class="card-desc">火山引擎即梦 3.0 Pro 图生视频 API将设计图生成 360 度旋转展示视频需要火山引擎 Access Key Secret Key API Key</p>
</div>
<el-form label-width="120px" class="config-form">
<el-form-item label="Access Key">
<el-input
v-model="volcAccessKey"
type="password"
show-password
placeholder="请输入火山引擎 Access Key"
clearable
/>
</el-form-item>
<el-form-item label="Secret Key">
<el-input
v-model="volcSecretKey"
type="password"
show-password
placeholder="请输入火山引擎 Secret Key"
clearable
/>
</el-form-item>
<el-form-item>
<span class="form-tip">获取方式火山引擎控制台 右上角头像 API访问密钥或访问 https://console.volcengine.com/iam/keymanage/</span>
</el-form-item>
<el-form-item label="视频时长">
<el-select v-model="videoFrames" style="width: 200px">
<el-option label="2 秒(快速预览)" value="49" />
<el-option label="5 秒(完整展示)" value="121" />
</el-select>
<span class="form-tip">推荐 5 足够展示完整 360 度旋转</span>
</el-form-item>
<el-form-item label="视频提示词">
<el-input
v-model="videoPrompt"
type="textarea"
:rows="3"
placeholder="玉雕作品在摄影棚内缓慢旋转360度展示全貌..."
/>
<span class="form-tip">用于控制视频生成效果尽情描述旋转展示方式灯光背景等</span>
</el-form-item>
</el-form>
</div>
<!-- 腾讯混元3D 配置卡片 -->
<div class="section-card">
<div class="card-header">
<div class="card-title-row">
<h3 class="section-title">腾讯混元3D 模型生成</h3>
<el-tag :type="hunyuan3dStatus" size="small">
{{ hunyuan3dStatusText }}
</el-tag>
</div>
<p class="card-desc">腾讯混元3D 图生 3D 模型 API将设计图转换为可交互的 3D 模型.glb 格式</p>
</div>
<el-form label-width="120px" class="config-form">
<el-form-item label="SecretId">
<el-input
v-model="tencentSecretId"
type="password"
show-password
placeholder="请输入腾讯云 SecretId"
clearable
/>
</el-form-item>
<el-form-item label="SecretKey">
<el-input
v-model="tencentSecretKey"
type="password"
show-password
placeholder="请输入腾讯云 SecretKey"
clearable
/>
</el-form-item>
<el-form-item>
<span class="form-tip">获取方式访问 <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">https://console.cloud.tencent.com/cam/capi</a> 创建密钥,并在 <a href="https://console.cloud.tencent.com/ai3d" target="_blank">混元3D控制台</a> 开通服务</span>
</el-form-item>
<el-form-item label="3D提示词">
<el-input
v-model="model3dPrompt"
type="textarea"
:rows="3"
placeholder="可选用于控制3D模型生成效果"
/>
<span class="form-tip">可选用于描述3D模型的生成效果要求</span>
</el-form-item>
</el-form>
</div>
<!-- 通用设置 -->
<div class="section-card">
<h3 class="section-title">通用设置</h3>
@@ -133,12 +230,23 @@ const siliconflowKey = ref('')
const siliconflowUrl = ref('https://api.siliconflow.cn/v1')
const volcengineKey = ref('')
const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
const volcAccessKey = ref('')
const volcSecretKey = ref('')
const tencentSecretId = ref('')
const tencentSecretKey = ref('')
const videoPrompt = ref('')
const videoFrames = ref('121')
const model3dPrompt = ref('')
const imageSize = ref('1024')
const saving = ref(false)
// 后端是否已配置 API Key脱敏值也算已配置
const siliconflowConfigured = ref(false)
const volcengineConfigured = ref(false)
const volcAccessKeyConfigured = ref(false)
const volcSecretKeyConfigured = ref(false)
const tencentSecretIdConfigured = ref(false)
const tencentSecretKeyConfigured = ref(false)
// 测试状态
const testingSiliconflow = ref(false)
@@ -151,6 +259,10 @@ const siliconflowStatus = computed(() => (siliconflowKey.value || siliconflowCon
const siliconflowStatusText = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? '已配置' : '未配置')
const volcengineStatus = computed(() => (volcengineKey.value || volcengineConfigured.value) ? 'success' : 'info')
const volcengineStatusText = computed(() => (volcengineKey.value || volcengineConfigured.value) ? '已配置' : '未配置')
const volcVideoStatus = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? 'success' : 'info')
const volcVideoStatusText = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? '已配置' : '未配置')
const hunyuan3dStatus = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? 'success' : 'info')
const hunyuan3dStatusText = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? '已配置' : '未配置')
// 加载配置
const loadConfigs = async () => {
@@ -176,6 +288,28 @@ const loadConfigs = async () => {
volcengineKey.value = map['VOLCENGINE_API_KEY']
}
volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3'
// 即梦视频 Access Key / Secret Key
volcAccessKeyConfigured.value = !!map['VOLC_ACCESS_KEY']
volcSecretKeyConfigured.value = !!map['VOLC_SECRET_KEY']
if (map['VOLC_ACCESS_KEY'] && !map['VOLC_ACCESS_KEY'].includes('****')) {
volcAccessKey.value = map['VOLC_ACCESS_KEY']
}
if (map['VOLC_SECRET_KEY'] && !map['VOLC_SECRET_KEY'].includes('****')) {
volcSecretKey.value = map['VOLC_SECRET_KEY']
}
// 腾讯云 SecretId / SecretKey
tencentSecretIdConfigured.value = !!map['TENCENT_SECRET_ID']
tencentSecretKeyConfigured.value = !!map['TENCENT_SECRET_KEY']
if (map['TENCENT_SECRET_ID'] && !map['TENCENT_SECRET_ID'].includes('****')) {
tencentSecretId.value = map['TENCENT_SECRET_ID']
}
if (map['TENCENT_SECRET_KEY'] && !map['TENCENT_SECRET_KEY'].includes('****')) {
tencentSecretKey.value = map['TENCENT_SECRET_KEY']
}
// 提示词配置
videoPrompt.value = map['VIDEO_PROMPT'] || ''
videoFrames.value = map['VIDEO_FRAMES'] || '121'
model3dPrompt.value = map['MODEL3D_PROMPT'] || ''
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
} catch (e) {
console.error('加载配置失败', e)
@@ -204,6 +338,22 @@ const handleSave = async () => {
if (volcengineKey.value) {
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
}
if (volcAccessKey.value) {
configs['VOLC_ACCESS_KEY'] = volcAccessKey.value
}
if (volcSecretKey.value) {
configs['VOLC_SECRET_KEY'] = volcSecretKey.value
}
if (tencentSecretId.value) {
configs['TENCENT_SECRET_ID'] = tencentSecretId.value
}
if (tencentSecretKey.value) {
configs['TENCENT_SECRET_KEY'] = tencentSecretKey.value
}
// 提示词始终提交(包括空值,允许清空)
configs['VIDEO_PROMPT'] = videoPrompt.value
configs['VIDEO_FRAMES'] = videoFrames.value
configs['MODEL3D_PROMPT'] = model3dPrompt.value
await updateConfigsBatch(configs)
ElMessage.success('配置已保存')
await loadConfigs()

View File

@@ -2,7 +2,16 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
plugins: [
vue({
template: {
compilerOptions: {
// model-viewer 是 Web Component告知 Vue 不要尝试解析它
isCustomElement: (tag) => tag === 'model-viewer',
},
},
}),
],
server: {
port: 3000,
proxy: {