ai_images/portal-ui/src/views/Image.vue

892 lines
20 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="prefixCls">
<div class="left">
<div class="upload">
<div class="upload-title">
<div class="upload-title-left">
{{ $t('common.uploadImage') }}
</div>
<div class="upload-title-tip">
{{ $t('common.uploadImageTip') }}
</div>
</div>
<!-- 上传方式选择 -->
<div class="upload-type-select">
<a-radio-group v-model="uploadType" @change="handleUploadTypeChange">
<a-radio value="upload">{{ $t('common.uploadImage') }}</a-radio>
<a-radio value="template">{{ $t('common.selectTemplate') }}</a-radio>
</a-radio-group>
</div>
<!-- 上传图片方式 -->
<div v-if="uploadType === 'upload'" class="upload-content">
<mf-image-upload
listType="draggable"
uploadHeight="200px"
:show-file-list="false"
:content="$t('common.uploadPlaceholder')"
v-model="firstUrl" />
</div>
<!-- 选择模版方式 -->
<div v-else class="template-content">
<div v-if="selectedTemplatePreview" class="template-preview">
<a-image
:src="selectedTemplatePreview"
fit="contain"
:preview="false"
class="preview-image" />
<div class="template-preview-actions">
<a-button type="primary" @click="openTemplateDialog">
{{ $t('common.reselectTemplate') }}
</a-button>
</div>
</div>
<div v-else class="template-placeholder">
<mf-button type="primary" size="large" @click="openTemplateDialog">
{{ $t('common.selectTemplate') }}
</mf-button>
</div>
</div>
</div>
<div
class="tags"
v-for="tag in tags">
<div class="tags-title">{{ tag.title }}</div>
<a-radio-group
:model-value="selectedTags[tag.id]"
@change="handleGroupChange(tag.id, $event)">
<a-radio
v-for="item in tag.children"
:value="item.value">
{{ item.title }}
</a-radio>
</a-radio-group>
</div>
<!-- 模型选择 -->
<div class="model-select">
<div class="model-title">{{ $t('common.selectModel') || '选择模型' }}</div>
<a-select v-model="selectedModel" style="width: 100%;">
<a-option
v-for="option in modelOptions"
:key="option.value"
:value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
<!-- 富文本编辑器 -->
<RichTextEditor
v-model="editorContent"
:placeholder="$t('common.textPlaceholder') || '请输入文本生成图片...'"
:uploaded-images="uploadedImages"
@text-change="handleTextChange"
@image-upload="handleImageUpload"
/>
<mf-button
class="submit"
type="primary"
size="large"
:loading="generateLoading"
@click="generateImage">
{{
price
? $t('common.generateImage', { score: price })
: $t('common.generateImageNow')
}}
</mf-button>
<div class="submit-tip">
{{ $t('common.generateTip') }}
</div>
</div>
<div
class="right"
v-if="showResult">
<div class="right-close">
<mf-icon
value="icon-close"
@click="close" />
</div>
<a-image
class="result-image"
fit="contain"
:src="imageUrl" />
<div
class="action"
v-show="imageUrl">
<mf-button @click="saveImage">
<a-image
:width="20"
:height="20"
:preview="false"
src="/images/btn_bctp@2x.png" />
{{ $t('common.saveImage') }}
</mf-button>
<mf-button @click="jumpToVideo">
<a-image
:width="20"
:height="20"
:preview="false"
src="/images/btn_scsp@2x.png" />
{{ $t('common.generateVideo') }}
</mf-button>
</div>
</div>
<!-- 模版选择弹窗 -->
<a-modal
v-model:visible="templateDialogVisible"
:title="$t('common.selectTemplate')"
:footer="false"
width="50%"
@cancel="handleTemplateDialogClose">
<div class="template-dialog-wrapper">
<div class="template-dialog-content">
<a-spin :loading="templateLoading">
<div class="template-grid">
<div
class="template-item"
v-for="template in templateList"
:key="template.id"
:class="{ 'selected': selectedTemplate && selectedTemplate.id === template.id }"
@click="selectTemplate(template)">
<a-image
:src="getImageUrl(template.imageUrl || template.img_url)"
:preview="false"
fit="cover" />
</div>
</div>
<div v-if="templateList.length === 0" class="empty-template">
{{ $t('common.noTemplates') }}
</div>
</a-spin>
</div>
<div class="template-dialog-footer">
<a-button @click="handleTemplateDialogClose">{{ $t('common.cancel') }}</a-button>
<a-button type="primary" :disabled="!selectedTemplate" @click="handleConfirmTemplate">{{ $t('common.confirm') }}</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import RichTextEditor from '@/components/RichTextEditor.vue'
export default {
data() {
return {
prefixCls: 'image',
firstUrl: '',
text: '',
current: 1,
generateLoading: false,
imageUrl: '',
price: null,
tags: [],
selectedTags: {},
showResult: !this.$base.isMobile(),
id: null,
// 上传方式选择
uploadType: 'upload', // 'upload' 或 'template'
// 模版选择相关
templateDialogVisible: false,
templateList: [],
templateLoading: false,
selectedTemplate: null,
selectedTemplatePreview: '', // 选中的模版预览图URL
// 模型选择 - 支持 Seedance 2.0
modelOptions: [
{ label: 'Seedance 2.0', value: 'ep-20260326165811-dlkth' },
{ label: 'Seedance 2.0 Fast', value: 'ep-20260326170056-dkj9m' }
],
selectedModel: 'ep-20260326165811-dlkth',
// 已上传的图片列表(用于@功能)
uploadedImages: []
}
},
components: {
RichTextEditor
},
computed: {
...mapGetters(['lang']),
},
watch: {
$route: {
handler(from, to) {
let type = to?.query?.type || 1
this.current = type
},
deep: true,
immediate: true
},
lang() {
this.getTags()
}
},
mounted() {
let type = this.$datas.getQueryString('type', this.$route.fullPath)
if (type) {
this.current = type || 1
}
let { text } = this.$route.query || {}
if (text) {
this.text = text
}
this.$axios({
url: 'api/manager/selectInfo',
method: 'GET',
data: {
aiType: this.current == 1 ? '11' : '12'
}
}).then((res) => {
this.price = res.data?.price
this.id = res.data?.id;
this.getTags()
})
},
methods: {
handleGroupChange(id, value) {
this.selectedTags[id] = value
},
getTags(aiId) {
this.$axios({
url: 'api/tag/list',
method: 'GET',
data: {
aiId: this.id
}
}).then((res) => {
this.tags = this.$datas.recurrence(res.data)
if (!this.$datas.isEmpty(this.tags)) {
this.tags.map((item) => {
let children = item.children || []
let firstChild = children[0]
if (firstChild) {
this.selectedTags[item.id] = firstChild.id
}
})
}
})
},
// 上传方式改变
handleUploadTypeChange() {
// 切换上传方式时,清空之前的选择
if (this.uploadType === 'upload') {
this.selectedTemplatePreview = ''
this.selectedTemplate = null
this.firstUrl = ''
} else {
this.firstUrl = ''
}
},
// 打开模版选择弹窗
openTemplateDialog() {
this.templateDialogVisible = true
this.selectedTemplate = null
// 加载模板列表
this.loadTemplateList()
},
// 加载模板列表
loadTemplateList() {
this.templateLoading = true
// 根据当前功能类型传递 ai_id 参数
this.$axios({
url: 'api/template/getTemplateList',
method: 'GET',
data: {
position: 0,
aiId: 11
}
}).then(res => {
this.templateList = res.rows || []
// 如果之前已选择过模版,恢复选中状态
if (this.selectedTemplatePreview && this.templateList.length > 0) {
const prevTemplate = this.templateList.find(t =>
this.getImageUrl(t.imageUrl || t.img_url) === this.selectedTemplatePreview
)
if (prevTemplate) {
this.selectedTemplate = prevTemplate
}
}
}).catch(err => {
console.error('加载模板列表失败:', err)
this.templateList = []
}).finally(() => {
this.templateLoading = false
})
},
// 选择模板
selectTemplate(template) {
this.selectedTemplate = template
},
// 获取图片URL
getImageUrl(url) {
if (!url) return ''
if (url.startsWith('/admin')) {
return '/api' + url
} else if (url.startsWith('/api')) {
return url
} else if (url.startsWith('http://') || url.startsWith('https://')) {
return url
} else {
return '/api' + url
}
},
// 获取模板名称(根据当前语言)
getTemplateName(template) {
if (!template) return ''
// 繁体中文显示 chineseContent其他语言显示 englishContent
if (this.lang === 'zh_HK') {
return template.chineseContent || template.name || ''
}
return template.englishContent || template.name || ''
},
// 确认选择模板
handleConfirmTemplate() {
if (this.selectedTemplate) {
const imageUrl = this.getImageUrl(this.selectedTemplate.imageUrl || this.selectedTemplate.img_url)
this.selectedTemplatePreview = imageUrl
this.firstUrl = {
url: imageUrl
}
this.templateDialogVisible = false
}
},
// 关闭模版选择弹窗
handleTemplateDialogClose() {
this.templateDialogVisible = false
// 不重置selectedTemplate保持用户的选择状态方便二次选择
},
// 处理富文本内容变化
handleTextChange(content) {
this.editorContent = content
// 同步到旧的 text 字段以保持兼容性
this.text = content
},
// 处理上传的图片(用于@功能)
handleImageUpload(imageInfo) {
if (imageInfo && imageInfo.url) {
this.uploadedImages.push({
url: imageInfo.url,
name: imageInfo.name || 'image'
})
}
},
close() {
this.showResult = false
},
jumpToVideo() {
this.$router.push(`/fast-video?url=${this.imageUrl}`)
},
async saveImage() {
try {
// 获取图片的 blob 数据
const response = await fetch(this.imageUrl)
const blob = await response.blob()
// 从 URL 中提取文件名,如果没有则使用默认名称
const urlParts = this.imageUrl.split('/')
const fileName = urlParts[urlParts.length - 1] || 'image.png'
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('下载图片失败:', error)
this.$message.error(this.$t('common.downloadFailed') || '下载失败')
}
},
generateImage() {
if (!this.firstUrl || !this.firstUrl.url) {
this.$message.error(this.$t('common.uploadImageError'))
return
}
// if (!this.text && this.current == 2) {
// this.$message.error(this.$t('common.textError'))
// return
// }
let tags = []
Object.keys(this.selectedTags).map((key) => {
let tagValue = this.selectedTags[key]
let item = this.$datas.deepFind(
this.tags,
(d) => d.id == tagValue
)
let prompt = item.prompt
if (prompt) {
let arr = prompt.split(',')
const randomIndex = Math.floor(Math.random() * arr.length)
prompt = arr[randomIndex]
// tags.push(prompt)
} else {
// tags.push(item.title)
}
tags.push(item.id)
})
this.generateLoading = true
this.$axios({
url: 'api/ai/imgToImg',
method: 'POST',
data: {
text: this.editorContent || this.text,
firstUrl: this.firstUrl.url,
functionType: this.current == 1 ? '11' : '12',
tags: tags.join(','),
model: this.selectedModel // 新增模型参数
}
})
.then((res) => {
this.generateLoading = false
if (res.code == 200) {
this.imageUrl = res.msg
this.showResult = true
} else if (res.code == -1) {
this.$confirm({
title: this.$t('common.notice'),
content: this.$t('common.balenceLow'),
okText: this.$t('common.confirm'),
cancelText: this.$t('common.cancel'),
onOk: (_) => {
this.$router.push('/recharge')
}
})
} else if (res.code == -2) {
this.$modal.error({
title: this.$t('common.notice'),
content: this.$t('common.createFailed'),
okText: this.$t('common.confirm')
})
} else if (res.code == -3) {
this.$modal.error({
title: this.$t('common.notice'),
content: this.$t('common.createTagFailed'),
okText: this.$t('common.confirm')
})
}
})
.catch((_) => {
this.generateLoading = false
})
}
}
}
</script>
<style lang="less" scoped>
.image {
display: flex;
height: 100%;
.left {
width: 400px;
padding: 20px 40px;
height: 100%;
overflow-y: auto;
flex-shrink: 0;
.submit {
width: 100%;
margin-top: 20px;
border-radius: 10px;
&-tip {
font-size: 12px;
color: #5c5d68;
margin-top: 20px;
text-align: center;
}
}
.arco-textarea-wrapper {
background: #1a1b20;
color: #fff;
height: 100px;
margin-top: 20px;
}
.types {
display: flex;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.type {
display: flex;
align-items: center;
justify-content: center;
width: 150px;
font-size: 14px;
color: #5c5d68;
height: 40px;
border-radius: 10px;
cursor: pointer;
&.active {
color: #ffffff;
background-color: rgb(var(--primary-6));
}
}
}
.upload {
// margin-top: 20px;
margin-bottom: 20px;
.model-select {
margin-bottom: 20px;
.model-title {
color: #fff;
margin-bottom: 8px;
font-size: 14px;
}
:deep(.arco-select) {
background-color: #1a1b20;
border-color: rgba(255,255,255,0.1);
}
}
/* 富文本编辑器样式 */
.rich-editor-root {
margin-bottom: 20px;
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
padding: 8px;
background-color: #1a1b20;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
.tool-btn {
padding: 6px 12px;
background-color: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover {
background-color: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.2);
}
}
}
.rich-editor {
min-height: 160px;
padding: 16px;
background-color: #1a1b20;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 14px;
line-height: 1.6;
outline: none;
resize: none;
&:focus {
border-color: rgb(var(--primary-6));
}
&[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: #5c5d68;
pointer-events: none;
}
}
}
&-title {
display: flex;
align-items: center;
margin-bottom: 16px;
justify-content: space-between;
&-left {
font-size: 14px;
color: #ffffff;
}
&-tip {
font-size: 12px;
color: #5c5d68;
}
}
.upload-type-select {
margin-bottom: 16px;
:deep(.arco-radio-group) {
.arco-radio {
margin-right: 24px;
&-label {
font-size: 14px;
color: #5c5d68;
}
}
}
}
.template-content {
.template-preview {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
background: #1a1b20;
.preview-image {
width: 100%;
height: 200px;
display: block;
:deep(.arco-image-img) {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.template-preview-actions {
padding: 12px;
text-align: center;
}
}
.template-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
background: #1a1b20;
}
}
}
.tags {
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
&-title {
color: #fff;
margin-bottom: 24px;
}
:deep(.arco-radio-group) {
.arco-radio {
margin-bottom: 12px;
&-label {
font-size: 14px;
color: #5c5d68;
}
&:hover {
.arco-radio-icon-hover::before {
background-color: transparent;
}
}
.arco-icon-hover:hover::before {
background-color: transparent;
}
}
}
}
}
.right {
flex: 1;
height: 100%;
overflow: auto;
background-color: #000000;
padding: 0 100px 20px 100px;
&-close {
display: none;
}
:deep(.arco-image-error) {
background-color: transparent;
}
:deep(.result-image) {
width: 100%;
min-width: 600px;
height: calc(100% - 60px);
.arco-image-img {
width: 100%;
height: 100%;
}
}
.action {
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
.mf-button {
display: flex;
align-items: center;
justify-content: center;
width: 200px;
height: 40px;
background: #1a1b20;
border-radius: 10px;
color: #fff;
margin: 0 15px;
:deep(.arco-image) {
margin-right: 8px;
&-img {
vertical-align: unset;
}
}
}
}
}
}
// 模版选择弹窗样式
.template-dialog-wrapper {
display: flex;
flex-direction: column;
max-height: 70vh;
min-height: 350px;
}
.template-dialog-content {
flex: 1;
overflow-y: auto;
min-height: 0;
.template-grid {
display: grid;
grid-template-columns: repeat(4, 20%);
gap: 16px;
padding: 10px 0;
.template-item {
cursor: pointer;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s;
position: relative;
&:hover {
border-color: var(--color-primary-light-1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.selected {
border-color: rgb(var(--primary-6));
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.2);
}
:deep(.arco-image) {
width: 100%;
height: 300px;
display: block;
.arco-image-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.template-name {
padding: 8px;
font-size: 12px;
color: var(--color-text-1);
text-align: center;
background: var(--color-fill-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.empty-template {
text-align: center;
padding: 60px 20px;
color: var(--color-text-3);
font-size: 14px;
}
}
.template-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 0 0 0;
border-top: 1px solid var(--color-border-2);
flex-shrink: 0;
background: var(--color-bg-1);
}
@media (max-width: 576px) {
.image {
.left {
width: 100%;
}
.right {
display: flex;
flex-direction: column;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
padding: 100px 40px;
background: linear-gradient(
0deg,
rgba(#050b15, 1) 0%,
rgba(#0f1011, 1) 49%,
rgba(#111215, 1) 100%
);
&-close {
right: 20px;
top: 80px;
position: fixed;
display: block;
cursor: pointer;
color: #fff;
.mf-icon {
font-size: 16px;
}
}
.result-image {
width: 100%;
min-width: auto;
}
.action {
.mf-button {
width: calc(50% - 8px);
margin: 0 10px;
background-color: rgb(var(--primary-6));
}
}
}
}
}
</style>