1111 lines
26 KiB
Vue
1111 lines
26 KiB
Vue
<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>
|