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