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

899 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.uploadFirstPlaceholder')"
v-model="firstUrl" />
<mf-image-upload
listType="draggable"
uploadHeight="200px"
:show-file-list="false"
:content="$t('common.uploadLastPlaceholder')"
v-model="lastUrl" />
</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="text">
<a-textarea
v-model="text"
:placeholder="$t('common.textVideoPlaceholder')" />
</div> -->
<mf-button
class="submit"
type="primary"
size="large"
:loading="generateLoading"
@click="generateVideo">
{{
price
? `${$t('common.createVideo', { price: price })}`
: $t('common.generateVideo')
}}
</mf-button>
<div class="submit-tip">
{{ $t('common.generateTip') }}
</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
class="right"
v-if="showResult">
<div class="right-close">
<mf-icon
value="icon-close"
@click="close" />
</div>
<div class="result-video">
<a-spin
:tip="$t('common.videoLoadingText')"
:loading="videoLoading">
<video
v-if="videoUrl"
:src="videoUrl"
autoplay
muted
playsinline
controls
style="width: 100%; height: 100%" />
</a-spin>
</div>
<div class="action">
<mf-button
v-if="videoUrl"
@click="saveVideo">
<a-image
:width="20"
:height="20"
:preview="false"
src="/images/btn_bctp@2x.png" />
{{ $t('common.saveVideo') }}
</mf-button>
<mf-button
v-if="videoId"
@click="viewVideo"
:loading="viewLoading">
<a-image
:width="20"
:height="20"
:preview="false"
src="/images/btn_scsp@2x.png" />
{{ $t('common.viewVideo') }}
</mf-button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
data() {
return {
prefixCls: 'fast-video',
firstUrl: '',
lastUrl: '',
text: '',
interval: null,
videoUrl: null,
videoLoading: false,
generateLoading: false,
videoId: null,
viewLoading: false,
hasNavGuard: false,
price: null,
tags: [],
selectedTags: {},
showResult: !this.$base.isMobile(),
id: null,
// 上传方式选择
uploadType: 'upload', // 'upload' 或 'template'
// 模版选择相关
templateDialogVisible: false,
templateList: [],
templateLoading: false,
selectedTemplate: null,
selectedTemplatePreview: '', // 选中的模版预览图URL
maxPollAttempts: 40 // 最大轮询次数3秒 * 40 = 120秒超时
}
},
beforeUnmount() {
this.destroyInterval()
},
mounted() {
let { url, text } = this.$route.query || {}
// 安全验证只接受有效的URL格式
if (url) {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'))) {
this.firstUrl = url
}
}
if (text) {
this.text = text
}
this.$axios({
url: 'api/manager/selectInfo',
method: 'GET',
data: {
aiType: '21'
}
}).then((res) => {
this.price = res.data?.price
this.id = res.data?.id;
this.getTags()
})
},
computed: {
...mapGetters(['lang'])
},
watch: {
lang() {
this.getTags()
}
},
methods: {
handleGroupChange(id, value) {
this.selectedTags[id] = value
},
close() {
this.showResult = false
},
// 上传方式改变
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 = 21
this.$axios({
url: 'api/template/getTemplateList',
method: 'GET',
data: {
position: 0,
aiId: 21
}
}).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保持用户的选择状态方便二次选择
},
getTags() {
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
}
})
}
})
},
async saveVideo() {
if (!this.videoUrl) {
this.$message.error(this.$t('common.downloadFailed') || '视频URL为空')
return
}
try {
// 获取视频的 blob 数据
const response = await fetch(this.videoUrl)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
// 从 URL 中提取文件名,如果没有则使用默认名称
const urlParts = this.videoUrl.split('/')
let fileName = urlParts[urlParts.length - 1] || 'video.mp4'
// 移除查询参数
fileName = fileName.split('?')[0]
// 如果没有扩展名,尝试从 Content-Type 获取
if (!fileName.includes('.')) {
const contentType = response.headers.get('content-type') || 'video/mp4'
const extension = contentType.split('/')[1]?.split(';')[0] || 'mp4'
// 从URL路径中尝试提取扩展名
const pathMatch = this.videoUrl.match(/\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv)/i)
if (pathMatch) {
fileName = `video_${new Date().getTime()}.${pathMatch[1]}`
} else {
fileName = `video_${new Date().getTime()}.${extension}`
}
}
// 创建下载链接
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') || '下载失败')
}
},
destroyInterval() {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
},
viewVideo() {
if (!this.videoId) return
this.viewLoading = true
this.$axios({
url: `api/ai/${this.videoId}`,
method: 'GET'
})
.then((res) => {
this.viewLoading = false
if (res.code == 200) {
if (res.data.status == 'succeeded') {
this.$store.dispatch('main/setPrevent', false)
this.videoUrl = res.data.content.video_url
} else {
this.$message.warning(
this.$t('common.videoLoadingText')
)
}
}
})
.catch((_) => {
this.viewLoading = false
})
},
getVideo(videoId) {
if (!videoId) return
let pollAttempts = 0
this.interval = setInterval((_) => {
pollAttempts++
// 超时保护:超过最大轮询次数则停止
if (pollAttempts > this.maxPollAttempts) {
this.destroyInterval()
this.$message.warning(this.$t('common.videoTimeout'))
this.videoLoading = false
return
}
this.$axios({
url: `api/ai/${videoId}`,
method: 'GET'
}).then((res) => {
this.videoLoading = true
if (res.code == 200) {
if (res.data.status == 'succeeded') {
this.videoUrl = res.data.content.video_url
this.$store.dispatch('main/setPrevent', false)
this.videoLoading = false
this.destroyInterval()
}
}
})
}, 3000)
},
generateVideo() {
// 处理 firstUrl可能是对象或字符串
const firstImageUrl = typeof this.firstUrl === 'object' ? this.firstUrl.url : this.firstUrl
if (!firstImageUrl) {
this.$message.error(this.$t('common.uploadFirstImageError'))
return
}
if (firstImageUrl.startsWith('blob:')) {
this.$message.error(this.$t('common.uploadWaitImageError'))
return
}
// if (!this.text) {
// this.$message.error(this.$t('common.textError'))
// return
// }
this.generateLoading = true
let params = {
text: this.text,
firstUrl: firstImageUrl,
functionType: '21'
}
if (this.lastUrl && this.lastUrl.url) {
params.lastUrl = this.lastUrl.url
}
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)
})
params.tags = tags.join(',')
this.$axios({
url: 'api/ai/imgToVideo',
method: 'POST',
data: params
})
.then((res) => {
this.generateLoading = false
if (res.code == 200) {
this.showResult = true
this.videoId = res.data.id
this.$store.dispatch('main/setPrevent', true)
this.getVideo(res.data.id)
} 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>
.fast-video {
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-bottom: 20px;
&-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;
}
}
}
}
.upload-content {
.mf-image-upload {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
}
.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: 20px 100px 20px 100px;
:deep(.result-video) {
width: 100%;
min-width: 600px;
height: calc(100% - 60px);
.arco-spin {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.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) {
.fast-video {
.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-video {
width: 100%;
min-width: auto;
}
.action {
width: 100%;
.mf-button {
width: calc(50% - 8px);
margin: 0 10px;
background-color: rgb(var(--primary-6));
}
}
}
}
}
</style>