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授权下载 - 初始化数据库新增完整表结构与约束,适配新版设计业务逻辑
This commit is contained in:
@@ -7,7 +7,7 @@ export const getDashboard = () => request.get('/admin/dashboard')
|
||||
export const getConfigs = (group?: string) =>
|
||||
request.get('/admin/configs', { params: group ? { group } : {} })
|
||||
|
||||
export const updateConfigs = (configs: Record<string, string>) =>
|
||||
export const updateConfigs = (_configs: Record<string, string>) =>
|
||||
request.post('/admin/configs', null, {
|
||||
// PUT 方法
|
||||
})
|
||||
|
||||
@@ -70,9 +70,9 @@ export function getDesignApi(id: number) {
|
||||
return request.get<any, Design>(`/designs/${id}`)
|
||||
}
|
||||
|
||||
// 生成设计
|
||||
// 生成设计(AI生图需要较长时间,超时设为5分钟)
|
||||
export function generateDesignApi(data: GenerateDesignParams) {
|
||||
return request.post<any, Design>('/designs/generate', data)
|
||||
return request.post<any, Design>('/designs/generate', data, { timeout: 300000 })
|
||||
}
|
||||
|
||||
// 删除设计
|
||||
@@ -80,7 +80,7 @@ export function deleteDesignApi(id: number) {
|
||||
return request.delete(`/designs/${id}`)
|
||||
}
|
||||
|
||||
// 获取设计下载 URL
|
||||
// 获取设计下载 URL(相对于 baseURL /api)
|
||||
export function getDesignDownloadUrl(id: number) {
|
||||
return `/api/designs/${id}/download`
|
||||
return `/designs/${id}/download`
|
||||
}
|
||||
|
||||
@@ -85,14 +85,14 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
:download="downloadFilename"
|
||||
<button
|
||||
class="action-btn download-btn"
|
||||
@click="handleDownload"
|
||||
:disabled="downloading"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>下载设计图</span>
|
||||
</a>
|
||||
<span>{{ downloading ? '下载中...' : '下载设计图' }}</span>
|
||||
</button>
|
||||
<button class="action-btn secondary-btn" @click="goToUserCenter">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>查看我的设计</span>
|
||||
@@ -108,6 +108,7 @@ import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User }
|
||||
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
|
||||
@@ -134,11 +135,13 @@ const activeViewName = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// 获取图片URL(添加API前缀)
|
||||
// 获取图片URL
|
||||
const toImageUrl = (url: string | null): string => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http')) return url
|
||||
return `/api${url}`
|
||||
// /uploads 路径已由 Vite 代理到后端,不需要加 /api 前缀
|
||||
if (url.startsWith('/uploads')) return url
|
||||
return url
|
||||
}
|
||||
|
||||
// 当前显示的图片URL
|
||||
@@ -159,8 +162,8 @@ const allImageUrls = computed(() => {
|
||||
return [toImageUrl(props.design.image_url)]
|
||||
})
|
||||
|
||||
// 下载URL
|
||||
const downloadUrl = computed(() => getDesignDownloadUrl(props.design.id))
|
||||
// 下载状态
|
||||
const downloading = ref(false)
|
||||
|
||||
// 下载文件名
|
||||
const downloadFilename = computed(() => {
|
||||
@@ -170,6 +173,40 @@ const downloadFilename = computed(() => {
|
||||
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
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</header>
|
||||
|
||||
<!-- 缺少参数错误提示 -->
|
||||
<div v-if="!categoryId" class="error-state">
|
||||
<div v-if="!categoryId && !designId" class="error-state">
|
||||
<div class="error-icon">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
</div>
|
||||
@@ -243,42 +243,46 @@ const router = useRouter()
|
||||
const designStore = useDesignStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
// URL参数
|
||||
// URL参数(兼容驼峰和下划线格式)
|
||||
const designId = computed(() => {
|
||||
const id = route.query.design_id || route.query.designId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
const categoryId = computed(() => {
|
||||
const id = route.query.categoryId
|
||||
const id = route.query.category_id || route.query.categoryId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
const subTypeId = computed(() => {
|
||||
const id = route.query.subTypeId
|
||||
const id = route.query.sub_type_id || route.query.subTypeId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
const colorId = computed(() => {
|
||||
const id = route.query.colorId
|
||||
const id = route.query.color_id || route.query.colorId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
|
||||
// 名称(从store缓存获取)
|
||||
// 名称(从 store 缓存或已加载的设计中获取)
|
||||
const categoryName = computed(() => {
|
||||
// 优先从已加载的设计中获取
|
||||
if (currentDesign.value?.category?.name) return currentDesign.value.category.name
|
||||
if (!categoryId.value) return ''
|
||||
// 优先从 currentCategory 获取
|
||||
if (categoryStore.currentCategory?.id === categoryId.value) {
|
||||
return categoryStore.currentCategory.name
|
||||
}
|
||||
// 否则从列表中查找
|
||||
const cat = categoryStore.categories.find(c => c.id === categoryId.value)
|
||||
return cat?.name || '设计'
|
||||
})
|
||||
|
||||
const subTypeName = computed(() => {
|
||||
if (currentDesign.value?.sub_type?.name) return currentDesign.value.sub_type.name
|
||||
if (!subTypeId.value) return ''
|
||||
// 从 store 的 subTypes 中查找
|
||||
const st = categoryStore.subTypes.find(s => s.id === subTypeId.value)
|
||||
return st?.name || ''
|
||||
})
|
||||
|
||||
const colorName = computed(() => {
|
||||
if (currentDesign.value?.color?.name) return currentDesign.value.color.name
|
||||
if (!colorId.value) return ''
|
||||
// 从 store 的 colors 中查找
|
||||
const c = categoryStore.colors.find(col => col.id === colorId.value)
|
||||
return c?.name || ''
|
||||
})
|
||||
@@ -399,13 +403,26 @@ const handleRegenerate = () => {
|
||||
|
||||
// 页面挂载时,确保有品类数据
|
||||
onMounted(async () => {
|
||||
// 清除之前的设计状态
|
||||
designStore.clearCurrentDesign()
|
||||
|
||||
// 如果 store 中没有品类数据,尝试加载
|
||||
if (categoryStore.categories.length === 0) {
|
||||
await categoryStore.fetchCategories()
|
||||
}
|
||||
|
||||
// 如果有 design_id,直接加载已有设计并显示预览
|
||||
if (designId.value) {
|
||||
try {
|
||||
await designStore.fetchDesign(designId.value)
|
||||
// 填充 prompt
|
||||
if (currentDesign.value?.prompt) {
|
||||
prompt.value = currentDesign.value.prompt
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载设计详情失败')
|
||||
}
|
||||
} else {
|
||||
// 清除之前的设计状态
|
||||
designStore.clearCurrentDesign()
|
||||
}
|
||||
})
|
||||
|
||||
// 离开页面时清理状态
|
||||
|
||||
@@ -174,6 +174,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { useDesignStore, type Design } from '@/stores/design'
|
||||
import { updateProfileApi, changePasswordApi } from '@/api/auth'
|
||||
import { getDesignDownloadUrl } from '@/api/design'
|
||||
import request from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -282,18 +283,34 @@ const handleCardClick = (design: Design) => {
|
||||
}
|
||||
|
||||
// Handle download
|
||||
const handleDownload = (design: Design) => {
|
||||
const handleDownload = async (design: Design) => {
|
||||
if (!design.image_url) {
|
||||
ElMessage.warning('暂无可下载的图片')
|
||||
return
|
||||
}
|
||||
const link = document.createElement('a')
|
||||
link.href = getDesignDownloadUrl(design.id)
|
||||
link.download = `design_${design.id}.png`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
// 远程 URL 直接新窗口打开
|
||||
const imgUrl = design.image_url
|
||||
if (imgUrl.startsWith('http')) {
|
||||
window.open(imgUrl, '_blank')
|
||||
return
|
||||
}
|
||||
// 本地文件通过 axios 携带 Token 下载
|
||||
try {
|
||||
const response = await request.get(getDesignDownloadUrl(design.id), {
|
||||
responseType: 'blob'
|
||||
}) as 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 = `design_${design.id}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
ElMessage.error('下载失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
@click="setDefaultModel('flux-dev')"
|
||||
>
|
||||
<div class="model-badge">默认</div>
|
||||
<div class="model-name">SiliconFlow FLUX.1 [dev]</div>
|
||||
<div class="model-price">~0.13 元/张</div>
|
||||
<div class="model-name">SiliconFlow Kolors</div>
|
||||
<div class="model-price">~0.04 元/张</div>
|
||||
<div class="model-tag">性价比高</div>
|
||||
</div>
|
||||
<div
|
||||
class="model-option"
|
||||
:class="{ active: defaultModel === 'seedream-4.5' }"
|
||||
@click="setDefaultModel('seedream-4.5')"
|
||||
:class="{ active: defaultModel === 'seedream-5.0' }"
|
||||
@click="setDefaultModel('seedream-5.0')"
|
||||
>
|
||||
<div class="model-badge">备选</div>
|
||||
<div class="model-name">火山引擎 Seedream 4.5</div>
|
||||
<div class="model-price">~0.30 元/张</div>
|
||||
<div class="model-name">火山引擎 Seedream 5.0 lite</div>
|
||||
<div class="model-price">~0.04 元/张</div>
|
||||
<div class="model-tag">高质量</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,12 +31,12 @@
|
||||
<div class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<h3 class="section-title">SiliconFlow FLUX.1 [dev]</h3>
|
||||
<h3 class="section-title">SiliconFlow Kolors</h3>
|
||||
<el-tag :type="siliconflowStatus" size="small">
|
||||
{{ siliconflowStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-desc">硅基流动文生图 API,基于 FLUX.1 开源模型,性价比高</p>
|
||||
<p class="card-desc">硅基流动文生图 API,基于 Kolors 开源模型,性价比高</p>
|
||||
</div>
|
||||
<el-form label-width="120px" class="config-form">
|
||||
<el-form-item label="API Key">
|
||||
@@ -66,12 +66,12 @@
|
||||
<div class="section-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<h3 class="section-title">火山引擎 Seedream 4.5</h3>
|
||||
<h3 class="section-title">火山引擎 Seedream 5.0 lite</h3>
|
||||
<el-tag :type="volcengineStatus" size="small">
|
||||
{{ volcengineStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="card-desc">字节跳动火山引擎文生图 API,Seedream 4.5 模型,高质量输出</p>
|
||||
<p class="card-desc">字节跳动火山引擎文生图 API,Seedream 5.0 lite 模型,支持中英文提示词,高质量输出</p>
|
||||
</div>
|
||||
<el-form label-width="120px" class="config-form">
|
||||
<el-form-item label="API Key">
|
||||
@@ -136,17 +136,21 @@ const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
|
||||
const imageSize = ref('1024')
|
||||
const saving = ref(false)
|
||||
|
||||
// 后端是否已配置 API Key(脱敏值也算已配置)
|
||||
const siliconflowConfigured = ref(false)
|
||||
const volcengineConfigured = ref(false)
|
||||
|
||||
// 测试状态
|
||||
const testingSiliconflow = ref(false)
|
||||
const testingVolcengine = ref(false)
|
||||
const siliconflowTestResult = ref<{ ok: boolean; msg: string } | null>(null)
|
||||
const volcengineTestResult = ref<{ ok: boolean; msg: string } | null>(null)
|
||||
|
||||
// 状态计算
|
||||
const siliconflowStatus = computed(() => siliconflowKey.value ? 'success' : 'info')
|
||||
const siliconflowStatusText = computed(() => siliconflowKey.value ? '已配置' : '未配置')
|
||||
const volcengineStatus = computed(() => volcengineKey.value ? 'success' : 'info')
|
||||
const volcengineStatusText = computed(() => volcengineKey.value ? '已配置' : '未配置')
|
||||
// 状态计算:输入框有值 或 后端已配置 都算"已配置"
|
||||
const siliconflowStatus = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? 'success' : 'info')
|
||||
const siliconflowStatusText = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? '已配置' : '未配置')
|
||||
const volcengineStatus = computed(() => (volcengineKey.value || volcengineConfigured.value) ? 'success' : 'info')
|
||||
const volcengineStatusText = computed(() => (volcengineKey.value || volcengineConfigured.value) ? '已配置' : '未配置')
|
||||
|
||||
// 加载配置
|
||||
const loadConfigs = async () => {
|
||||
@@ -160,6 +164,10 @@ const loadConfigs = async () => {
|
||||
defaultModel.value = map['AI_IMAGE_MODEL'] || 'flux-dev'
|
||||
// 注意:API Key 是脱敏的(****),不回填到输入框
|
||||
// 只有完整值才回填
|
||||
// 记录后端是否已有 API Key(脱敏值也算已配置)
|
||||
siliconflowConfigured.value = !!map['SILICONFLOW_API_KEY']
|
||||
volcengineConfigured.value = !!map['VOLCENGINE_API_KEY']
|
||||
// 脱敏值不回填输入框,只有完整值才回填
|
||||
if (map['SILICONFLOW_API_KEY'] && !map['SILICONFLOW_API_KEY'].includes('****')) {
|
||||
siliconflowKey.value = map['SILICONFLOW_API_KEY']
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ async function loadTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const res = await getPromptTemplates()
|
||||
templates.value = res.data
|
||||
templates.value = res as any
|
||||
} catch { /* ignore */ } finally { templateLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ async function saveTemplate(tpl: Template) {
|
||||
async function loadMappingTypes() {
|
||||
try {
|
||||
const res = await getMappingTypes()
|
||||
mappingTypes.value = res.data
|
||||
mappingTypes.value = res as any
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ async function loadMappings() {
|
||||
mappingLoading.value = true
|
||||
try {
|
||||
const res = await getPromptMappings(currentType.value || undefined)
|
||||
mappings.value = res.data
|
||||
mappings.value = res as any
|
||||
} catch { /* ignore */ } finally { mappingLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ async function handlePreview() {
|
||||
previewing.value = true
|
||||
try {
|
||||
const res = await previewPrompt(previewParams.value)
|
||||
previewResult.value = res.data.prompt
|
||||
previewResult.value = (res as any).prompt
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '预览失败')
|
||||
} finally { previewing.value = false }
|
||||
|
||||
Reference in New Issue
Block a user