Files
geekai/web/src/views/Dalle.vue
T
RockYang b18b8ccb02 release: v4.2.8
整合开源版 v4.2.8 功能:Sora2 视频、路由重构、手机站开关、DALL-E 参考图,以及启动时自动同步数据表字段等修复与优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:12:37 +08:00

598 lines
21 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>
<div class="page-dall">
<div class="inner custom-scroll">
<div class="sd-box">
<h2 class="!text-[#252f76] py-3">AI图像生成</h2>
<div class="sd-params">
<el-form :model="params" label-width="80px" label-position="left">
<div class="param-line pt-1">
<el-form-item label="生图模型">
<template #default>
<div class="form-item-inner">
<el-select
v-model="selectedModel"
style="width: 150px"
placeholder="请选择模型"
@change="changeModel"
>
<el-option v-for="v in models" :label="v.name" :value="v" :key="v.value" />
</el-select>
</div>
</template>
</el-form-item>
</div>
<div class="param-line">
<el-form-item label="图片比例">
<template #default>
<div class="form-item-inner">
<el-select
v-model="params.aspect_ratio"
style="width: 150px"
value-key="value"
@change="changeAspectRatio"
>
<el-option
v-for="v in radioAspects"
:label="v.label"
:value="v.value"
:key="v.value"
>
<i class="iconfont mr-1" :class="v.icon"></i>
{{ v.label }}
</el-option>
</el-select>
</div>
</template>
</el-form-item>
</div>
<div class="param-line">
<el-input
v-model="params.prompt"
:autosize="{ minRows: 4, maxRows: 6 }"
type="textarea"
ref="promptRef"
maxlength="1024"
show-word-limit
placeholder="请在此输入绘画提示词,您也可以点击下面的提示词助手生成绘画提示词"
v-loading="promptGenerating"
/>
</div>
<div class="flex justify-end pt-2 pr-2">
<el-button @click="generatePrompt" type="primary" :loading="promptGenerating">
<span v-if="!promptGenerating">
<i class="iconfont icon-chuangzuo"></i>
生成专业绘画指令
</span>
<span v-else>生成中...</span>
</el-button>
</div>
<div class="mt-2 mb-2">
<label class="text-gray-700 font-semibold">参考图(可选)</label>
<div class="py-2">
<ImageUpload
v-model="params.image"
:max-count="5"
:max-size="20"
:multiple="true"
/>
</div>
</div>
</el-form>
</div>
<div class="py-4">
<button
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2 text-base"
type="button"
@click="generate"
>
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span v-if="isGenerating">创作中...</span>
<span v-else>立即生成({{ dallPower }}算力)</span>
</button>
</div>
</div>
<div class="task-list-box pl-6 pr-6 pb-4 pt-4 h-dvh">
<div class="task-list-inner">
<div class="job-list-box">
<h2 class="text-xl">任务列表</h2>
<task-list :list="runningJobs" />
<template v-if="finishedJobs.length > 0">
<h2 class="text-xl">创作记录</h2>
<div class="finish-job-list mt-3">
<div v-if="finishedJobs.length > 0">
<Waterfall
:list="finishedJobs"
:row-key="waterfallOptions.rowKey"
:gutter="waterfallOptions.gutter"
:has-around-gutter="waterfallOptions.hasAroundGutter"
:width="waterfallOptions.width"
:breakpoints="waterfallOptions.breakpoints"
:img-selector="waterfallOptions.imgSelector"
:background-color="waterfallOptions.backgroundColor"
:animation-effect="waterfallOptions.animationEffect"
:animation-duration="waterfallOptions.animationDuration"
:animation-delay="waterfallOptions.animationDelay"
:animation-cancel="waterfallOptions.animationCancel"
:lazyload="waterfallOptions.lazyload"
:load-props="waterfallOptions.loadProps"
:cross-origin="waterfallOptions.crossOrigin"
:align="waterfallOptions.align"
:is-loading="loading"
:is-over="isOver"
@afterRender="loading = false"
>
<template #default="{ item, url }">
<div
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
>
<div class="overflow-hidden rounded-lg">
<LazyImg
:url="url"
v-if="item.progress === 100"
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
@click="previewImg(item)"
/>
<el-image v-else-if="item.progress === 101">
<template #error>
<div class="image-slot">
<div class="err-msg-container">
<div class="title">任务失败</div>
<div class="opt">
<el-popover
title="错误详情"
trigger="click"
:width="250"
:content="item['err_msg']"
placement="top"
>
<template #reference>
<el-button type="info">详情</el-button>
</template>
</el-popover>
<el-button type="danger" @click="removeImage(item)"
>删除</el-button
>
</div>
</div>
</div>
</template>
</el-image>
</div>
<div
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
v-if="item.progress === 100"
>
<div
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
>
<div class="flex">
<el-tooltip content="取消分享" placement="top" v-if="item.publish">
<el-button
type="warning"
@click="publishImage(item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
</el-tooltip>
<el-tooltip content="分享" placement="top" v-else>
<el-button
type="success"
@click="publishImage(item, true)"
circle
>
<i class="iconfont icon-share-bold"></i>
</el-button>
</el-tooltip>
<el-tooltip content="复制提示词" placement="top">
<el-button
type="info"
circle
class="copy-prompt"
:data-clipboard-text="item.prompt"
>
<i class="iconfont icon-file"></i>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
type="danger"
:icon="Delete"
@click="removeImage(item)"
circle
/>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
</Waterfall>
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="loading"
/>
<div v-else>
<button
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
@click="fetchFinishJobs"
v-if="!isOver"
>
加载更多
</button>
<div class="no-more-data" v-else>
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</div>
</div>
</div>
<el-empty :image-size="100" :image="nodata" description="暂无记录" v-else />
</div>
</template>
<!-- end finish job list-->
</div>
</div>
<back-top :right="30" :bottom="30" />
</div>
<!-- end task list box -->
</div>
</div>
<el-image-viewer
@close="
() => {
previewURL = ''
}
"
v-if="previewURL !== ''"
:url-list="[previewURL]"
/>
</div>
</template>
<script setup>
import nodata from '@/assets/img/no-data.png'
import BackTop from '@/components/BackTop.vue'
import ImageUpload from '@/components/ImageUpload.vue'
import TaskList from '@/components/TaskList.vue'
import { checkSession } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { Delete } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onUnmounted, ref } from 'vue'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0)
const isLogin = ref(false)
const loading = ref(true)
const isOver = ref(false)
const previewURL = ref('')
const store = useSharedStore()
const models = ref([])
const waterfallOptions = store.waterfallOptions
const resizeElement = function () {
listBoxHeight.value = window.innerHeight - 58
}
resizeElement()
window.onresize = () => {
resizeElement()
}
// 为了兼容 size 和 aspect_ratio 参数,label 对应的是 aspect_ratio 的值,size 是预估值
const radioAspects = ref({
'1:1': { value: '1:1', size: '1024x1024', label: '1:1(正方形,头像)', icon: 'icon-aspect_1_1' },
'2:3': {
value: '2:3',
size: '512x768',
label: '2:3(社交媒体,自拍)',
icon: 'icon-aspect_2_3',
},
'4:3': {
value: '4:3',
size: '1024x768',
label: '4:3(文章配图,插画)',
icon: 'icon-aspect_4_3',
},
'9:16': {
value: '9:16',
size: '768x1366',
label: '9:16(手机壁纸,人像)',
icon: 'icon-aspect_9_16',
},
'16:9': {
value: '16:9',
size: '1366x768',
label: '16:9(桌面壁纸,风景)',
icon: 'icon-aspect_16_9',
},
'3:2': { value: '3:2', size: '768x512', label: '3:21248x832', icon: 'icon-aspect_3_2' },
'3:4': { value: '3:4', size: '960x1280', label: '3:4864x1152', icon: 'icon-aspect_3_4' },
'4:5': { value: '4:5', size: '960x1280', label: '4:5896x1120', icon: 'icon-aspect_4_5' },
'5:4': {
value: '5:4',
size: '1280x960',
label: '5:41120x896',
icon: 'icon-aspect_5_4',
},
'21:9': { value: '21:9', size: '1344x576', label: '21:91536x658', icon: 'icon-aspect_16_9' },
})
const params = ref({
aspect_ratio: '1:1',
size: '1024x1024',
prompt: '',
})
const finishedJobs = ref([])
const runningJobs = ref([])
const allowPulling = ref(true) // 是否允许轮询
const downloadPulling = ref(false) // 下载轮询
const tastPullHandler = ref(null)
const downloadPullHandler = ref(null)
const userPower = ref(0)
const dallPower = ref(0)
const clipboard = ref(null)
const userId = ref(0)
const selectedModel = ref(null)
onMounted(() => {
initData()
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
showMessageOK('复制成功!')
})
clipboard.value.on('error', () => {
showMessageError('复制失败!')
})
// 获取模型列表
httpGet('/api/dall/models')
.then((res) => {
models.value = res.data
selectedModel.value = models.value[0]
params.value.model_id = selectedModel.value.id
changeModel(selectedModel.value)
})
.catch((e) => {
showMessageError('获取模型列表失败:' + e.message)
})
})
onUnmounted(() => {
clipboard.value.destroy()
if (tastPullHandler.value) {
clearInterval(tastPullHandler.value)
}
if (downloadPullHandler.value) {
clearInterval(downloadPullHandler.value)
}
})
// 改变图片比例
const changeAspectRatio = (value) => {
params.value.size = radioAspects.value[value].size
}
const initData = () => {
checkSession()
.then((user) => {
userPower.value = user['power']
userId.value = user.id
isLogin.value = true
page.value = 0
fetchRunningJobs()
fetchFinishJobs()
// 轮询运行中任务
tastPullHandler.value = setInterval(() => {
if (allowPulling.value) {
fetchRunningJobs()
}
}, 5000)
// 图片下载轮询
downloadPullHandler.value = setInterval(() => {
if (downloadPulling.value) {
page.value = 0
fetchFinishJobs()
}
}, 5000)
})
.catch(() => {})
}
const fetchRunningJobs = () => {
if (!isLogin.value) {
return
}
// 获取运行中的任务
httpGet(`/api/dall/jobs?finish=false`)
.then((res) => {
// 如果任务有更新,则更新已完成任务列表
if (res.data.items && res.data.items.length !== runningJobs.value.length) {
page.value = 0
fetchFinishJobs()
}
if (res.data.items.length > 0) {
runningJobs.value = res.data.items
} else {
allowPulling.value = false
runningJobs.value = []
}
})
.catch((e) => {
ElMessage.error('获取任务失败:' + e.message)
})
}
const page = ref(1)
const pageSize = ref(15)
// 获取已完成的任务
const fetchFinishJobs = () => {
if (!isLogin.value) {
return
}
loading.value = true
page.value = page.value + 1
httpGet(`/api/dall/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
.then((res) => {
if (res.data.items.length < pageSize.value) {
isOver.value = true
loading.value = false
}
const imageList = res.data.items
let needPulling = false
for (let i = 0; i < imageList.length; i++) {
if (imageList[i]['img_url']) {
imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
} else if (imageList[i].progress === 100) {
needPulling = true
imageList[i]['img_thumb'] = waterfallOptions.loadProps.loading
}
}
// 如果当前是第一页,则开启图片下载轮询
if (page.value === 1) {
downloadPulling.value = needPulling
}
if (page.value === 1) {
finishedJobs.value = imageList
} else {
finishedJobs.value = finishedJobs.value.concat(imageList)
}
})
.catch((e) => {
ElMessage.error('获取任务失败:' + e.message)
loading.value = false
})
}
// 创建绘图任务
const promptRef = ref(null)
const isGenerating = ref(false)
const generate = () => {
if (isGenerating.value) {
return
}
if (params.value.prompt === '') {
promptRef.value.focus()
return ElMessage.error('请输入绘画提示词!')
}
if (!isLogin.value) {
store.setShowLoginDialog(true)
return
}
isGenerating.value = true
httpPost('/api/dall/image', params.value)
.then(() => {
ElMessage.success('任务执行成功!')
userPower.value -= dallPower.value
// 追加任务列表
runningJobs.value.push({
prompt: params.value.prompt,
progress: 0,
})
allowPulling.value = true
isOver.value = false
})
.catch((e) => {
ElMessage.error('任务执行失败:' + e.message)
})
.finally(() => {
isGenerating.value = false
})
}
const removeImage = (item) => {
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet('/api/dall/remove', { id: item.id })
.then(() => {
ElMessage.success('任务删除成功')
page.value = 0
isOver.value = false
fetchFinishJobs()
})
.catch((e) => {
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {})
}
const previewImg = (item) => {
previewURL.value = item.img_url
}
// 发布图片到作品墙
const publishImage = (item, action) => {
let text = '图片发布'
if (action === false) {
text = '取消发布'
}
httpGet('/api/dall/publish', { id: item.id, action: action })
.then(() => {
ElMessage.success(text + '成功')
item.publish = action
page.value = 0
isOver.value = false
})
.catch((e) => {
ElMessage.error(text + '失败:' + e.message)
})
}
const promptGenerating = ref(false)
const generatePrompt = () => {
if (params.value.prompt === '') {
return showMessageError('请输入原始提示词')
}
promptGenerating.value = true
httpPost('/api/prompt/image', { prompt: params.value.prompt })
.then((res) => {
params.value.prompt = res.data
promptGenerating.value = false
})
.catch((e) => {
showMessageError('生成提示词失败:' + e.message)
promptGenerating.value = false
})
}
const changeModel = (model) => {
dallPower.value = model.power
params.value.model_id = selectedModel.value.id
}
</script>
<style lang="scss" scoped>
@use '../assets/css/image-dall.scss' as *;
@use '../assets/css/custom-scroll.scss' as *;
</style>