2338 lines
57 KiB
Vue
2338 lines
57 KiB
Vue
<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>
|