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

2338 lines
57 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">
<section class="vg-chat-section">
<div class="vg-chat-head">
<h3 class="vg-chat-title">对话记录</h3>
<button type="button" class="vg-task-refresh" @click="refreshChatList">刷新</button>
</div>
<p v-if="!sortedTaskRows.length" class="vg-task-empty">暂无对话,开始生成后将显示在此</p>
<div
v-else
class="vg-chat-list"
ref="chatListRef"
@scroll="onChatScroll">
<div
v-for="row in sortedTaskRows"
:key="row.id"
class="vg-chat-block"
:class="{ 'vg-chat-block--compact': !isChatRowSuccessWithMedia(row) }">
<!-- 用户输入部分 -->
<div
class="vg-chat-user-section"
:class="{ 'vg-chat-user-section--compact': !isChatRowSuccessWithMedia(row) }">
<div v-if="isChatRowSuccessWithMedia(row)" class="vg-chat-user-block">
<div class="vg-chat-user-text">
<template v-for="(seg, idx) in getRowPromptSegments(row)" :key="`${row.id || 'row'}_${idx}`">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<img
v-else-if="seg.type === 'image'"
class="vg-chat-inline-ref-image"
:src="seg.url"
:alt="seg.token || ''" />
<video
v-else-if="seg.type === 'video'"
class="vg-chat-inline-ref-video"
:src="seg.url"
controls
muted
preload="metadata" />
<audio
v-else-if="seg.type === 'audio'"
class="vg-chat-inline-audio"
:src="seg.url"
controls
preload="metadata" />
</template>
</div>
<div class="vg-chat-params" v-if="rowChatParamsVisible(row)">
<span class="vg-chat-param-chip">{{ rowChatParams(row).modeLabel }}</span>
<span class="vg-chat-param-chip">模型 {{ rowChatParams(row).modelLabel }}</span>
<span class="vg-chat-param-chip">比例 {{ rowChatParams(row).ratio }}</span>
<span class="vg-chat-param-chip">时长 {{ rowChatParams(row).duration }}</span>
<span class="vg-chat-param-chip">分辨率 {{ rowChatParams(row).resolution }}</span>
</div>
<div class="vg-chat-task-actions">
<button
type="button"
class="vg-link vg-chat-action-btn"
:disabled="generateLoading"
@click="editFromTaskRow(row)">
重新编辑
</button>
<button
type="button"
class="vg-link vg-chat-action-btn"
:disabled="generateLoading"
@click="regenerateFromTaskRow(row)">
重新生成
</button>
<button
type="button"
class="vg-link vg-chat-action-btn favorite-btn"
:class="{ 'is-favorited': row.isTop === 'Y' }"
@click="toggleFavorite(row)">
<template v-if="row.isTop === 'Y'">
★ 已收藏
</template>
<template v-else>
☆ 收藏
</template>
</button>
</div>
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
</div>
<div v-else class="vg-chat-user-row">
<div class="vg-chat-user-col-main">
<div class="vg-chat-user-text">
<template v-for="(seg, idx) in getRowPromptSegments(row)" :key="`${row.id || 'row'}_${idx}`">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<img
v-else-if="seg.type === 'image'"
class="vg-chat-inline-ref-image"
:src="seg.url"
:alt="seg.token || ''" />
<video
v-else-if="seg.type === 'video'"
class="vg-chat-inline-ref-video"
:src="seg.url"
controls
muted
preload="metadata" />
<audio
v-else-if="seg.type === 'audio'"
class="vg-chat-inline-audio"
:src="seg.url"
controls
preload="metadata" />
</template>
</div>
<div class="vg-chat-params" v-if="rowChatParamsVisible(row)">
<span class="vg-chat-param-chip">{{ rowChatParams(row).modeLabel }}</span>
<span class="vg-chat-param-chip">模型 {{ rowChatParams(row).modelLabel }}</span>
<span class="vg-chat-param-chip">比例 {{ rowChatParams(row).ratio }}</span>
<span class="vg-chat-param-chip">时长 {{ rowChatParams(row).duration }}</span>
<span class="vg-chat-param-chip">分辨率 {{ rowChatParams(row).resolution }}</span>
</div>
<div class="vg-chat-task-actions">
<button
type="button"
class="vg-link vg-chat-action-btn"
:disabled="generateLoading"
@click="editFromTaskRow(row)">
重新编辑
</button>
<button
type="button"
class="vg-link vg-chat-action-btn"
:disabled="generateLoading"
@click="regenerateFromTaskRow(row)">
重新生成
</button>
<button
type="button"
class="vg-link vg-chat-action-btn favorite-btn"
:class="{ 'is-favorited': row.isTop === 'Y' }"
@click="toggleFavorite(row)">
<template v-if="row.isTop === 'Y'">
★ 已收藏
</template>
<template v-else>
☆ 收藏
</template>
</button>
</div>
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
</div>
<div class="vg-chat-user-col-status">
<span class="vg-chat-inline-status" :class="chatRowInlineStatusClass(row)">
{{ taskStatusText(row) }}
</span>
<button
type="button"
class="vg-link vg-chat-inline-cancel"
v-if="row.result && row.status === 0"
@click="cancelRowTask(row)">
取消
</button>
</div>
</div>
</div>
<!-- 仅「已完成且可播放/下载」保留下方结果区 -->
<template v-if="isChatRowSuccessWithMedia(row)">
<div class="vg-chat-divider"></div>
<div class="vg-chat-ai-section">
<div class="vg-chat-ai-top">
<div class="vg-chat-ai-status">
{{ taskStatusText(row) }}
</div>
<button
type="button"
class="vg-chat-download-btn"
@click="downloadChatVideo(row)"
title="下载到本地">
下载视频
</button>
<div class="vg-chat-ai-time">{{ formatCreateTime(row.updateTime || row.createTime) }}</div>
</div>
<div class="vg-chat-result">
<video
v-if="isVideoUrl(row.result)"
:src="row.result"
controls
preload="metadata" />
<div v-else class="vg-chat-result-link">
<a :href="row.result" target="_blank" rel="noreferrer">查看结果</a>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-if="chatLoadingMore" class="vg-chat-loadmore">
加载更多…
</div>
</section>
<div class="vg-body">
<!-- 中间:生成器 -->
<div class="vg-generator">
<div class="vg-generator-inner">
<div class="vg-main-column">
<VideoComposeCard
ref="videoComposeRef"
v-model="promptText"
v-model:mediaList="mediaList"
:max-media-count="maxMediaCount"
:allowed-media-types="allowedMediaTypes"
:placeholder="
$t('common.textVideoPlaceholder') ||
'描述画面与动态,例如:阳光下的女孩在海边起舞…'
"
:video-mode="videoMode">
<template #reference-params>
<div class="vg-params-row vg-params-row--reference-col">
<div class="vg-param">
<span class="vg-param-label">生成模式</span>
<a-select
v-model="videoMode"
class="vg-param-select"
placeholder="请选择模式"
@change="pickVideoMode">
<a-option v-for="opt in videoModeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</div>
<div class="vg-param">
<span class="vg-param-label">模型</span>
<a-select
v-model="selectedModel"
class="vg-param-select"
placeholder="请选择模型"
allow-clear>
<a-option v-for="opt in modelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-option>
</a-select>
</div>
<div class="vg-param">
<span class="vg-param-label">比例</span>
<a-select v-model="selectedRatio" class="vg-param-select" placeholder="画幅比例" allow-clear>
<a-option v-for="r in ratioOptions" :key="r" :value="r">{{ r }}</a-option>
</a-select>
</div>
<div class="vg-param">
<span class="vg-param-label">时长</span>
<a-select v-model="selectedDuration" class="vg-param-select" placeholder="秒" allow-clear>
<a-option v-for="d in durationOptions" :key="d" :value="d">{{ d }} 秒</a-option>
</a-select>
</div>
<div class="vg-param">
<span class="vg-param-label">分辨率</span>
<a-select
v-model="selectedResolution"
class="vg-param-select"
placeholder="分辨率"
allow-clear>
<a-option v-for="r in resolutionOptions" :key="r" :value="r">{{ r }}</a-option>
</a-select>
</div>
</div>
</template>
<template #toolbar>
<div class="vg-toolbar vg-toolbar--reference-submit">
<div
class="vg-toolbar-actions"
:class="{ 'vg-toolbar-actions--reference': true }">
<mf-button
size="small"
type="text"
class="vg-toolbar-clear"
@click="clearReferenceCompose">
清空
</mf-button>
<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="18" height="18" 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>
</template>
</VideoComposeCard>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import VideoComposeCard from '@/components/VideoComposeCard.vue'
const REFERENCE_DRAFT_STORAGE_KEY = 'portalVideoGen_referenceDraft_v1'
export default {
name: 'VideoGen',
data() {
return {
prefixCls: 'video-gen',
videoMode: 'image-reference',
videoModeOptions: [
{ value: 'text-to-video', label: '文生视频' },
{ value: 'image-first-frame', label: '图生视频 · 首帧' },
{ value: 'image-first-last-frame', label: '图生视频 · 首尾帧' },
{ value: 'image-reference', label: '图生视频 · 参考图' }
],
promptText: '',
mediaList: [],
editorVisualTick: 0,
interval: null,
videoUrl: null,
videoLoading: false,
generateLoading: false,
videoId: null,
showResult: false,
price: null,
id: null,
modelOptions: [],
ratioOptions: [],
durationOptions: [],
resolutionOptions: [],
selectedModel: '',
selectedRatio: '16:9',
selectedDuration: null,
selectedResolution: '',
// 每 30 秒查询任务状态,最多 30 次(约 15 分钟)
maxPollAttempts: 30,
pollIntervalMs: 30000,
taskRows: [],
// 对话历史分页加载(仅用于对话记录区域)
chatPageNum: 1,
chatPageSize: 10,
chatMaxTotal: 50,
chatLoadingMore: false,
chatHasMore: true,
chatMaxHintShown: false
}
},
components: {
VideoComposeCard
},
computed: {
canCancel() {
return !!this.videoId && !this.videoUrl
},
maxMediaCount() {
if (this.videoMode === 'image-first-frame') return 1
if (this.videoMode === 'image-first-last-frame') return 2
if (this.videoMode === 'image-reference') return 12
return 12
},
allowedMediaTypes() {
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return ['image']
if (this.videoMode === 'image-reference') return ['image', 'video', 'audio']
return ['image', 'video']
},
posterUrl() {
const list = Array.isArray(this.mediaList) ? this.mediaList : []
const firstImage = list.find((i) => i?.mediaType === 'image') || list[0]
if (!firstImage?.url) return ''
// 预览封面主要用于参考图/首帧类模式
if (this.videoMode === 'image-reference') return firstImage.url
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return firstImage.url
return ''
},
sortedTaskRows() {
const list = Array.isArray(this.taskRows) ? this.taskRows : []
return [...list].sort((a, b) => {
const at = this.parseDateTimeToMs(a?.createTime)
const bt = this.parseDateTimeToMs(b?.createTime)
// 升序:对话越往下越新(底部最新)
if (at !== bt) return at - bt
// createTime 相同id 小的更旧排前id 大的更新,排后
return Number(a?.id || 0) - Number(b?.id || 0)
})
},
...mapGetters(['token'])
},
watch: {
token(newVal, oldVal) {
if (!newVal || newVal === oldVal) return
// 登录成功token 从无到有或切换账号)后补拉依赖鉴权的接口
this.loadInitialData()
}
},
mounted() {
this.loadInitialData()
},
beforeUnmount() {
this.destroyInterval()
},
methods: {
loadInitialData() {
this.loadPriceInfo()
this.loadTaskList(true).then(() => {
this.$nextTick(() => {
this.scrollChatToBottom()
})
})
this.loadVideoParams()
},
plainPromptText() {
return this.$refs.videoRichEditor?.getPlainText?.() || ''
},
bumpEditorVisualTick() {
this.editorVisualTick++
},
onVideoEditorTextChange() {
this.bumpEditorVisualTick()
if (this.videoMode === 'image-reference') {
this.saveReferenceDraftFromEditor()
}
},
saveReferenceDraftFromEditor() {
const ed = this.$refs.videoRichEditor
if (!ed || typeof ed.getDraftState !== 'function') return
try {
const draft = ed.getDraftState()
localStorage.setItem(REFERENCE_DRAFT_STORAGE_KEY, JSON.stringify(draft))
} catch (_) {
/* ignore quota / private mode */
}
},
restoreReferenceDraftToEditor() {
const ed = this.$refs.videoRichEditor
if (!ed || typeof ed.applyDraftState !== 'function') return
try {
const raw = localStorage.getItem(REFERENCE_DRAFT_STORAGE_KEY)
if (!raw) {
ed.clear?.()
return
}
const draft = JSON.parse(raw)
ed.applyDraftState(draft)
} catch (_) {
ed.clear?.()
}
},
/** 编辑器内插入的参考图 URL与 getContentItems 一致) */
firstReferenceImageUrlFromEditor() {
const items = this.$refs.videoRichEditor?.getContentItems?.() || []
for (const i of items) {
if (i.type === 'image_url' && i.image_url?.url) {
if (!i.role || i.role === 'reference_image') return i.image_url.url
}
}
return ''
},
pickVideoMode(m) {
this.videoMode = m
// 切换生成模式后清空参考素材,避免不同后端入参结构混用
this.mediaList = []
},
async loadTaskList(reset = false) {
try {
if (reset) {
this.chatPageNum = 1
this.chatHasMore = true
this.chatMaxHintShown = false
this.chatLoadingMore = false
this.taskRows = []
}
if (!this.chatHasMore || this.chatLoadingMore) return
const pageNum = this.chatPageNum
const pageSize = this.chatPageSize
this.chatLoadingMore = true
const res = await this.$axios({
url: 'api/portal/video/tasks',
method: 'GET',
params: { pageNum, pageSize }
})
const newRows = res.code === 200 ? res.rows || [] : []
// 合并去重(按 id
const existMap = new Map((this.taskRows || []).map((x) => [x.id, x]))
for (const row of newRows) {
if (!row || row.id == null) continue
if (existMap.has(row.id)) {
existMap.set(row.id, { ...existMap.get(row.id), ...row })
} else {
existMap.set(row.id, row)
}
}
const merged = Array.from(existMap.values())
// 始终保留“最新的最多 50 条”,避免向上翻页时截断造成顺序/条数错乱
this.taskRows = merged
.sort(
(a, b) =>
(this.parseDateTimeToMs(b?.createTime) -
this.parseDateTimeToMs(a?.createTime)) ||
(Number(b?.id || 0) - Number(a?.id || 0))
)
.slice(0, this.chatMaxTotal)
// 更新分页状态
if (this.taskRows.length >= this.chatMaxTotal) {
this.chatHasMore = false
} else {
if (newRows.length < pageSize) this.chatHasMore = false
this.chatPageNum = pageNum + 1
}
} catch (_) {
/* 未登录等忽略 */
} finally {
this.chatLoadingMore = false
}
},
async onChatScroll(e) {
const el = e?.target
if (!el) return
if (this.chatLoadingMore) return
// 向上滚动加载更早历史滚到顶部附近60px 内)触发下一页
if (el.scrollTop > 60) return
if (this.taskRows.length >= this.chatMaxTotal) {
if (!this.chatMaxHintShown) {
this.chatMaxHintShown = true
this.$message.warning(`最多加载 ${this.chatMaxTotal} 条历史数据`)
}
this.chatHasMore = false
return
}
// 记录旧高度,避免加载更多后用户位置跳动
const prevScrollHeight = el.scrollHeight
const prevScrollTop = el.scrollTop
await this.loadTaskList(false)
this.$nextTick(() => {
const newScrollHeight = el.scrollHeight
const diff = newScrollHeight - prevScrollHeight
// 保持相对视口位置:滚动高度增加多少,就把 scrollTop 增加多少
el.scrollTop = diff >= 0 ? prevScrollTop + diff : prevScrollTop
})
},
// 轮询成功/失败/取消后,只刷新第一页数据,避免把用户已滚动加载的历史重置回 10 条
async refreshChatFirstPage() {
try {
const res = await this.$axios({
url: 'api/portal/video/tasks',
method: 'GET',
params: { pageNum: 1, pageSize: this.chatPageSize }
})
const newRows = res.code === 200 ? res.rows || [] : []
const existMap = new Map((this.taskRows || []).map((x) => [x.id, x]))
for (const row of newRows) {
if (!row || row.id == null) continue
if (existMap.has(row.id)) {
existMap.set(row.id, { ...existMap.get(row.id), ...row })
} else {
existMap.set(row.id, row)
}
}
this.taskRows = Array.from(existMap.values())
.sort(
(a, b) =>
(this.parseDateTimeToMs(b?.createTime) -
this.parseDateTimeToMs(a?.createTime)) ||
(Number(b?.id || 0) - Number(a?.id || 0))
)
.slice(0, this.chatMaxTotal)
} catch (_) {
/* ignore */
}
},
async refreshChatList() {
await this.loadTaskList(true)
this.$nextTick(() => {
this.scrollChatToBottom()
})
},
scrollChatToBottom() {
const el = this.$refs.chatListRef
if (!el) return
el.scrollTop = el.scrollHeight
},
taskRowResultTrim(row) {
let r = String(row?.result ?? '').trim()
if (r) return r
const raw = row?.videoParams
if (raw == null || raw === '') return ''
if (typeof raw !== 'string') return ''
try {
const o = JSON.parse(raw)
const id = o?.volcTaskId
if (id != null && String(id).trim() !== '') return String(id).trim()
} catch (_) {
/* ignore */
}
return ''
},
taskStatusText(row) {
const st = row?.status
if (st === 2 || st === '2') return '已失败/已取消'
if (st === 0 || st === '0') {
const extStatus = row?.extStatus
if (extStatus === 0) {
return '队列中'
}
return '执行中'
}
if (st === 1 || st === '1') {
const r = this.taskRowResultTrim(row)
if (!r) return '失败'
if (this.isHttpOrHttpsUrl(r)) {
return '已完成'
}
// 任务 id 格式由火山侧决定,不再假定 cgt 前缀;非 URL 的中间态均视为执行中
const extStatus = row?.extStatus
if (extStatus === 0) {
return '队列中'
}
return '任务执行中'
}
const extStatus = row?.extStatus
if (extStatus === 0) {
return '队列中'
}
return '执行中'
},
/** 是否展示完整「结果区」(视频 / 链接);其余状态仅紧凑展示在用户行右侧 */
isChatRowSuccessWithMedia(row) {
const r = this.taskRowResultTrim(row)
const st = row?.status
return (st === 1 || st === '1') && !!r && this.isHttpOrHttpsUrl(r)
},
chatRowInlineStatusClass(row) {
const st = row?.status
if (st === 2 || st === '2') return 'vg-chat-inline-status--failed'
if (st === 1 || st === '1') {
const r = this.taskRowResultTrim(row)
if (!r) return 'vg-chat-inline-status--failed'
return 'vg-chat-inline-status--running'
}
return 'vg-chat-inline-status--running'
},
parseDateTimeToMs(value) {
if (!value) return 0
const ms = new Date(value).getTime()
return Number.isFinite(ms) ? ms : 0
},
formatCreateTime(value) {
const ms = this.parseDateTimeToMs(value)
if (!ms) return ''
return new Date(ms).toLocaleString()
},
/** 对话结果是否为可访问的 http(s) 链接(任务 id 等非 URL 不视为已完成) */
isHttpOrHttpsUrl(value) {
const u = String(value || '').trim()
return /^https?:\/\//i.test(u)
},
isVideoUrl(url) {
const u = String(url || '')
if (!u) return false
return /\.(mp4|webm|mov|ogg|m4v|avi|mkv)(\?.*)?$/i.test(u)
},
getRowAttachmentList(row) {
const list = []
if (row?.img1) list.push(row.img1)
if (row?.img2 && row.img2 !== row.img1) list.push(row.img2)
// text-to-video附件 url 可能落在 videoParams.content 里
if (!list.length && row?.videoParams) {
try {
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
const content = vp?.content
if (Array.isArray(content)) {
for (const item of content) {
const url = item?.image_url?.url
if (url) list.push(url)
}
}
} catch (_) {
// ignore parse error
}
}
return list
.filter((url) => !!url)
.map((url) => ({
url,
mediaType: this.isVideoUrl(url) ? 'video' : 'image'
}))
},
getRowReferenceMediaByType(row) {
const images = []
const videos = []
const audios = []
if (!row?.videoParams) return { images, videos, audios }
try {
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
const content = Array.isArray(vp?.content) ? vp.content : []
for (const item of content) {
if (item?.type === 'image_url' && (!item.role || item.role === 'reference_image')) {
const url = item?.image_url?.url
if (url) images.push(url)
}
if (item?.type === 'video_url' && item?.role === 'reference_video') {
const url = item?.video_url?.url
if (url) videos.push(url)
}
if (item?.type === 'audio_url' && item?.role === 'reference_audio') {
const url = item?.audio_url?.url
if (url) audios.push(url)
}
}
} catch (_) {
// ignore parse error
}
return { images, videos, audios }
},
getRowPromptSegments(row) {
const rawText = row?.text || '—'
const text = String(rawText)
const mode = String(row?.mode || '').trim()
if (mode === 'image-first-frame') {
const segs = []
if (row?.img1) segs.push({ type: 'image', url: row.img1, token: '[首帧]' })
segs.push({ type: 'text', text })
return segs
}
if (mode === 'image-first-last-frame') {
const segs = []
if (row?.img1) segs.push({ type: 'image', url: row.img1, token: '[首帧]' })
segs.push({ type: 'text', text })
if (row?.img2) segs.push({ type: 'image', url: row.img2, token: '[尾帧]' })
return segs
}
// 参考素材:[图片n][视频n][音频n] 与 content 中同类序号对应;兼容旧 [图n]
const refs = this.getRowReferenceMediaByType(row)
const hasRefPayload =
refs.images.length > 0 || refs.videos.length > 0 || refs.audios.length > 0
const hasRefTokens = /\[图片\d+\]|\[视频\d+\]|\[音频\d+\]|\[图\d+\]/.test(text)
if (!hasRefPayload && !hasRefTokens) {
return [{ type: 'text', text }]
}
const tokenReg = /(\[图片(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]|\[图(\d+)\])/g
const segments = []
let last = 0
let m
while ((m = tokenReg.exec(text)) !== null) {
const token = m[0]
const start = m.index
if (start > last) {
segments.push({ type: 'text', text: text.slice(last, start) })
}
let url = ''
let segType = 'image'
if (m[2] != null) {
url = refs.images[Number(m[2]) - 1] || ''
segType = 'image'
} else if (m[3] != null) {
url = refs.videos[Number(m[3]) - 1] || ''
segType = 'video'
} else if (m[4] != null) {
url = refs.audios[Number(m[4]) - 1] || ''
segType = 'audio'
} else if (m[5] != null) {
url = refs.images[Number(m[5]) - 1] || ''
segType = 'image'
}
if (url && /^asset:\/\//i.test(url)) {
segments.push({ type: 'text', text: token })
} else if (url) {
segments.push({ type: segType, url, token })
} else {
segments.push({ type: 'text', text: token })
}
last = start + token.length
}
if (last < text.length) {
segments.push({ type: 'text', text: text.slice(last) })
}
return segments.length ? segments : [{ type: 'text', text }]
},
async cancelRowTask(row) {
const tid = row.result
if (!tid) return
await this.cancelTaskById(tid)
await this.refreshChatFirstPage()
},
parseVideoParams(row) {
if (!row?.videoParams) return null
try {
return typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
} catch (_) {
return null
}
},
videoModeLabel(mode) {
const m = String(mode || '').trim()
if (!m) return '—'
const hit = this.videoModeOptions.find((o) => o.value === m)
return hit ? hit.label : m
},
modelLabelForValue(val) {
if (!val) return ''
const hit = this.modelOptions.find((o) => o.value === val)
return hit ? hit.label : ''
},
rowChatParams(row) {
const vp = this.parseVideoParams(row)
const modelVal = row?.model ?? vp?.model ?? ''
const ratio = row?.ratio ?? vp?.ratio ?? ''
const durRaw = row?.duration != null ? row.duration : vp?.duration
const resolution = row?.resolution ?? vp?.resolution ?? ''
const mode = row?.mode || vp?.generationMode || ''
return {
modeLabel: this.videoModeLabel(mode),
modelLabel: this.modelLabelForValue(modelVal) || modelVal || '—',
ratio: ratio || '—',
duration: durRaw != null && durRaw !== '' ? `${durRaw}` : '—',
resolution: resolution || '—'
}
},
rowChatParamsVisible(row) {
return !!(
row &&
(row.mode ||
row.model ||
row.ratio ||
row.duration != null ||
row.resolution ||
row.videoParams)
)
},
getRowPromptTextForRestore(row) {
if (row?.text) return row.text
const vp = this.parseVideoParams(row)
const c = vp?.content?.[0]
if (c?.type === 'text' && c.text) return c.text
return ''
},
buildMediaListForTextToVideo(row) {
const out = []
try {
const vp = this.parseVideoParams(row)
const content = vp?.content
if (Array.isArray(content)) {
for (const item of content) {
if (item?.type === 'image_url' && item.image_url?.url) {
out.push({
id: `hist_${out.length}_${Date.now()}`,
url: item.image_url.url,
mediaType: 'image',
name: ''
})
}
}
}
} catch (_) {}
if (!out.length) {
return this.getRowAttachmentList(row).map((x, i) => ({
id: `att_${row.id || ''}_${i}`,
url: x.url,
mediaType: x.mediaType || 'image',
name: ''
}))
}
return out
},
async applyTaskRowToForm(row) {
if (!row) return
const mode = row.mode || 'text-to-video'
if (row.model && this.modelOptions.some((o) => o.value === row.model)) {
this.selectedModel = row.model
}
if (row.ratio && this.ratioOptions.includes(row.ratio)) {
this.selectedRatio = row.ratio
}
const dur = row.duration != null ? Number(row.duration) : null
if (dur != null && this.durationOptions.includes(dur)) {
this.selectedDuration = dur
}
if (row.resolution && this.resolutionOptions.includes(row.resolution)) {
this.selectedResolution = row.resolution
}
if (mode === 'image-reference') {
try {
localStorage.removeItem('video_reference_media')
} catch (_) {}
}
this.videoMode = mode
const prompt = this.getRowPromptTextForRestore(row)
if (mode !== 'image-reference') {
this.promptText = prompt
} else {
this.promptText = ''
}
await this.$nextTick()
if (mode === 'text-to-video') {
this.mediaList = this.buildMediaListForTextToVideo(row)
} else if (mode === 'image-first-frame') {
this.mediaList = row.img1
? [{ id: 'r1', url: row.img1, mediaType: 'image', name: '' }]
: []
} else if (mode === 'image-first-last-frame') {
const list = []
if (row.img1) list.push({ id: 'r1', url: row.img1, mediaType: 'image', name: '' })
if (row.img2) list.push({ id: 'r2', url: row.img2, mediaType: 'image', name: '' })
this.mediaList = list
} else if (mode === 'image-reference') {
this.mediaList = []
await this.$nextTick()
await this.$refs.videoComposeRef?.loadReferenceFromTaskRow?.(row)
}
},
async editFromTaskRow(row) {
try {
await this.applyTaskRowToForm(row)
this.$message.success('已载入该条任务到编辑区,可修改后再次生成')
this.$nextTick(() => {
const el = this.$refs.videoComposeRef?.$el
if (el && typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
})
} catch (_) {
this.$message.error('载入失败,请重试')
}
},
regenerateFromTaskRow(row) {
this.$confirm({
title: '重新生成',
content: '将按该条记录的提示词与参数再次提交任务,是否继续?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
await this.applyTaskRowToForm(row)
await this.$nextTick()
await this.generateVideo()
}
})
},
downloadChatVideo(row) {
const url = row?.result
if (!url || !this.isHttpOrHttpsUrl(url)) return
const a = document.createElement('a')
a.href = url
a.download = `video_${row.id || Date.now()}.mp4`
a.target = '_blank'
a.rel = 'noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
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
})
},
async loadVideoParams() {
try {
const res = await this.$axios({
url: 'api/portal/video/options',
method: 'GET'
})
if (res.code !== 200 || !res.data) return
const { defaults, models, ratios, durations, resolutions } = res.data
this.modelOptions = Array.isArray(models) ? models : []
this.ratioOptions = Array.isArray(ratios) ? ratios : []
this.durationOptions = Array.isArray(durations) ? durations.map((n) => Number(n)) : []
this.resolutionOptions = Array.isArray(resolutions) ? resolutions : []
const d = defaults || {}
if (d.model) {
this.selectedModel = d.model
} else if (this.modelOptions.length) {
this.selectedModel = this.modelOptions[0].value
}
if (this.ratioOptions.includes('16:9')) {
this.selectedRatio = '16:9'
} else if (d.ratio && this.ratioOptions.includes(d.ratio)) {
this.selectedRatio = d.ratio
} else if (this.ratioOptions.length) {
this.selectedRatio = this.ratioOptions[0]
}
const durNum = d.duration != null ? Number(d.duration) : null
if (durNum != null && this.durationOptions.includes(durNum)) {
this.selectedDuration = durNum
} else if (this.durationOptions.length) {
this.selectedDuration = this.durationOptions[0]
}
if (d.resolution && this.resolutionOptions.includes(d.resolution)) {
this.selectedResolution = d.resolution
} else if (this.resolutionOptions.length) {
this.selectedResolution = this.resolutionOptions[0]
}
} catch (_) {
this.$message?.warning?.('加载视频参数配置失败')
}
},
clearReferenceCompose() {
this.$refs.videoComposeRef?.clearAll?.()
},
async generateVideo() {
if (this.generateLoading) return
const prompt = (this.promptText || '').trim()
const text = prompt || '一个优雅的女孩在阳光下跳舞'
const list = Array.isArray(this.mediaList) ? this.mediaList : []
if (list.some((x) => x && x.isUploading)) {
this.$message.warning('素材正在上传中,请稍后再生成')
return
}
const attachments = list.filter((x) => x && x.url)
if (!this.selectedModel || !this.selectedRatio || this.selectedDuration == null || !this.selectedResolution) {
this.$message.error('请选择模型、比例、时长与分辨率')
return
}
// 组装入参:只依赖 promptText + mediaList
const params = {
text,
functionType: '21',
model: this.selectedModel,
ratio: this.selectedRatio,
duration: this.selectedDuration,
resolution: this.selectedResolution
}
if (this.videoMode === 'text-to-video') {
if (!prompt && attachments.length === 0) {
this.$message.error('请输入视频描述或添加参考素材')
return
}
const contentItems = []
if (prompt) {
contentItems.push({
type: 'text',
text: prompt
})
}
if (attachments.length) {
contentItems.push(
...attachments.map((item) => ({
type: 'image_url',
image_url: { url: item.url },
role: 'reference_image'
}))
)
}
if (contentItems.length) {
params.content = contentItems
}
}
if (this.videoMode === 'image-first-frame') {
const first = attachments[0]
if (!first) {
this.$message.error(this.$t('common.uploadFirstImageError') || '请上传首帧图')
return
}
if (first.mediaType !== 'image') {
this.$message.error('首帧模式仅支持图片素材')
return
}
params.firstUrl = first.url
}
if (this.videoMode === 'image-first-last-frame') {
const first = attachments[0]
const last = attachments[1]
if (!first) {
this.$message.error(this.$t('common.uploadFirstImageError') || '请上传首帧图')
return
}
if (!last) {
this.$message.error('请上传尾帧图')
return
}
if (first.mediaType !== 'image' || last.mediaType !== 'image') {
this.$message.error('首尾帧模式仅支持图片素材')
return
}
params.firstUrl = first.url
params.lastUrl = last.url
}
if (this.videoMode === 'image-reference') {
const compose = this.$refs.videoComposeRef
const contentItems =
compose && typeof compose.getImageReferenceContentItems === 'function'
? compose.getImageReferenceContentItems()
: []
const first = contentItems[0]
if (!first || first.type !== 'text') {
this.$message.error('参考图内容格式异常,请重试')
return
}
params.text = first.text || text
params.content = contentItems
// 后端要求reference_url 需要使用资产标识asset://assetId时优先传 asset://
// 展示用仍然可以是 https url但提交 content/reference_url 要与 assetId 关联。
const firstPreview = attachments.find((x) => x?.mediaType === 'image')
if (firstPreview) {
const au = String(firstPreview?.assetUrl || '').trim()
const aid = String(firstPreview?.assetId || '').trim()
params.referenceUrl = au || (aid ? `asset://${aid}` : firstPreview.url)
}
}
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]
this.generateLoading = true
try {
const res = await this.$axios({
url: path,
method: 'POST',
data: params
})
if (res.code == 200) {
this.$message.success('已经提交任务,请等待或者稍后到个人中心查看作品')
this.videoId = res.data.id
this.showResult = true
this.$refs.videoComposeRef?.clearPromptOnly?.()
this.getVideo(res.data.id)
this.refreshChatFirstPage()
} else if (res.code == -1) {
this.$confirm({
title: this.$t('common.notice'),
content: this.$t('common.balenceLow'),
onOk: () => this.$router.push('/recharge')
})
} else {
const reason = res.msg || res.message || '提交失败'
this.$message.error(`${reason},如需帮助请联系管理员`)
}
} catch (err) {
const tip =
typeof err === 'string' && err
? err
: err?.message || err?.response?.data?.msg || '网络或服务异常,请稍后重试'
this.$message.error(`${tip},如需帮助请联系管理员`)
} finally {
this.generateLoading = false
}
},
getVideo(videoId) {
let attempts = 0
this.interval = setInterval(() => {
attempts++
if (attempts > this.maxPollAttempts) {
this.$message.warning(
'等待结果超时:任务可能仍在云端生成,可稍后在「对话记录」刷新或向客服提供任务 ID'
)
this.destroyInterval()
return
}
this.$axios({
url: `api/portal/video/tasks/${videoId}`,
method: 'GET'
}).then((res) => {
if (res.code != 200) {
this.destroyInterval()
this.$message.error(res.msg || '查询生成状态失败')
return
}
const st = res.data != null && res.data.status != null ? String(res.data.status).toLowerCase().trim() : ''
if (st === 'succeeded') {
this.videoUrl = res.data.content?.video_url || res.data.video_url
if (!this.videoUrl) {
this.$message.warning('任务已完成但未返回视频地址,请稍后重试或联系管理员')
}
this.destroyInterval()
this.refreshChatFirstPage()
return
}
if (st === 'failed' || st === 'cancelled' || st === 'canceled') {
this.destroyInterval()
this.$message.error(
res.data.error?.message || res.data.message || '视频生成失败或已取消'
)
this.refreshChatFirstPage()
}
}).catch(() => {
/* 单次网络错误不停止轮询,避免偶发抖动 */
})
}, this.pollIntervalMs)
},
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.refreshChatFirstPage()
} else {
this.$message.error(res.msg || '取消失败')
}
} catch (error) {
this.$message.error('取消请求失败')
}
resolve()
},
onCancel: () => resolve()
})
})
},
// 收藏/取消收藏 - 复用生成库接口
async toggleFavorite(row) {
if (!row || !row.id) {
this.$message.error('无效的任务记录')
return
}
const newIsTop = row.isTop === 'Y' ? 'N' : 'Y'
try {
const res = await this.$axios({
url: '/api/portal/assets/favorite',
method: 'POST',
data: {
id: row.id,
isTop: newIsTop
}
})
if (res.code === 200) {
// 更新本地数据
row.isTop = newIsTop
this.$message.success(newIsTop === 'Y' ? '收藏成功' : '已取消收藏')
} else {
this.$message.error(res.msg || '操作失败')
}
} catch (err) {
this.$message.error(err?.message || '收藏操作失败')
}
}
}
}
</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%;
height: 100%;
gap: 14px;
padding: 20px 20px 16px;
box-sizing: border-box;
overflow-y: hidden;
overflow-x: hidden;
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);
}
/* —— Body与对话区约 1:3整体约 25% / 75% —— */
.vg-body {
display: flex;
flex: 1 1 0%;
order: 1;
min-height: 0;
max-height: none;
gap: 12px;
align-items: stretch;
}
.vg-left-rail {
flex: 0 0 200px;
min-width: 176px;
min-height: 0;
overflow-y: auto;
padding: 18px 14px;
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);
align-self: stretch;
}
.vg-rail-title {
margin: 0 0 14px;
font-size: 12px;
color: var(--vg-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.vg-mode-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.vg-mode-item {
padding: 11px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
color: var(--vg-muted);
cursor: pointer;
border: 1px solid transparent;
background: rgba(0, 0, 0, 0.22);
transition: color 0.15s, border-color 0.15s, background 0.15s;
&:hover {
color: var(--vg-text);
background: rgba(255, 255, 255, 0.05);
}
&.active {
color: var(--vg-cyan);
border-color: rgba(0, 202, 224, 0.35);
background: rgba(0, 202, 224, 0.1);
}
}
.vg-generator {
flex: 1;
min-width: 280px;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.vg-generator-inner {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
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;
overflow: hidden;
:deep(.vg-compose-root) {
flex: 1;
min-height: 0;
max-height: 100%;
}
}
.rich-editor-container {
flex: 1;
min-height: 180px;
margin: 0;
display: flex;
flex-direction: column;
:deep(.video-editor-root) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 200px;
}
}
.vg-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
padding-top: 10px;
border-top: 1px solid var(--vg-border);
}
.vg-toolbar-settings {
flex: 1;
min-width: 0;
}
.vg-params-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
border: 1px solid var(--vg-border);
}
.vg-param {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
flex: 1 1 160px;
min-width: 0;
}
.vg-param-label {
font-size: 11px;
color: var(--vg-muted);
font-weight: 600;
white-space: nowrap;
min-width: 44px;
}
.vg-param-select {
flex: 1;
min-width: 0;
:deep(.arco-select-view-single) {
background: rgba(0, 0, 0, 0.2) !important;
border: 1px solid var(--vg-border) !important;
border-radius: 8px !important;
color: var(--vg-text) !important;
min-height: 30px !important;
}
:deep(.arco-select-view-value) {
color: var(--vg-text) !important;
font-size: 12px;
}
}
/* 参考图:工具条在富文本右侧窄列内,清空与提交纵向排列 */
.vg-toolbar--reference-submit {
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0;
margin-top: 0;
padding-top: 0;
border-top: none;
width: 100%;
min-height: 0;
}
.vg-toolbar-actions {
flex-shrink: 0;
align-self: center;
display: flex;
align-items: center;
gap: 8px;
}
.vg-toolbar-actions--reference {
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 14px;
width: 100%;
align-self: center;
padding-top: 0;
}
.vg-toolbar-clear:deep(.arco-btn) {
color: rgba(255, 255, 255, 0.75);
}
.vg-toolbar-clear:deep(.arco-btn:hover) {
color: #7eeaf2;
background: rgba(0, 202, 224, 0.1);
}
/* 参考图模式:生成参数两列网格 */
.vg-params-row--reference-col {
display: grid;
/* 两列:
左列:生成模式 / 模型 / 分辨率(同一列,上下排列)
右列:比例 / 时长(同一列,上下排列)
且左列更宽,保证左列下拉框宽度 > 右列 */
grid-template-columns: 2fr 1fr;
grid-auto-rows: auto;
align-items: start;
gap: 8px 10px;
padding: 6px 8px;
min-height: 0;
flex-wrap: nowrap;
}
.vg-params-row--reference-col .vg-param {
flex: none;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.vg-params-row--reference-col > .vg-param:nth-child(1) {
grid-column: 1;
grid-row: 1;
}
.vg-params-row--reference-col > .vg-param:nth-child(2) {
grid-column: 1;
grid-row: 2;
}
.vg-params-row--reference-col > .vg-param:nth-child(3) {
grid-column: 2;
grid-row: 1;
}
.vg-params-row--reference-col > .vg-param:nth-child(4) {
grid-column: 2;
grid-row: 2;
}
.vg-params-row--reference-col > .vg-param:nth-child(5) {
grid-column: 1;
grid-row: 3;
}
.vg-params-row--reference-col .vg-param-label {
min-width: 0;
}
@media (max-width: 520px) {
.vg-params-row--reference-col {
grid-template-columns: 1fr;
}
}
.vg-submit-circle {
width: 42px;
height: 42px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
/* 稍微下移:让按钮对齐参数区视觉中线 */
margin-top: 6px;
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;
}
/* —— 右侧预览(收窄宽度、视频限高,内容可滚动) —— */
.right {
flex: 0 1 360px;
min-width: 260px;
max-width: min(360px, 100%);
min-height: 0;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--vg-border);
border-radius: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
&.has-result {
align-items: stretch;
justify-content: flex-start;
padding: 16px;
}
}
.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;
}
}
.result-container {
width: 100%;
box-sizing: border-box;
}
@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: 320px;
margin: 0 auto;
}
.result-video {
display: block;
width: 100%;
max-width: 100%;
max-height: min(42vh, 360px);
height: auto;
object-fit: contain;
background: #000;
border-radius: 12px;
}
.result-actions {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
@media (max-width: 768px) {
.vg-body {
flex: 1 1 auto;
flex-direction: column;
max-height: none;
min-height: min(36vh, 400px);
}
.vg-left-rail {
flex: none;
width: 100%;
min-width: 0;
}
.vg-mode-list {
flex-direction: row;
flex-wrap: wrap;
}
.vg-mode-item {
flex: 1 1 calc(50% - 4px);
min-width: 140px;
text-align: center;
}
.vg-generator {
flex: none;
width: 100%;
max-height: none;
}
.right {
flex: 1 1 auto;
max-width: none;
min-height: 260px;
max-height: min(55vh, 480px);
}
/* 对话内视频小屏占满宽,避免横向截断 */
.vg-chat-result video {
width: 100%;
max-width: 100%;
}
}
.vg-task-empty {
margin: 0;
font-size: 13px;
color: var(--vg-muted);
}
.vg-chat-section {
order: 0;
flex: 3 1 0%;
margin-top: 0;
padding: 14px 18px;
background: var(--vg-panel);
border: 1px solid var(--vg-border);
border-radius: 16px;
min-height: 0;
display: flex;
flex-direction: column;
}
.vg-chat-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.vg-chat-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--vg-text);
}
.vg-chat-list {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 14px;
max-height: none;
overflow: auto;
padding-right: 4px;
}
.vg-chat-block {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 16px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.vg-chat-block--compact {
padding: 12px 16px;
}
.vg-chat-divider {
height: 1px;
margin: 4px 0 0;
background: rgba(255, 255, 255, 0.08);
border: none;
flex-shrink: 0;
}
/* 用户输入区域 */
.vg-chat-user-section {
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.vg-chat-user-section--compact {
padding-bottom: 0;
border-bottom: none;
}
.vg-chat-ai-section {
padding-top: 16px;
}
.vg-chat-user-text {
font-size: 13px;
color: var(--vg-text);
white-space: pre-wrap;
line-height: 1.7;
margin-bottom: 12px;
}
.vg-chat-inline-ref-image {
display: inline-block;
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 6px;
vertical-align: middle;
margin: 0 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.vg-chat-inline-ref-video {
display: inline-block;
width: 120px;
max-height: 72px;
vertical-align: middle;
margin: 0 4px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.vg-chat-inline-audio {
display: inline-block;
vertical-align: middle;
margin: 0 4px;
max-width: 200px;
height: 32px;
}
.vg-chat-time {
margin-top: 8px;
font-size: 12px;
color: var(--vg-muted);
}
.vg-chat-user-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.vg-chat-user-col-main {
flex: 1;
min-width: 0;
}
.vg-chat-user-col-main .vg-chat-time {
margin-top: 8px;
}
.vg-chat-user-col-status {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
max-width: 42%;
text-align: right;
}
.vg-chat-inline-status {
font-size: 12px;
font-weight: 600;
line-height: 1.35;
text-align: right;
word-break: break-word;
}
.vg-chat-inline-status--running {
color: var(--vg-cyan);
}
.vg-chat-inline-status--failed {
color: rgba(255, 107, 107, 0.95);
}
.vg-chat-inline-cancel {
font-size: 12px;
padding: 0;
}
.vg-chat-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
margin-bottom: 6px;
}
.vg-chat-param-chip {
font-size: 11px;
line-height: 1.35;
padding: 3px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
}
.vg-chat-task-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.vg-chat-action-btn {
font-size: 12px;
padding: 0;
}
.vg-chat-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.favorite-btn {
color: #ff9800 !important;
}
.favorite-btn.is-favorited {
color: #ffeb3b !important;
font-weight: 600;
}
.vg-chat-ai-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.vg-chat-download-btn {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(0, 202, 224, 0.45);
background: rgba(0, 202, 224, 0.12);
color: var(--vg-cyan);
cursor: pointer;
}
.vg-chat-download-btn:hover {
background: rgba(0, 202, 224, 0.2);
}
.vg-chat-ai-status {
font-size: 13px;
color: var(--vg-cyan);
font-weight: 600;
}
.vg-chat-ai-time {
font-size: 12px;
color: var(--vg-muted);
}
.vg-chat-loading {
padding: 16px 0;
font-size: 13px;
color: var(--vg-muted);
text-align: center;
}
.vg-chat-failed {
padding: 16px 0;
font-size: 13px;
color: rgba(255, 107, 107, 0.95);
text-align: center;
}
.vg-chat-result {
margin-top: 8px;
}
.vg-chat-result video {
width: 33%;
max-width: 33%;
margin: 0;
display: block;
max-height: 240px;
border-radius: 12px;
background: #000;
}
.vg-chat-result-link {
padding: 10px 0;
}
.vg-chat-result-link a {
color: var(--vg-cyan);
}
.vg-chat-ai-actions {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.vg-chat-loadmore {
padding: 6px 0;
font-size: 12px;
color: var(--vg-muted);
text-align: center;
}
.vg-task-section {
flex-shrink: 0;
margin-top: 8px;
padding: 18px 22px;
background: var(--vg-panel);
border: 1px solid var(--vg-border);
border-radius: 16px;
min-height: 0;
}
.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;
}
/* 小于 1920×1080工具条参数纵向排布整体仍保持约 75%/25% 弹性分配 */
@media (max-width: 1919px) {
.video-gen {
padding: 14px 12px 12px;
gap: 10px;
}
.vg-chat-section {
padding: 12px 14px;
}
.vg-generator-inner {
padding: 10px 12px;
gap: 8px;
}
.vg-toolbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.vg-toolbar-actions {
align-self: flex-end;
}
.vg-params-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.vg-param {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
}
</style>