Files
YuShiSheJiShi/frontend/src/components/DesignPreview.vue
1d94ec114a feat(video): 集成可灵AI多图参考生视频生成服务
- 替换视频生成服务为可灵AI多图参考生视频API,支持1-4张多视角图片输入
- 调整图片拼接逻辑,生成横向长图传入即梦API备用
- 实现基于JWT认证的可灵API请求和轮询机制,支持高品质1:1正方形视频生成
- 在设计详情页新增视频展示区域及生成、重新生成和下载视频操作
- 更新后台系统配置,支持配置可灵AI Access Key和Secret Key
- 删除即梦视频相关配置及逻辑,所有视频生成功能切换到可灵AI实现
- 优化视频生成提示词,提升视频质感和展示效果
- 增加视频文件本地存储和路径管理,保证视频可访问和下载
- 前端增加视频生成状态管理和用户界面交互提示
- 后端添加PyJWT依赖,支持JWT认证流程
2026-03-28 00:20:48 +08:00

1004 lines
25 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="design-preview">
<!-- 视角 Tab 多图时显示 -->
<div class="view-tabs" v-if="hasMultipleViews">
<button
v-for="(img, idx) in design.images"
:key="img.id"
class="view-tab"
:class="{ active: activeViewIndex === idx }"
@click="activeViewIndex = idx"
>
{{ img.view_name }}
</button>
</div>
<!-- 图片预览区 -->
<div class="preview-container">
<div class="image-wrapper" :style="{ transform: `scale(${scale})` }">
<el-image
:src="currentImageUrl"
:alt="design.prompt"
fit="contain"
:preview-src-list="allImageUrls"
:initial-index="activeViewIndex"
preview-teleported
class="design-image"
>
<template #placeholder>
<div class="image-placeholder">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>加载中...</span>
</div>
</template>
<template #error>
<div class="image-error">
<el-icon><PictureFilled /></el-icon>
<span>图片加载失败</span>
</div>
</template>
</el-image>
</div>
<!-- 视角指示器多图时显示 -->
<div class="view-indicator" v-if="hasMultipleViews">
<span class="indicator-text">{{ activeViewName }} ({{ activeViewIndex + 1 }}/{{ design.images.length }})</span>
</div>
<!-- 缩放控制 -->
<div class="zoom-controls">
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
<el-icon><ZoomOut /></el-icon>
</button>
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
<button class="zoom-btn" @click="zoomIn" :disabled="scale >= 2">
<el-icon><ZoomIn /></el-icon>
</button>
<button class="zoom-btn reset-btn" @click="resetZoom" v-if="scale !== 1">
<el-icon><RefreshRight /></el-icon>
</button>
</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="video-section" v-if="design.video_url || generatingVideo">
<h4 class="section-title">展示视频</h4>
<div v-if="generatingVideo" class="generating-state">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>正在生成展示视频预计需要 2-5 分钟...</span>
</div>
<div v-else-if="design.video_url" class="video-wrapper">
<video
:key="design.video_url"
controls
preload="auto"
crossorigin="anonymous"
class="preview-video"
>
<source :src="design.video_url" type="video/mp4" />
您的浏览器不支持视频播放
</video>
<div class="video-actions">
<button class="model3d-action-btn" @click="handleDownloadVideo">
<el-icon><Download /></el-icon>
<span>下载视频</span>
</button>
<button class="model3d-action-btn" @click="handleRegenVideo">
<el-icon><RefreshRight /></el-icon>
<span>重新生成</span>
</button>
</div>
</div>
</div>
<div class="design-info">
<h4 class="info-title">设计详情</h4>
<div class="info-grid">
<div class="info-item">
<span class="info-label">品类</span>
<span class="info-value">{{ design.category?.name || '-' }}</span>
</div>
<div class="info-item" v-if="design.sub_type">
<span class="info-label">类型</span>
<span class="info-value">{{ design.sub_type.name }}</span>
</div>
<div class="info-item" v-if="design.color">
<span class="info-label">颜色</span>
<span class="info-value">{{ design.color.name }}</span>
</div>
<div class="info-item full-width">
<span class="info-label">设计需求</span>
<span class="info-value prompt">{{ design.prompt }}</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button
class="action-btn download-btn"
@click="handleDownload"
:disabled="downloading"
>
<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
v-if="!design.video_url && !generatingVideo"
class="action-btn video-btn"
@click="handleGenerateVideo"
>
<el-icon><VideoCameraFilled /></el-icon>
<span>生成展示视频</span>
</button>
<button class="action-btn secondary-btn" @click="goToUserCenter">
<el-icon><User /></el-icon>
<span>查看我的设计</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
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, generate3DModelApi, generateVideoApi } from '@/api/design'
import request from '@/api/request'
const props = defineProps<{
design: Design
}>()
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 generatingVideo = ref(false)
// 是否有多视角图片
const hasMultipleViews = computed(() => {
return props.design.images && props.design.images.length > 1
})
// 当前视角名称
const activeViewName = computed(() => {
if (props.design.images && props.design.images.length > 0) {
return props.design.images[activeViewIndex.value]?.view_name || ''
}
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
}
}
// 生成展示视频
const handleGenerateVideo = async () => {
generatingVideo.value = true
try {
const updated = await generateVideoApi(props.design.id)
if (updated.video_url) {
props.design.video_url = updated.video_url
}
ElMessage.success('展示视频生成成功!')
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.detail || '视频生成失败,请重试')
} finally {
generatingVideo.value = false
}
}
// 重新生成展示视频
const handleRegenVideo = async () => {
generatingVideo.value = true
try {
const updated = await generateVideoApi(props.design.id, true)
if (updated.video_url) {
props.design.video_url = updated.video_url
}
ElMessage.success('展示视频重新生成成功!')
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '视频重新生成失败')
} finally {
generatingVideo.value = false
}
}
// 下载展示视频
const handleDownloadVideo = async () => {
const videoUrl = props.design.video_url
if (!videoUrl) return
try {
const res = await fetch(videoUrl)
if (!res.ok) throw new Error('下载失败')
const blob = await res.blob()
const category = props.design.category?.name || '设计'
const subType = props.design.sub_type?.name || ''
const filename = `${category}${subType ? '-' + subType : ''}-展示视频-${props.design.id}.mp4`
_downloadBlob(blob, filename)
ElMessage.success('视频下载成功')
} catch {
ElMessage.error('视频下载失败')
}
}
// 获取图片URL
const toImageUrl = (url: string | null): string => {
if (!url) return ''
if (url.startsWith('http')) return url
// /uploads 路径已由 Vite 代理到后端,不需要加 /api 前缀
if (url.startsWith('/uploads')) return url
return url
}
// 当前显示的图片URL
const currentImageUrl = computed(() => {
// 优先用多视角图片
if (props.design.images && props.design.images.length > 0) {
return toImageUrl(props.design.images[activeViewIndex.value]?.image_url)
}
// 兼容旧数据,使用单图
return toImageUrl(props.design.image_url)
})
// 所有图片URL用于大图预览
const allImageUrls = computed(() => {
if (props.design.images && props.design.images.length > 0) {
return props.design.images.map(img => toImageUrl(img.image_url))
}
return [toImageUrl(props.design.image_url)]
})
// 下载状态
const downloading = ref(false)
// 下载文件名
const downloadFilename = computed(() => {
const category = props.design.category?.name || '设计'
const subType = props.design.sub_type?.name || ''
const viewSuffix = hasMultipleViews.value ? `-${activeViewName.value}` : ''
return `${category}${subType ? '-' + subType : ''}${viewSuffix}-${props.design.id}.png`
})
// 下载设计图
const handleDownload = () => {
const imgUrl = currentImageUrl.value
if (!imgUrl) {
ElMessage.error('图片不存在')
return
}
// 远程 URL 直接新窗口打开(用户右键可保存)
if (imgUrl.startsWith('http')) {
window.open(imgUrl, '_blank')
return
}
// 本地文件通过 axios 携带 Token 下载
downloading.value = true
request.get(getDesignDownloadUrl(props.design.id), {
responseType: 'blob'
}).then((response: any) => {
const blob = new Blob([response], { type: 'image/png' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = downloadFilename.value
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载成功')
}).catch(() => {
ElMessage.error('下载失败,请重试')
}).finally(() => {
downloading.value = false
})
}
// 切换视角时重置缩放
watch(activeViewIndex, () => {
scale.value = 1
})
// 放大
const zoomIn = () => {
if (scale.value < 2) {
scale.value = Math.min(2, scale.value + 0.25)
}
}
// 缩小
const zoomOut = () => {
if (scale.value > 0.5) {
scale.value = Math.max(0.5, scale.value - 0.25)
}
}
// 重置缩放
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('设计已自动保存到您的设计历史中')
router.push('/user')
}
</script>
<style scoped lang="scss">
$primary-color: #5B7E6B;
$primary-light: #8BAF9C;
$secondary-color: #C4A86C;
$bg-color: #FAF8F5;
$bg-dark: #F0EDE8;
$border-color: #E8E4DF;
$text-primary: #2C2C2C;
$text-secondary: #6B6B6B;
$text-light: #999999;
.design-preview {
display: flex;
flex-direction: column;
gap: 24px;
}
.view-tabs {
display: flex;
gap: 8px;
padding: 4px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.view-tab {
flex: 1;
padding: 10px 16px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
transition: all 0.25s ease;
letter-spacing: 1px;
&:hover {
color: $primary-color;
background: rgba($primary-color, 0.05);
}
&.active {
background: $primary-color;
color: #fff;
border-color: $primary-color;
font-weight: 500;
}
}
.view-indicator {
position: absolute;
top: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.5);
padding: 4px 12px;
border-radius: 12px;
}
.indicator-text {
font-size: 12px;
color: #fff;
}
.preview-container {
position: relative;
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.image-wrapper {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
max-height: 500px;
transition: transform 0.3s ease;
transform-origin: center center;
}
.design-image {
max-width: 100%;
max-height: 450px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
cursor: zoom-in;
:deep(img) {
max-width: 100%;
max-height: 450px;
object-fit: contain;
}
}
.image-placeholder,
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 300px;
height: 300px;
background: $bg-color;
border-radius: 8px;
color: $text-light;
.el-icon {
font-size: 48px;
}
}
.loading-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.zoom-controls {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.95);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid $border-color;
border-radius: 6px;
cursor: pointer;
color: $text-secondary;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: $primary-color;
border-color: $primary-color;
color: #fff;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.zoom-level {
min-width: 48px;
text-align: center;
font-size: 13px;
color: $text-secondary;
}
.design-info {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.info-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;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
&.full-width {
grid-column: 1 / -1;
}
}
.info-label {
font-size: 12px;
color: $text-light;
letter-spacing: 0.5px;
}
.info-value {
font-size: 14px;
color: $text-primary;
&.prompt {
line-height: 1.6;
color: $text-secondary;
}
}
.action-buttons {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 28px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: all 0.25s ease;
text-decoration: none;
letter-spacing: 1px;
.el-icon {
font-size: 18px;
}
}
.download-btn {
background: $primary-color;
color: #fff;
border: none;
&:hover {
background: #4a6a5a;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba($primary-color, 0.3);
}
}
.secondary-btn {
background: #fff;
color: $primary-color;
border: 1px solid $primary-color;
&:hover {
background: $primary-color;
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;
}
// 视频区域样式
.video-section {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.video-wrapper {
border-radius: 8px;
overflow: hidden;
background: #000;
}
.preview-video {
width: 100%;
max-height: 500px;
display: block;
}
.video-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 0 0;
background: #fff;
}
.video-btn {
background: linear-gradient(135deg, #e040fb 0%, #7c4dff 100%) !important;
color: #fff !important;
border: none !important;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
</style>