文生视频和图生视频功能完成

This commit is contained in:
GeekMaster
2025-07-23 19:11:30 +08:00
parent 54fe49de5d
commit a3f6a641aa
20 changed files with 640 additions and 610 deletions

View File

@@ -1,61 +1,97 @@
<template>
<div class="image-upload">
<div class="upload-list" v-if="imageList.length > 0">
<div v-for="(image, index) in imageList" :key="index" class="upload-item">
<el-image
:src="image"
:preview-src-list="imageList"
:initial-index="index"
fit="cover"
class="upload-image"
/>
<div class="upload-overlay">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(index)"
class="remove-btn"
/>
<!-- 单图模式 -->
<template v-if="props.maxCount === 1">
<div class="single-upload">
<div v-if="imageList.length === 0" class="upload-btn">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="false"
accept="image/*"
class="uploader"
>
<div class="upload-placeholder">
<el-icon :size="20"><UploadFilled /></el-icon>
<span>上传图片</span>
</div>
</el-upload>
</div>
<div v-else class="upload-item single-image-item">
<el-image :src="imageList[0]" fit="cover" class="upload-image" />
<div class="upload-overlay" style="opacity: 1">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(0)"
class="remove-btn"
/>
</div>
</div>
</div>
</template>
<!-- 上传按钮 -->
<div v-if="!multiple || imageList.length < maxCount" class="upload-btn">
<!-- 多图模式 -->
<template v-else>
<div class="upload-list" v-if="imageList.length > 0">
<div v-for="(image, index) in imageList" :key="index" class="upload-item">
<el-image :src="image" fit="cover" class="upload-image" />
<div class="upload-overlay">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(index)"
class="remove-btn"
/>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="!multiple || imageList.length < maxCount" class="upload-btn">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
accept="image/*"
class="uploader"
:limit="maxCount"
>
<div class="upload-placeholder">
<el-icon :size="20"><UploadFilled /></el-icon>
<span>上传图片</span>
</div>
</el-upload>
</div>
</div>
<!-- 初始上传区域 -->
<div v-else class="upload-area">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
accept="image/*"
class="uploader"
:limit="maxCount"
>
<div class="upload-placeholder">
<el-icon :size="20"><Plus /></el-icon>
<span>上传图片</span>
</div>
<el-icon :size="40" class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽图片到此处 <em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip text-center">
支持 JPGPNG 格式最多上传 {{ maxCount }} 单张最大 5MB
</div>
</template>
</el-upload>
</div>
</div>
<!-- 初始上传区域 -->
<div v-else class="upload-area">
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
accept="image/*"
class="uploader"
>
<div class="upload-placeholder">
<el-icon :size="40"><Plus /></el-icon>
<div class="upload-text">
<p>点击上传图片</p>
<p class="upload-tip">支持 JPGPNG 格式最大 10MB</p>
</div>
</div>
</el-upload>
</div>
</template>
<!-- 上传进度 -->
<el-progress
@@ -69,7 +105,8 @@
<script setup>
import { httpPost } from '@/utils/http'
import { Delete, Plus } from '@element-plus/icons-vue'
import { replaceImg } from '@/utils/libs'
import { Delete, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
@@ -97,14 +134,14 @@ const uploadProgress = ref(0)
// 图片列表
const imageList = computed({
get() {
if (props.multiple) {
if (props.multiple || props.maxCount > 1) {
return Array.isArray(props.modelValue) ? props.modelValue : []
} else {
return props.modelValue ? [props.modelValue] : []
}
},
set(value) {
if (props.multiple) {
if (props.multiple || props.maxCount > 1) {
emit('update:modelValue', value)
} else {
emit('update:modelValue', value[0] || '')
@@ -112,6 +149,7 @@ const imageList = computed({
},
})
const uploadCount = ref(1)
// 处理上传
const handleUpload = async (uploadFile) => {
const file = uploadFile.file
@@ -122,17 +160,18 @@ const handleUpload = async (uploadFile) => {
return
}
// 检查文件大小 (10MB)
if (file.size > 10 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 10MB')
// 检查文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 5MB')
return
}
// 检查数量限制
if (props.multiple && imageList.value.length >= props.maxCount) {
if (uploadCount.value > props.maxCount) {
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
return
}
uploadCount.value++
uploading.value = true
uploadProgress.value = 0
@@ -153,10 +192,10 @@ const handleUpload = async (uploadFile) => {
clearInterval(progressTimer)
uploadProgress.value = 100
const imageUrl = response.data.url
const imageUrl = replaceImg(response.data.url)
// 更新图片列表
if (props.multiple) {
if (props.multiple || props.maxCount > 1) {
const newList = [...imageList.value, imageUrl]
imageList.value = newList
} else {
@@ -178,114 +217,114 @@ const removeImage = (index) => {
const newList = [...imageList.value]
newList.splice(index, 1)
imageList.value = newList
uploadCount.value--
}
</script>
<style lang="stylus" scoped>
.image-upload
width 100%
<style lang="stylus">
.image-upload {
width: 100%;
}
.upload-list
display flex
flex-wrap wrap
gap 10px
.single-upload {
width: 100px;
height: 100px;
position: relative;
}
.upload-item
position relative
width 100px
height 100px
border-radius 6px
overflow hidden
border 1px solid #dcdfe6
.single-image-item {
width: 100px;
height: 100px;
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
}
.upload-image
width 100%
height 100%
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.upload-overlay
position absolute
top 0
left 0
right 0
bottom 0
background rgba(0, 0, 0, 0.5)
display flex
align-items center
justify-content center
opacity 0
transition opacity 0.3s
.upload-item {
position: relative;
width: 100px;
height: 100px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
.remove-btn
background rgba(245, 108, 108, 0.8)
border none
color white
.upload-image {
width: 100%;
height: 100%;
}
&:hover .upload-overlay
opacity 1
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
.upload-btn
width 100px
height 100px
border 2px dashed #dcdfe6
border-radius 6px
display flex
align-items center
justify-content center
cursor pointer
transition all 0.3s
.remove-btn {
background: rgba(245, 108, 108, 0.8);
border: none;
color: white;
}
}
&:hover
border-color #409eff
color #409eff
&:hover .upload-overlay {
opacity: 1;
}
}
.uploader
width 100%
height 100%
.upload-btn {
.uploader {
width: 100%;
.upload-placeholder
display flex
flex-direction column
align-items center
gap 5px
font-size 12px
color #8c939d
.el-upload-dragger {
width: 100px;
height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 12px;
color: #8c939d;
}
}
.upload-area
border 2px dashed #dcdfe6
border-radius 6px
padding 40px
text-align center
cursor pointer
transition all 0.3s
.upload-area {
.el-upload-dragger {
width: 100%;
}
.uploader {
width: 100%;
}
}
&:hover
border-color #409eff
.upload-progress {
margin-top: 10px;
}
.uploader
width 100%
.upload-placeholder
display flex
flex-direction column
align-items center
gap 10px
color #8c939d
.upload-text
p
margin 5px 0
.upload-tip
font-size 12px
color #c0c4cc
.upload-progress
margin-top 10px
:deep(.el-upload)
width 100%
height 100%
display flex
align-items center
justify-content center
:deep(.el-upload) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -7,7 +7,7 @@
import { checkSession } from '@/store/cache'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import { ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
@@ -233,29 +233,32 @@ export const useJimengStore = defineStore('jimeng', () => {
// 获取任务状态文本
const getTaskStatusText = (status) => {
const statusMap = {
in_queue: '排队中',
generating: '处理中',
success: '成功',
failed: '失败',
canceled: '已取消',
in_queue: '任务排队中',
generating: '任务执行中',
success: '任务成功',
failed: '任务失败',
canceled: '任务已取消',
}
return statusMap[status] || status
}
// 获取状态类型
const getStatusType = (status) => {
const getTaskType = (type) => {
const typeMap = {
pending: 'info',
processing: 'warning',
completed: 'success',
failed: 'danger',
text_to_image: 'primary',
image_to_image: 'primary',
image_edit: 'primary',
image_effects: 'primary',
text_to_video: 'success',
image_to_video: 'success',
}
return typeMap[status] || 'info'
return typeMap[type] || 'primary'
}
// 切换任务筛选
const switchTaskFilter = (filter) => {
taskFilter.value = filter
isOver.value = false
fetchData(1)
}
@@ -272,10 +275,13 @@ export const useJimengStore = defineStore('jimeng', () => {
page_size: pageSize.value,
filter: taskFilter.value,
})
const data = response.data
if (data.total === 0) {
if (!data.items || data.items.length === 0) {
isOver.value = true
currentList.value = []
if (pageNum === 1) {
currentList.value = []
}
return
}
@@ -297,7 +303,9 @@ export const useJimengStore = defineStore('jimeng', () => {
// 简单轮询逻辑
const startPolling = () => {
if (pollHandler) return
if (pollHandler) {
clearInterval(pollHandler)
}
pollHandler = setInterval(async () => {
const response = await httpPost('/api/jimeng/jobs', {
page: 1,
@@ -322,7 +330,7 @@ export const useJimengStore = defineStore('jimeng', () => {
if (todoList.length === 0) {
stopPolling()
}
}, 5000)
}, 3000)
}
const stopPolling = () => {
@@ -404,7 +412,9 @@ export const useJimengStore = defineStore('jimeng', () => {
const response = await httpPost('/api/jimeng/task', requestData)
if (response.data) {
showMessageOK('任务提交成功')
isOver.value = false
await fetchData(1)
startPolling()
}
} catch (error) {
console.error('提交任务失败:', error)
@@ -414,13 +424,40 @@ export const useJimengStore = defineStore('jimeng', () => {
}
}
const downloadFile = async (item) => {
const url = replaceImg(item.video_url || item.img_url)
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
} catch (error) {
showMessageError('下载失败')
item.downloading = false
}
}
// 重试任务
const retryTask = async (taskId) => {
try {
const response = await httpPost(`/api/jimeng/retry/${taskId}`)
const response = await httpGet(`/api/jimeng/retry?id=${taskId}`)
if (response.data) {
showMessageOK('重试任务已提交')
await fetchData(page.value)
isOver.value = false
await fetchData(1)
startPolling()
}
} catch (error) {
console.error('重试任务失败:', error)
@@ -440,7 +477,7 @@ export const useJimengStore = defineStore('jimeng', () => {
const response = await httpGet('/api/jimeng/remove', { id: item.id })
if (response.data) {
showMessageOK('删除成功')
await fetchData(page.value)
await fetchData(1)
}
} catch (error) {
if (error !== 'cancel') {
@@ -456,17 +493,6 @@ export const useJimengStore = defineStore('jimeng', () => {
showDialog.value = true
}
// 下载文件
const downloadFile = (item) => {
const url = item.video_url || item.img_url
if (url) {
const link = document.createElement('a')
link.href = url
link.download = `jimeng_${item.id}.${item.video_url ? 'mp4' : 'jpg'}`
link.click()
}
}
// 画同款功能
const drawSame = (item) => {
// 联动功能开关
@@ -576,14 +602,14 @@ export const useJimengStore = defineStore('jimeng', () => {
getCurrentPowerCost,
getFunctionName,
getTaskStatusText,
getStatusType,
getTaskType,
switchTaskFilter,
fetchData,
submitTask,
downloadFile,
retryTask,
removeJob,
playVideo,
downloadFile,
cleanup,
drawSame,

View File

@@ -95,7 +95,11 @@
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload v-model="store.imageToImageParams.image_input" />
<ImageUpload
v-model="store.imageToImageParams.image_input"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
@@ -133,7 +137,11 @@
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload v-model="store.imageEditParams.image_urls" :multiple="true" />
<ImageUpload
v-model="store.imageEditParams.image_urls"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
@@ -162,7 +170,11 @@
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload v-model="store.imageEffectsParams.image_input1" />
<ImageUpload
v-model="store.imageEffectsParams.image_input1"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
@@ -228,7 +240,11 @@
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload v-model="store.imageToVideoParams.image_urls" :multiple="true" />
<ImageUpload
v-model="store.imageToVideoParams.image_urls"
:max-count="2"
:multiple="true"
/>
</div>
<div class="param-line pt">
@@ -313,6 +329,7 @@
v-bind="waterfallOptions"
:is-loading="store.loading"
:is-over="store.isOver"
:lazyload="true"
@afterRender="onWaterfallAfterRender"
>
<template #default="{ item }">
@@ -323,32 +340,82 @@
<el-image
v-if="item.img_url"
:src="item.img_url"
:preview-src-list="[item.img_url]"
:preview-teleported="true"
fit="cover"
class="preview-image"
/>
<video
v-else-if="item.video_url"
:src="item.video_url"
class="preview-video"
preload="metadata"
/>
>
<template #placeholder>
<div class="w-full h-full flex justify-center items-center">
<img :src="loadingIcon" class="max-w-[50px] max-h-[50px]" />
</div>
</template>
</el-image>
<div v-else-if="item.video_url" class="w-full h-full preview-video-wrapper">
<video
:src="item.video_url"
preload="auto"
loop="loop"
muted="muted"
class="preview-video w-full h-full"
>
您的浏览器不支持视频播放
</video>
<div class="video-mask" @click="store.playVideo(item)">
<div class="play-btn">
<img src="/images/play.svg" alt="播放" />
</div>
</div>
</div>
<div v-else class="preview-placeholder">
<i
class="iconfont icon-video text-2xl"
v-if="item.type.includes('video')"
></i>
<i class="iconfont icon-dalle text-2xl" v-else></i>
<span>{{ store.getTaskStatusText(item.status) }}</span>
<div
v-if="item.status === 'in_queue'"
class="flex flex-col items-center gap-1"
>
<i class="iconfont icon-video" v-if="item.type.includes('video')"></i>
<i class="iconfont icon-dalle" v-else></i>
<span>
{{ store.getTaskStatusText(item.status) }}
</span>
</div>
<div
v-else-if="item.status === 'generating'"
class="flex flex-col items-center gap-1"
>
<span>
<Generating>
<div class="text-gray-400 text-base pt-3">
{{ store.getTaskStatusText(item.status) }}
</div></Generating
>
</span>
</div>
<div
v-else-if="item.status === 'failed'"
class="flex flex-col items-center gap-1"
>
<i class="iconfont icon-error text-red-500"></i>
<span class="text text-red-500">
{{ store.getTaskStatusText(item.status) }}
</span>
<span
class="text-sm text-red-400 err-msg-clip cursor-pointer mx-5"
@click="copyErrorMsg(item.err_msg)"
>
{{ item.err_msg }}
</span>
</div>
</div>
</div>
</div>
<div class="task-center">
<div class="task-info flex justify-between">
<div class="flex gap-2">
<el-tag size="small" :type="store.getStatusType(item.status)">
{{ store.getTaskStatusText(item.status) }}
<el-tag size="small" :type="store.getTaskType(item.type)">
{{ store.getFunctionName(item.type) }}
</el-tag>
<el-tag size="small">{{ store.getFunctionName(item.type) }}</el-tag>
</div>
<div class="flex gap-2">
<span>
@@ -368,6 +435,37 @@
></i>
</el-tooltip>
</span>
<template v-if="item.status === 'failed'">
<span class="ml-1" v-if="item.status === 'failed'">
<el-tooltip content="重试" placement="top">
<i
class="iconfont icon-refresh cursor-pointer"
@click="store.retryTask(item.id)"
></i>
</el-tooltip>
</span>
<span class="ml-1" v-if="item.status === 'failed'">
<el-tooltip content="删除" placement="top">
<i
class="iconfont icon-remove cursor-pointer text-red-500"
@click="store.removeJob(item)"
></i>
</el-tooltip>
</span>
</template>
<span class="ml-1" v-if="item.video_url || item.img_url">
<el-tooltip content="下载" placement="top">
<i
v-if="!item.downloading"
class="iconfont icon-download text-sm cursor-pointer"
@click="store.downloadFile(item)"
></i>
<el-image src="/images/loading.gif" class="w-4 h-4" fit="cover" v-else />
</el-tooltip>
</span>
</div>
</div>
<div
@@ -380,42 +478,6 @@
<span v-if="item.power">{{ item.power }}算力</span>
</div>
</div>
<div class="task-right">
<div class="task-actions">
<el-button
v-if="item.status === 'failed'"
type="primary"
size="small"
@click="store.retryTask(item.id)"
>
重试
</el-button>
<el-button
v-if="item.video_url || item.img_url"
type="default"
size="small"
@click="store.downloadFile(item)"
>
下载
</el-button>
<el-button
v-if="item.video_url"
type="default"
size="small"
@click="store.playVideo(item)"
>
播放
</el-button>
<el-button
type="danger"
v-if="item.status === 'failed'"
size="small"
@click="store.removeJob(item)"
>
删除
</el-button>
</div>
</div>
</div>
</template>
</Waterfall>
@@ -423,7 +485,7 @@
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="store.loading"
v-if="!waterfallRendered"
/>
<div v-else>
<div class="no-more-data" v-if="store.isOver">
@@ -439,7 +501,14 @@
<!-- 视频预览对话框 -->
<el-dialog v-model="store.showDialog" title="视频预览" width="70%" center>
<video :src="store.currentVideoUrl" controls style="width: 100%; max-height: 60vh">
<video
:src="store.currentVideoUrl"
autoplay="true"
controls
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
</el-dialog>
@@ -448,13 +517,15 @@
<script setup>
import '@/assets/css/jimeng.styl'
import loadingIcon from '@/assets/img/loading.gif'
import ImageUpload from '@/components/ImageUpload.vue'
import Generating from '@/components/ui/Generating.vue'
import { imageSizeOptions, useJimengStore, videoAspectRatioOptions } from '@/store/jimeng'
import { useSharedStore } from '@/store/sharedata'
import { dateFormat } from '@/utils/libs'
import { Switch } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
@@ -474,6 +545,9 @@ const getCategoryIcon = (category) => {
const store = useJimengStore()
// 新增:瀑布流渲染完成状态
const waterfallRendered = ref(false)
onMounted(() => {
store.init()
})
@@ -482,7 +556,27 @@ onUnmounted(() => {
store.cleanup()
})
// 监听 loading每次 loading 变为 true 时重置渲染状态
watch(
() => store.loading,
(val) => {
if (val) {
waterfallRendered.value = false
}
}
)
watch(
() => store.isOver,
(val) => {
if (val) {
waterfallRendered.value = true
}
}
)
function onWaterfallAfterRender() {
waterfallRendered.value = true
if (!store.loading && !store.isOver) {
store.fetchData(store.page + 1)
}
@@ -498,6 +592,17 @@ function copyPrompt(prompt) {
ElMessage.error('复制失败')
})
}
function copyErrorMsg(msg) {
navigator.clipboard
.writeText(msg)
.then(() => {
ElMessage.success('错误信息已复制')
})
.catch(() => {
ElMessage.error('复制失败')
})
}
</script>
<style lang="stylus" scoped>
@@ -508,6 +613,23 @@ function copyPrompt(prompt) {
gap: 20px;
padding: 10px 0;
}
// 新增:增强任务项悬停动画
.task-item {
transition: box-shadow 3s cubic-bezier(0.4,0,0.2,1), transform 0.5s cubic-bezier(0.4,0,0.2,1), border-color 0.5s;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1.5px solid transparent;
border-radius: 12px;
background: #fff;
position: relative;
z-index: 1;
}
.task-item:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 1.5px 8px rgba(0,0,0,0.10);
border-color: #a259ff;
transform: scale(1.025) translateY(-2px);
z-index: 10;
background: #f7fbff;
}
}
@media (max-width: 1200px) {
.task-list .task-grid {
@@ -519,4 +641,49 @@ function copyPrompt(prompt) {
grid-template-columns: 1fr;
}
}
.preview-video-wrapper
position: relative
width: 100%
height: 100%
.video-mask
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(0,0,0,0.25)
display: flex
justify-content: center
align-items: center
opacity: 0
transition: opacity 0.2s
z-index: 2
&:hover .video-mask
opacity: 1
.play-btn
width: 64px
height: 64px
background: rgba(255,255,255,0.3)
border-radius: 50%
display: flex
justify-content: center
align-items: center
box-shadow: 0 2px 8px rgba(0,0,0,0.15)
cursor: pointer
z-index: 3
transition: background 0.2s
&:hover
background: rgba(255,255,255,0.4)
.play-btn img
width: 36px
height: 36px
.err-msg-clip
display: -webkit-box
-webkit-line-clamp: 2
-webkit-box-orient: vertical
overflow: hidden
text-overflow: ellipsis
word-break: break-all
white-space: normal
cursor: pointer
</style>