Files
YuShiSheJiShi/frontend/src/components/DesignPreview.vue
bb84747917 feat(ai): 升级AI生图模型及多视角一致性支持
- 将默认AI生图模型升级为flux-dev及seedream-5.0版本
- SiliconFlow模型由FLUX.1-dev切换为Kolors,优化调用参数和返回值
- 火山引擎Seedream升级至5.0 lite版本,支持多视角参考图传入
- 设计图片字段由字符串改为Text扩展URL长度限制
- 设计图下载支持远程URL重定向和本地文件兼容
- 生成AI图片时多视角保持风格一致,SiliconFlow复用seed,Seedream传参考图
- 后台配置界面更改模型名称及价格显示,新增API Key状态检测
- 前端照片下载从链接改为按钮,远程文件新窗口打开
- 设计相关接口支持较长请求超时,下载走API路径无/api前缀
- 前端页面兼容驼峰与下划线格式URL参数识别
- 用户中心设计图下载支持本地文件Token授权下载
- 初始化数据库新增完整表结构与约束,适配新版设计业务逻辑
2026-03-27 17:39:01 +08:00

507 lines
11 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>
<!-- 设计信息 -->
<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>
<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 } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { Design } from '@/stores/design'
import { getDesignDownloadUrl } from '@/api/design'
import request from '@/api/request'
const props = defineProps<{
design: Design
}>()
const router = useRouter()
// 当前视角索引
const activeViewIndex = ref(0)
// 缩放比例
const scale = ref(1)
// 是否有多视角图片
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 ''
})
// 获取图片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
}
// 跳转到用户中心
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;
}
}
</style>