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

1111 lines
26 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">
<header class="vg-hero">
<div class="vg-hero-text">
<h1 class="vg-hero-title">视频生成</h1>
<p class="vg-hero-desc">文生视频图生视频选择模型后生成成片</p>
</div>
<div class="vg-type-select" @click.stop>
<span class="vg-hero-mode-label">生成模式</span>
<button
type="button"
class="vg-type-trigger"
:class="{ 'is-open': typeMenuOpen }"
@click.stop="typeMenuOpen = !typeMenuOpen"
:aria-expanded="typeMenuOpen">
<span class="vg-type-label">{{ videoModeLabel }}</span>
<svg class="vg-type-chevron" width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.01 7.982A1.2 1.2 0 0 1 21 9.679l-8.156 8.06a1.2 1.2 0 0 1-1.688 0L3 9.68a1.2 1.2 0 0 1 1.687-1.707L12 15.199l7.313-7.227a1.2 1.2 0 0 1 1.697.01Z" fill="currentColor" />
</svg>
</button>
<transition name="vg-fade">
<div v-show="typeMenuOpen" class="vg-type-dropdown">
<div class="vg-type-dropdown-bg" />
<ul class="vg-type-options" role="listbox">
<li
v-for="opt in videoModeOptions"
:key="opt.value"
:class="['vg-type-option', { active: videoMode === opt.value }]"
role="option"
@click="pickVideoMode(opt.value)">
{{ opt.label }}
</li>
</ul>
</div>
</transition>
</div>
</header>
<div class="vg-body">
<!-- 左侧:生成器 -->
<div class="vg-generator">
<div class="vg-generator-inner">
<!-- 参考上传区(图生视频);文生视频时显示轻量占位 -->
<div class="vg-refs">
<div class="vg-ref-group">
<div class="vg-ref-bg" />
<div v-if="isImageVideoMode" class="vg-ref-content">
<div class="vg-ref-tilt">
<div class="upload-inner">
<div class="upload-title">
<div class="upload-title-left">
{{ imageUploadPrimaryLabel }}
</div>
<div class="upload-title-tip">
{{ $t('common.uploadImageTip') || '支持PNG/JPG最大10MB' }}
</div>
</div>
<mf-image-upload
v-if="videoMode !== 'image-reference'"
listType="picture-card"
uploadHeight="140px"
:show-file-list="false"
:content="$t('common.uploadFirstPlaceholder') || '点击上传'"
v-model="firstUrl" />
<mf-image-upload
v-else
listType="picture-card"
uploadHeight="140px"
:show-file-list="false"
:content="'上传参考图'"
v-model="referenceUrl" />
<div class="last-frame" v-if="videoMode === 'image-first-last-frame'">
<div class="upload-title-left">
{{ $t('common.uploadLastPlaceholder') || '尾帧(可选)' }}
</div>
<mf-image-upload
listType="picture-card"
uploadHeight="100px"
:show-file-list="false"
:content="$t('common.uploadLastPlaceholder') || '上传尾帧'"
v-model="lastUrl" />
</div>
</div>
</div>
</div>
<div v-else class="vg-ref-placeholder">
<div class="vg-ref-placeholder-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8 20a1.2 1.2 0 0 0 2.4 0v-6.8H20a1.2 1.2 0 1 0 0-2.4h-6.8V4a1.2 1.2 0 0 0-2.4 0v6.8H4a1.2 1.2 0 0 0 0 2.4h6.8V20Z" fill="currentColor" />
</svg>
</div>
<p class="vg-ref-placeholder-text">切换到「图生视频」可上传参考图</p>
</div>
</div>
</div>
<div class="vg-main-column">
<div class="rich-editor-container">
<RichTextEditor
v-model="editorContent"
:placeholder="
$t('common.textVideoPlaceholder') ||
'描述画面与动态,例如:阳光下的女孩在海边起舞…'
"
:uploaded-images="uploadedImages"
@text-change="handleTextChange"
@image-upload="handleImageUpload"
/>
</div>
<div class="vg-toolbar">
<div class="vg-toolbar-settings">
<div class="vg-model-wrap">
<span class="vg-model-icon" aria-hidden="true">
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M11.805 5.786c1.25-.926 2.193-1.373 2.471-1.096.489.488-1.261 3.03-3.909 5.677-4.33 4.331-6.715 8.968-5.326 10.358.29.29.723.416 1.264.394.421.017.92-.07 1.48-.249-2.117.9-3.859 1.005-4.76.104-1.874-1.874.61-7.402 5.553-12.353l.022-.02c.03-.032.062-.063.093-.094l.065-.064.11-.108c.97-.95 1.96-1.804 2.937-2.549Zm5.55 11.57c1.532-1.531 3.2-2.347 3.725-1.822.525.525-.29 2.192-1.822 3.724-1.532 1.531-3.2 2.347-3.725 1.822-.524-.525.291-2.192 1.822-3.724Z" />
</svg>
</span>
<a-select v-model="selectedModel" class="vg-model-select">
<a-option v-for="option in modelOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<div class="vg-toolbar-actions">
<button
type="button"
class="vg-submit-circle"
:disabled="generateLoading"
@click="generateVideo"
:title="price ? $t('common.createVideo', { price }) : $t('common.generateVideo')">
<span class="vg-submit-circle-inner">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M12.002 3c.424 0 .806.177 1.079.46l5.98 5.98.103.114a1.5 1.5 0 0 1-2.225 2.006l-3.437-3.436V19.5l-.008.153a1.5 1.5 0 0 1-2.985 0l-.007-.153V8.122l-3.44 3.438a1.5 1.5 0 0 1-2.225-2.006l.103-.115 6-5.999.025-.025.059-.052.044-.037c.029-.023.06-.044.09-.065l.014-.01a1.43 1.43 0 0 1 .101-.062l.03-.017c.209-.11.447-.172.699-.172Z" />
</svg>
</span>
</button>
</div>
</div>
<div class="vg-submit-hint">
{{
price
? `${$t('common.createVideo', { price: price })} · ${$t('common.generateTip') || '生成将消耗余额'}`
: $t('common.generateTip') || '生成视频需要消耗余额'
}}
</div>
</div>
</div>
</div>
<!-- 右侧预览 -->
<div class="right" :class="{ 'has-result': showResult }">
<div v-if="!showResult" class="empty-state">
<div class="empty-visual">
<div class="empty-ring" />
<img src="/images/empty-video.png" alt="" />
</div>
<p>生成的视频将在这里显示</p>
</div>
<div v-else class="result-container">
<div class="result-video-wrapper">
<video
v-if="videoUrl"
:src="videoUrl"
controls
autoplay
class="result-video"
:poster="posterUrl">
您的浏览器不支持视频播放
</video>
</div>
<div class="result-actions">
<mf-button v-if="canCancel" @click="cancelTask" type="danger"> 取消任务 </mf-button>
<mf-button @click="saveVideo" :disabled="!videoUrl">
<a-image src="/images/btn_save.png" /> 保存视频
</mf-button>
<mf-button @click="closeResult">
<a-image src="/images/btn_close.png" /> 关闭
</mf-button>
</div>
</div>
</div>
</div>
<section class="vg-task-section">
<div class="vg-task-head">
<h3 class="vg-task-title">我的视频任务</h3>
<button type="button" class="vg-task-refresh" @click="loadTaskList">刷新</button>
</div>
<p v-if="!taskRows.length" class="vg-task-empty">暂无任务,生成后将显示在此</p>
<ul v-else class="vg-task-list">
<li v-for="row in taskRows" :key="row.id" class="vg-task-item">
<div class="vg-task-meta">
<span class="vg-task-id">{{ row.result || '—' }}</span>
<span class="vg-task-st">{{ taskStatusText(row) }}</span>
</div>
<div class="vg-task-actions">
<button type="button" class="vg-link" v-if="row.result && row.status === 0" @click="cancelRowTask(row)">
取消
</button>
</div>
</li>
</ul>
</section>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import RichTextEditor from '@/components/RichTextEditor.vue'
export default {
name: 'VideoGen',
data() {
return {
prefixCls: 'video-gen',
videoMode: 'text-to-video',
videoModeOptions: [
{ value: 'text-to-video', label: '文生视频' },
{ value: 'image-first-frame', label: '图生视频 · 首帧' },
{ value: 'image-first-last-frame', label: '图生视频 · 首尾帧' },
{ value: 'image-reference', label: '图生视频 · 参考图' }
],
firstUrl: '',
lastUrl: '',
referenceUrl: '',
editorContent: '',
interval: null,
videoUrl: null,
videoLoading: false,
generateLoading: false,
videoId: null,
showResult: false,
price: null,
id: null,
modelOptions: [
{ label: 'Seedance 2.0', value: 'ep-20260326165811-dlkth' },
{ label: 'Seedance 2.0 Fast', value: 'ep-20260326170056-dkj9m' }
],
selectedModel: 'ep-20260326165811-dlkth',
uploadedImages: [],
maxPollAttempts: 40,
typeMenuOpen: false,
taskRows: []
}
},
components: {
RichTextEditor
},
computed: {
...mapGetters(['lang']),
canCancel() {
return !!this.videoId && !this.videoUrl
},
videoModeLabel() {
const o = this.videoModeOptions.find((x) => x.value === this.videoMode)
return o ? o.label : '文生视频'
},
isImageVideoMode() {
return this.videoMode !== 'text-to-video'
},
imageUploadPrimaryLabel() {
if (this.videoMode === 'image-reference') return '上传参考图'
if (this.videoMode === 'image-first-last-frame') return '上传首帧'
return this.$t('common.uploadFirstImage') || '上传首帧'
},
posterUrl() {
const f = this.videoMode === 'image-reference' ? this.referenceUrl : this.firstUrl
if (!f) return ''
return typeof f === 'object' ? (f.url || '') : f
}
},
mounted() {
this.loadPriceInfo()
this.loadTaskList()
document.addEventListener('click', this.onDocClick)
},
beforeUnmount() {
this.destroyInterval()
document.removeEventListener('click', this.onDocClick)
},
methods: {
onDocClick() {
this.typeMenuOpen = false
},
pickVideoMode(m) {
this.videoMode = m
this.firstUrl = ''
this.lastUrl = ''
this.referenceUrl = ''
this.editorContent = ''
this.uploadedImages = []
this.typeMenuOpen = false
},
plainPromptText() {
if (!this.editorContent || !String(this.editorContent).trim()) return ''
const s = String(this.editorContent)
if (s.indexOf('<') < 0) return s.trim()
const d = document.createElement('div')
d.innerHTML = s
return (d.textContent || d.innerText || '').trim()
},
async loadTaskList() {
try {
const res = await this.$axios({
url: 'api/portal/video/tasks',
method: 'GET',
data: { pageNum: 1, pageSize: 30 }
})
if (res.code === 200 && res.rows) this.taskRows = res.rows
} catch (_) {
/* 未登录等忽略 */
}
},
taskStatusText(row) {
if (row.status === 1) return '已完成'
if (row.status === 2) return '已失败/已取消'
return '进行中'
},
async cancelRowTask(row) {
const tid = row.result
if (!tid) return
await this.cancelTaskById(tid)
await this.loadTaskList()
},
async loadPriceInfo() {
this.$axios({
url: 'api/manager/selectInfo',
method: 'GET',
data: { aiType: '21' }
}).then((res) => {
this.price = res.data?.price
this.id = res.data?.id
})
},
handleTextChange(content) {
this.editorContent = content
},
handleImageUpload(imageInfo) {
if (imageInfo && imageInfo.url) {
this.uploadedImages.push({
url: imageInfo.url,
name: imageInfo.name || 'image'
})
}
},
generateVideo() {
const text = this.plainPromptText() || '一个优雅的女孩在阳光下跳舞'
if (this.isImageVideoMode) {
if (this.videoMode === 'image-reference') {
const ref = typeof this.referenceUrl === 'object' ? this.referenceUrl?.url : this.referenceUrl
if (!ref) {
this.$message.error('请上传参考图')
return
}
} else {
const firstImageUrl = typeof this.firstUrl === 'object' ? this.firstUrl?.url : this.firstUrl
if (!firstImageUrl) {
this.$message.error(this.$t('common.uploadFirstImageError') || '请上传首帧图')
return
}
if (this.videoMode === 'image-first-last-frame') {
const last = typeof this.lastUrl === 'object' ? this.lastUrl?.url : this.lastUrl
if (!last) {
this.$message.error('请上传尾帧图')
return
}
}
}
} else if (!this.plainPromptText()) {
this.$message.error('请输入视频描述文本')
return
}
this.generateLoading = true
const params = {
text,
functionType: '21',
model: this.selectedModel
}
const urlMap = {
'text-to-video': 'api/portal/video/text-to-video',
'image-first-frame': 'api/portal/video/image-first-frame',
'image-first-last-frame': 'api/portal/video/image-first-last-frame',
'image-reference': 'api/portal/video/image-reference'
}
const path = urlMap[this.videoMode]
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') {
params.firstUrl = typeof this.firstUrl === 'object' ? this.firstUrl.url : this.firstUrl
}
if (this.videoMode === 'image-first-last-frame') {
params.lastUrl = typeof this.lastUrl === 'object' ? this.lastUrl.url : this.lastUrl
}
if (this.videoMode === 'image-reference') {
params.referenceUrl = typeof this.referenceUrl === 'object' ? this.referenceUrl.url : this.referenceUrl
}
this.$axios({
url: path,
method: 'POST',
data: params
}).then((res) => {
this.generateLoading = false
if (res.code == 200) {
this.videoId = res.data.id
this.showResult = true
this.getVideo(res.data.id)
this.loadTaskList()
} else if (res.code == -1) {
this.$confirm({
title: this.$t('common.notice'),
content: this.$t('common.balenceLow'),
onOk: () => this.$router.push('/recharge')
})
}
}).catch(() => {
this.generateLoading = false
})
},
getVideo(videoId) {
let attempts = 0
this.interval = setInterval(() => {
attempts++
if (attempts > this.maxPollAttempts) {
this.$message.warning('视频生成超时')
this.destroyInterval()
return
}
this.$axios({
url: `api/portal/video/tasks/${videoId}`,
method: 'GET'
}).then((res) => {
if (res.code == 200 && res.data.status === 'succeeded') {
this.videoUrl = res.data.content?.video_url || res.data.video_url
this.destroyInterval()
}
})
}, 3000)
},
destroyInterval() {
if (this.interval) {
clearInterval(this.interval)
this.interval = null
}
},
saveVideo() {
if (!this.videoUrl) return
const link = document.createElement('a')
link.href = this.videoUrl
link.download = `video_${Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
},
closeResult() {
this.showResult = false
this.videoUrl = null
this.videoId = null
this.taskStatus = null
},
async cancelTask() {
if (!this.videoId) return
await this.cancelTaskById(this.videoId)
},
async cancelTaskById(taskId) {
return new Promise((resolve) => {
this.$confirm({
title: '取消任务',
content: '确定要取消当前视频生成任务吗?取消后余额将退回。',
okText: '确定取消',
cancelText: '关闭',
onOk: async () => {
try {
const res = await this.$axios({
url: `api/portal/video/tasks/${taskId}`,
method: 'DELETE'
})
if (res.code === 200) {
this.$message.success(res.msg || '任务已取消,余额已退回')
this.destroyInterval()
this.showResult = false
this.videoUrl = null
this.videoId = null
this.loadTaskList()
} else {
this.$message.error(res.msg || '取消失败')
}
} catch (error) {
this.$message.error('取消请求失败')
}
resolve()
},
onCancel: () => resolve()
})
})
}
}
}
</script>
<style lang="less" scoped>
.video-gen {
--vg-cyan: #00cae0;
--vg-ink: #0a0b0d;
--vg-panel: rgba(22, 24, 30, 0.92);
--vg-border: rgba(255, 255, 255, 0.08);
--vg-text: rgba(255, 255, 255, 0.88);
--vg-muted: rgba(255, 255, 255, 0.45);
display: flex;
flex-direction: column;
min-height: 100%;
gap: 20px;
padding: 28px 28px 32px;
background: radial-gradient(120% 80% at 50% -20%, rgba(0, 202, 224, 0.12), transparent 55%),
radial-gradient(80% 50% at 100% 30%, rgba(33, 151, 255, 0.08), transparent 45%), var(--vg-ink);
box-sizing: border-box;
}
/* —— Hero —— */
.vg-hero {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px 24px;
padding: 4px 0 16px;
border-bottom: 1px solid var(--vg-border);
margin-bottom: 4px;
}
.vg-hero-text {
flex: 1;
min-width: 200px;
}
.vg-hero-title {
margin: 0 0 6px;
font-size: clamp(22px, 3vw, 30px);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--vg-text);
line-height: 1.2;
}
.vg-hero-desc {
margin: 0;
font-size: 14px;
color: var(--vg-muted);
line-height: 1.5;
}
.vg-type-select {
position: relative;
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
}
.vg-hero-mode-label {
font-size: 13px;
color: var(--vg-muted);
white-space: nowrap;
}
.vg-type-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.03));
border: 1px solid var(--vg-border);
border-radius: 12px;
color: #fff;
font-size: 15px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
box-shadow: 0 0 0 1px rgba(0, 202, 224, 0.15);
&:hover {
border-color: rgba(0, 202, 224, 0.45);
}
&.is-open .vg-type-chevron {
transform: rotate(180deg);
}
}
.vg-type-label {
background: linear-gradient(90deg, #fff, rgba(0, 202, 224, 0.95));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.vg-type-chevron {
width: 0.85em;
height: 0.85em;
opacity: 0.75;
transition: transform 0.2s cubic-bezier(0.15, 0.75, 0.3, 1);
}
.vg-type-dropdown {
position: absolute;
left: 0;
top: calc(100% + 8px);
min-width: 100%;
z-index: 50;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.45);
}
.vg-type-dropdown-bg {
position: absolute;
inset: 0;
background: rgba(18, 20, 26, 0.98);
backdrop-filter: blur(12px);
border: 1px solid var(--vg-border);
border-radius: 14px;
}
.vg-type-options {
position: relative;
list-style: none;
margin: 0;
padding: 6px;
z-index: 1;
}
.vg-type-option {
padding: 12px 16px;
border-radius: 10px;
color: var(--vg-muted);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--vg-text);
}
&.active {
color: var(--vg-cyan);
background: rgba(0, 202, 224, 0.1);
}
}
.vg-fade-enter-active,
.vg-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.vg-fade-enter-from,
.vg-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* —— Body —— */
.vg-body {
display: flex;
flex: 1;
gap: 20px;
align-items: stretch;
min-height: 420px;
}
.vg-generator {
flex: 0 0 min(520px, 44vw);
min-width: 300px;
display: flex;
}
.vg-generator-inner {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
padding: 22px;
background: var(--vg-panel);
border: 1px solid var(--vg-border);
border-radius: 20px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.vg-refs {
flex-shrink: 0;
}
.vg-ref-group {
position: relative;
border-radius: 16px;
overflow: hidden;
min-height: 92px;
}
.vg-ref-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0, 202, 224, 0.08), rgba(33, 151, 255, 0.05));
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 16px;
pointer-events: none;
}
.vg-ref-content {
position: relative;
padding: 12px;
}
.vg-ref-tilt {
transform: rotate(-1.5deg);
transition: transform 0.25s ease;
&:hover {
transform: rotate(0deg);
}
}
.upload-inner {
transform: rotate(1.5deg);
.upload-title {
margin-bottom: 10px;
}
.upload-title-left {
color: var(--vg-text);
font-weight: 600;
font-size: 14px;
}
.upload-title-tip {
color: var(--vg-muted);
font-size: 12px;
margin-top: 4px;
}
.last-frame {
margin-top: 14px;
}
}
.vg-ref-placeholder {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
min-height: 88px;
}
.vg-ref-placeholder-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
color: var(--vg-cyan);
}
.vg-ref-placeholder-text {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--vg-muted);
}
.vg-main-column {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.rich-editor-container {
flex: 1;
min-height: 260px;
margin: 0;
display: flex;
flex-direction: column;
:deep(.rich-editor-root) {
flex: 1;
display: flex;
flex-direction: column;
}
:deep(.rich-editor) {
flex: 1;
min-height: 220px !important;
font-size: 15px;
line-height: 1.7;
padding: 16px 18px;
border-radius: 14px !important;
background: rgba(0, 0, 0, 0.25) !important;
border: 1px solid var(--vg-border) !important;
}
}
.vg-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--vg-border);
}
.vg-toolbar-settings {
flex: 1;
min-width: 0;
}
.vg-model-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 12px;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
border: 1px solid var(--vg-border);
}
.vg-model-icon {
display: flex;
color: var(--vg-cyan);
opacity: 0.85;
}
.vg-model-select {
flex: 1;
min-width: 0;
:deep(.arco-select-view-single) {
background: transparent !important;
border: none !important;
color: var(--vg-text) !important;
}
:deep(.arco-select-view-value) {
color: var(--vg-text) !important;
}
}
.vg-toolbar-actions {
flex-shrink: 0;
}
.vg-submit-circle {
width: 52px;
height: 52px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
background: linear-gradient(145deg, #00d4e8, #0090a8);
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.35), 0 8px 24px rgba(0, 150, 180, 0.35);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
&:hover:not(:disabled) {
transform: scale(1.04);
box-shadow: 0 0 0 3px rgba(0, 202, 224, 0.5), 0 12px 28px rgba(0, 150, 180, 0.45);
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
}
.vg-submit-circle-inner {
display: flex;
align-items: center;
justify-content: center;
}
.vg-submit-hint {
margin-top: 12px;
font-size: 12px;
color: var(--vg-muted);
line-height: 1.5;
}
/* —— 右侧预览 —— */
.right {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--vg-border);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
min-height: 360px;
&.has-result {
align-items: flex-start;
padding: 20px;
}
}
.empty-state {
text-align: center;
color: var(--vg-muted);
padding: 24px;
.empty-visual {
position: relative;
display: inline-block;
margin-bottom: 16px;
}
.empty-ring {
position: absolute;
inset: -12px;
border-radius: 50%;
border: 1px solid rgba(0, 202, 224, 0.2);
animation: vg-pulse 2.4s ease-in-out infinite;
}
img {
position: relative;
width: 120px;
opacity: 0.55;
}
p {
margin: 0;
font-size: 14px;
}
}
@keyframes vg-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.08);
opacity: 0.85;
}
}
.result-video-wrapper {
width: 100%;
max-width: 720px;
}
.result-video {
width: 100%;
border-radius: 12px;
}
.result-actions {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 640px) {
.vg-body {
flex-direction: column;
}
.vg-generator {
flex: none;
width: 100%;
}
.vg-hero {
flex-direction: column;
align-items: stretch;
}
.vg-type-select {
flex-direction: column;
align-items: flex-start;
}
}
.vg-task-empty {
margin: 0;
font-size: 13px;
color: var(--vg-muted);
}
.vg-task-section {
margin-top: 8px;
padding: 18px 22px;
background: var(--vg-panel);
border: 1px solid var(--vg-border);
border-radius: 16px;
}
.vg-task-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.vg-task-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--vg-text);
}
.vg-task-refresh {
font-size: 13px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--vg-border);
background: rgba(255, 255, 255, 0.06);
color: var(--vg-muted);
cursor: pointer;
}
.vg-task-refresh:hover {
color: var(--vg-cyan);
border-color: rgba(0, 202, 224, 0.35);
}
.vg-task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow: auto;
}
.vg-task-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.2);
font-size: 12px;
}
.vg-task-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.vg-task-id {
color: var(--vg-muted);
word-break: break-all;
}
.vg-task-st {
color: var(--vg-cyan);
font-weight: 600;
}
.vg-link {
background: none;
border: none;
padding: 0;
color: #ff6b6b;
cursor: pointer;
font-size: 12px;
}
.vg-link:hover {
text-decoration: underline;
}
</style>