fix: 生成页面优化布局

This commit is contained in:
old burden 2026-04-02 15:56:46 +08:00
parent eb5211b9e4
commit 0d4b1d18be
6 changed files with 845 additions and 268 deletions

View File

@ -51,12 +51,12 @@ export default {
this.$router.replace('/403')
},
ok() {
// 18+
// 18+
Promise.resolve(this.$store.dispatch('main/setForbidden', false)).finally(() => {
// zh_HK
this.$store.dispatch('main/setLanguage', 'zh_HK')
i18n.global.locale = 'zh_HK'
this.$router.push({ name: 'video-gen' })
this.$router.push({ name: 'index' })
})
}
}

View File

@ -4,19 +4,27 @@
class="vg-compose-card"
:class="{
'vg-compose-card--reference': isReference,
'vg-compose-card--split': !isReference && !isTextToVideo,
'vg-compose-card--solo': isTextToVideo
'vg-compose-card--split': !isReference
}">
<!-- 参考图模式四列 参考图 / 资产 / 选择区域 / 富文本 -->
<template v-if="isReference">
<div class="vg-compose-mod vg-compose-mod--ref">
<div
class="vg-compose-mod vg-compose-mod--ref"
:class="{ 'vg-compose-mod--dragover': referenceDragDepth > 0 }"
@dragenter.prevent="onReferenceDragEnter"
@dragleave.prevent="onReferenceDragLeave"
@dragover.prevent="onReferenceDragOver"
@drop.prevent="onReferenceDrop"
@paste="onReferencePaste">
<div class="vg-mod-body">
<div
v-if="mediaList.length === 0"
class="vg-compose-empty vg-compose-empty--compact"
@click="onReferenceEmptyAreaClick">
<div class="vg-compose-empty-icon" aria-hidden="true">+</div>
<div class="vg-compose-empty-text">上传或从右侧选择素材</div>
<div class="vg-compose-empty-text">
点击或拖拽/粘贴添加参考仅云存储不入库上传资产会写入素材库并加入参考
</div>
</div>
<div v-else class="vg-compose-media-scroll vg-compose-media-scroll--vertical">
<div
@ -78,7 +86,7 @@
size="small"
type="primary"
:disabled="!hasAssetGroupId"
@click="openFilePicker">
@click="openFilePickerForAssetUpload">
上传资产
</mf-button>
</span>
@ -92,8 +100,16 @@
</div>
</template>
<!-- 首帧 / 首尾帧保留左侧素材列 -->
<div class="vg-compose-left" v-else-if="!isTextToVideo">
<!-- 文生视频不展示参考图区域块仅展示参数选择 -->
<div class="vg-compose-left" v-else-if="isTextToVideo">
<div class="vg-compose-mod vg-compose-mod--pick">
<div class="vg-mod-body vg-mod-body--params">
<slot name="reference-params"></slot>
</div>
</div>
</div>
<div class="vg-compose-left vg-compose-left--two-col" v-else-if="!isTextToVideo">
<!-- 首帧模式 -->
<div v-if="isFirstFrame" class="vg-first-frame-panel">
<div class="vg-panel-title">首帧图</div>
@ -141,17 +157,19 @@
</div>
</div>
</div>
<!-- 参数选择列已由 VideoGen.vue 统一通过 reference-params slot 渲染 -->
<div class="vg-compose-mod vg-compose-mod--pick">
<div class="vg-mod-body vg-mod-body--params">
<slot name="reference-params"></slot>
</div>
</div>
</div>
<div class="vg-compose-right" :class="{ 'vg-compose-right--reference': isReference }">
<div v-if="!isReference" class="vg-compose-right-head">
<div class="vg-compose-right-title">描述画面与动态</div>
<mf-button size="small" type="text" class="vg-compose-clear" @click="clearAll">
清空
</mf-button>
</div>
<div :class="['vg-compose-right-body', { 'vg-reference-editor-row': isReference }]">
<div
class="vg-compose-right"
:class="{ 'vg-compose-right--reference': isReference || isTextToVideo || isFirstFrame || isFirstLastFrame }">
<div class="vg-compose-right-body vg-reference-editor-row">
<div class="vg-rich-editor-wrap">
<div
ref="editorRef"
@ -182,12 +200,9 @@
</div>
</div>
<div v-if="isReference" class="vg-reference-actions-col">
<div class="vg-reference-actions-col">
<slot name="toolbar"></slot>
</div>
<template v-else>
<slot name="toolbar"></slot>
</template>
</div>
</div>
@ -300,6 +315,10 @@ const assetGroups = ref([])
const assetPickerVisible = ref(false)
const assetQueryResults = ref([])
const assetPickerSelectedKeys = ref([])
/** 参考图区拖拽高亮(嵌套 dragenter/leave 计数) */
const referenceDragDepth = ref(0)
/** 仅「上传资产」按钮为 true左侧直接上传/拖拽/粘贴为 false */
const createAssetIntent = ref(false)
watch(
() => props.modelValue,
@ -382,18 +401,34 @@ const buildReferenceListAssetsPayload = () => {
}
const onReferenceEmptyAreaClick = () => {
openReferenceDirectUpload()
}
/** 参考图区直接上传(仅 COS不调 createAsset */
const openReferenceDirectUpload = () => {
if (!isReference.value) return
createAssetIntent.value = false
currentUploadIndex.value = -1
fileInputRef.value?.click()
}
/** 仅「上传资产」COS + createAsset 并写入素材组 */
const openFilePickerForAssetUpload = () => {
if (!hasAssetGroupId.value) {
Message.warning('请先选择素材组')
return
}
openFilePicker()
createAssetIntent.value = true
currentUploadIndex.value = -1
fileInputRef.value?.click()
}
const openFilePicker = () => {
if (isReference.value && !hasAssetGroupId.value) {
Message.warning('请先选择素材组')
if (isReference.value) {
openReferenceDirectUpload()
return
}
createAssetIntent.value = false
currentUploadIndex.value = -1
fileInputRef.value?.click()
}
@ -601,20 +636,17 @@ const confirmPickAssets = () => {
assetPickerSelectedKeys.value = []
}
const handleSelectFiles = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
if (!files.length) return
const processFilesList = async (files, options = {}) => {
if (!files || !files.length) return
const isReferenceMode = isReference.value
if (isReferenceMode && !String(assetGroupId.value || '').trim()) {
const wantCreateAsset = isReferenceMode && options.createAsset === true
if (wantCreateAsset && !String(assetGroupId.value || '').trim()) {
Message.warning('请先选择素材组')
input.value = ''
return
}
let targetList = [...mediaList.value]
//
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
const idx = currentUploadIndex.value
if (files.length > 0) {
@ -622,20 +654,25 @@ const confirmPickAssets = () => {
const mediaType = detectMediaType(file)
if (mediaType !== 'image') {
Message.warning('首尾帧仅支持图片')
input.value = ''
return
}
const id = `frame_${Date.now()}`
const localPreview = URL.createObjectURL(file)
const entry = { id, url: localPreview, mediaType: 'image', name: file.name, _fileRef: file, isUploading: true, label: idx === 0 ? '[首帧]' : '[尾帧]' }
const entry = {
id,
url: localPreview,
mediaType: 'image',
name: file.name,
_fileRef: file,
isUploading: true,
label: idx === 0 ? '[首帧]' : '[尾帧]'
}
targetList[idx] = entry
}
} else {
//
const remain = props.maxMediaCount - mediaList.value.length
if (remain <= 0 && !isReferenceMode) {
Message.warning(`最多添加 ${props.maxMediaCount} 个参考素材`)
input.value = ''
return
}
@ -671,8 +708,7 @@ const confirmPickAssets = () => {
setMediaList(targetList)
await nextTick()
//
const toUpload = targetList.filter(item => item.isUploading)
const toUpload = targetList.filter((item) => item.isUploading)
for (const entry of toUpload) {
try {
const res = await uploadFile({
@ -683,7 +719,7 @@ const confirmPickAssets = () => {
const url = extractUploadUrlFromResponse(res)
if (!url) throw new Error(res?.msg || '未返回文件地址')
let assetId = entry.assetId || ''
if (isReferenceMode) {
if (wantCreateAsset) {
const gid = String(assetGroupId.value || '').trim()
if (!gid) throw new Error('请先选择素材组')
const mt = entry.mediaType || 'image'
@ -715,7 +751,7 @@ const confirmPickAssets = () => {
)
)
if (isReferenceMode) {
Message.success('已上传完成')
Message.success(wantCreateAsset ? '已上传并写入素材库' : '已上传完成')
}
try {
@ -726,10 +762,56 @@ const confirmPickAssets = () => {
Message.error('上传失败,请重试')
}
}
}
input.value = ''
const handleSelectFiles = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
const createAsset =
isReference.value && createAssetIntent.value === true
try {
await processFilesList(files, { createAsset })
} finally {
createAssetIntent.value = false
}
if (input) input.value = ''
currentUploadIndex.value = -1
}
}
const onReferenceDragEnter = () => {
if (!isReference.value) return
referenceDragDepth.value++
}
const onReferenceDragLeave = () => {
if (!isReference.value) return
referenceDragDepth.value = Math.max(0, referenceDragDepth.value - 1)
}
const onReferenceDragOver = () => {
if (!isReference.value) return
referenceDragDepth.value = Math.max(1, referenceDragDepth.value)
}
const onReferenceDrop = async (e) => {
if (!isReference.value) return
referenceDragDepth.value = 0
const files = Array.from(e.dataTransfer?.files || [])
if (!files.length) return
await processFilesList(files, { createAsset: false })
}
const onReferencePaste = async (e) => {
if (!isReference.value) return
const items = e.clipboardData?.items
if (!items || !items.length) return
const files = []
for (const item of items) {
if (item.kind !== 'file') continue
const f = item.getAsFile()
if (f && f.type.startsWith('image/')) files.push(f)
}
if (!files.length) return
e.preventDefault()
await processFilesList(files, { createAsset: false })
}
const onPromptInput = (e) => {
setPrompt(e.target.value)
@ -1159,33 +1241,13 @@ const onEditorKeyup = (e) => {
}
}
const selectMentionItem = (item) => {
if (!item?.url || !editorRef.value) return
const buildReferenceHolderElement = (item) => {
const kind = item.mediaType || 'image'
const key = `${kind}:${(item.assetId || '').trim() || String(item.url || '').trim()}`
const keys = getUniqueRefKeysInDocForKind(kind)
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
Message.warning(`同类参考${label}最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,无法再插入新素材`)
mentionVisible.value = false
return
}
editorRef.value.focus()
restoreSelection()
removeMentionTrigger()
saveSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) return
const token = '[?]'
const holder = document.createElement('span')
holder.className = 'vg-inline-ref'
holder.setAttribute('data-mention-reference', '1')
holder.setAttribute('data-token', token)
holder.setAttribute('data-reference-url', item.url)
holder.setAttribute('data-token', '[?]')
holder.setAttribute('data-reference-url', String(item.url || '').trim())
holder.setAttribute('data-reference-asset-id', item.assetId || '')
holder.setAttribute('data-reference-kind', kind)
holder.setAttribute('contenteditable', 'false')
@ -1214,6 +1276,157 @@ const selectMentionItem = (item) => {
img.className = 'vg-inline-ref-image'
holder.appendChild(img)
}
return holder
}
const extractRefsFromContent = (content) => {
const images = []
const videos = []
const audios = []
if (!Array.isArray(content)) return { images, videos, audios }
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)
}
}
return { images, videos, audios }
}
const parseContentItemToMedia = (it, idx) => {
const id = `hist_${idx}_${Date.now().toString(36)}`
if (it?.type === 'image_url' && (!it.role || it.role === 'reference_image')) {
const raw = String(it.image_url?.url || '').trim()
if (!raw) return null
const assetId = raw.startsWith('asset://') ? raw.slice(9) : ''
return { id, url: raw, assetId, mediaType: 'image', name: '' }
}
if (it?.type === 'video_url' && it.role === 'reference_video') {
const raw = String(it.video_url?.url || '').trim()
if (!raw) return null
const assetId = raw.startsWith('asset://') ? raw.slice(9) : ''
return { id, url: raw, assetId, mediaType: 'video', name: '' }
}
if (it?.type === 'audio_url' && it.role === 'reference_audio') {
const raw = String(it.audio_url?.url || '').trim()
if (!raw) return null
const assetId = raw.startsWith('asset://') ? raw.slice(9) : ''
return { id, url: raw, assetId, mediaType: 'audio', name: '' }
}
return null
}
const normRefKey = (u) => String(u || '').trim()
const findMediaItemForRefUrl = (items, url, kind) => {
const u = normRefKey(url)
if (!u) return null
return (
items.find((x) => {
if ((x.mediaType || 'image') !== kind) return false
const xu = normRefKey(x.url)
const xa = normRefKey(x.assetId)
if (u === xu) return true
if (u.startsWith('asset://') && u.slice(9) === xa) return true
if (xa && `asset://${xa}` === u) return true
return false
}) || null
)
}
const loadReferenceFromTaskRow = async (row) => {
if (!isReference.value || !editorRef.value) return
let vp = row?.videoParams
try {
vp = typeof vp === 'string' ? JSON.parse(vp) : vp
} catch {
return
}
const content = vp?.content
if (!Array.isArray(content) || !content.length) return
const head = content[0]
if (head?.type !== 'text') return
const mediaItems = []
for (let i = 1; i < content.length; i++) {
const parsed = parseContentItemToMedia(content[i], i)
if (parsed) mediaItems.push(parsed)
}
setMediaList(mediaItems.slice(0, props.maxMediaCount))
await nextTick()
const refs = extractRefsFromContent(content)
const text = head.text || ''
editorRef.value.innerHTML = ''
const tokenReg = /(\[图片(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]|\[图(\d+)\])/g
let last = 0
let m
while ((m = tokenReg.exec(text)) !== null) {
if (m.index > last) {
editorRef.value.appendChild(document.createTextNode(text.slice(last, m.index)))
}
let url = ''
let kind = 'image'
if (m[2] != null) {
url = refs.images[Number(m[2]) - 1] || ''
kind = 'image'
} else if (m[3] != null) {
url = refs.videos[Number(m[3]) - 1] || ''
kind = 'video'
} else if (m[4] != null) {
url = refs.audios[Number(m[4]) - 1] || ''
kind = 'audio'
} else if (m[5] != null) {
url = refs.images[Number(m[5]) - 1] || ''
kind = 'image'
}
const mediaItem = findMediaItemForRefUrl(mediaItems, url, kind)
if (mediaItem) {
const holder = buildReferenceHolderElement(mediaItem)
editorRef.value.appendChild(holder)
} else {
editorRef.value.appendChild(document.createTextNode(m[0]))
}
last = m.index + m[0].length
}
if (last < text.length) {
editorRef.value.appendChild(document.createTextNode(text.slice(last)))
}
renumberAllReferenceMentions()
setPrompt(getEditorPlainText())
saveReferenceToStorage(internalMediaList.value)
}
const selectMentionItem = (item) => {
if (!item?.url || !editorRef.value) return
const kind = item.mediaType || 'image'
const key = `${kind}:${(item.assetId || '').trim() || String(item.url || '').trim()}`
const keys = getUniqueRefKeysInDocForKind(kind)
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
Message.warning(`同类参考${label}最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,无法再插入新素材`)
mentionVisible.value = false
return
}
editorRef.value.focus()
restoreSelection()
removeMentionTrigger()
saveSelection()
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) return
const holder = buildReferenceHolderElement({ ...item, mediaType: kind })
range.insertNode(holder)
range.setStartAfter(holder)
@ -1232,6 +1445,7 @@ defineExpose({
getEditorPlainText,
getImageReferenceContentItems,
clearAll,
loadReferenceFromTaskRow,
clearPromptOnly: () => {
setPrompt('')
if (editorRef.value) editorRef.value.innerHTML = ''
@ -1267,6 +1481,8 @@ defineExpose({
/* 参考图:参考图 | 资产(组+操作) | 选择区域(生成参数) | 富文本 */
.vg-compose-card--reference {
display: grid;
height: 100%;
min-height: 0;
grid-template-columns: minmax(72px, 0.85fr) minmax(72px, 0.75fr) minmax(168px, 1.35fr) minmax(120px, 2.95fr);
grid-template-rows: minmax(0, 1fr);
column-gap: 10px;
@ -1346,10 +1562,25 @@ defineExpose({
color: rgba(255, 255, 255, 0.38);
}
/* 参考图列:限制在网格单元高度内,多图时在列表内滚动 */
.vg-compose-mod--ref {
overflow: hidden;
min-height: 0;
}
.vg-compose-mod--ref .vg-mod-body {
min-height: 72px;
overflow-x: hidden;
overflow-y: auto;
flex: 1 1 0;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.vg-compose-mod--ref.vg-compose-mod--dragover {
outline: 2px dashed rgba(0, 202, 224, 0.65);
outline-offset: 2px;
border-radius: 12px;
background: rgba(0, 202, 224, 0.06);
}
.vg-compose-empty--compact {
@ -1377,14 +1608,19 @@ defineExpose({
}
.vg-compose-media-scroll--vertical {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
max-height: 100%;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
gap: 8px;
padding: 2px;
padding: 2px 4px 2px 2px;
scroll-snap-type: y proximity;
touch-action: pan-y;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.vg-compose-media-item--tile {
@ -1400,6 +1636,28 @@ defineExpose({
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
/* 首帧/首尾帧:左侧(两列布局)增加参数后,让右侧富文本框宽度略收窄 */
.vg-compose-card--split .vg-compose-left--two-col {
width: 40%;
overflow-y: hidden; /* 让滚动优先发生在参数模块内部 */
}
.vg-compose-left--two-col {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
gap: 6px;
overflow-y: hidden;
overflow-x: hidden;
align-items: stretch;
}
.vg-compose-left--two-col > * {
min-height: 0;
}
.vg-compose-left.hidden {
@ -1419,6 +1677,60 @@ defineExpose({
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.vg-first-frame-panel,
.vg-first-last-panel {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
padding: 10px 10px;
}
/* 首帧/首尾帧:上传区不参与 flex 收缩,避免参数模块挤压导致上传区裁剪 */
.vg-compose-card--split .vg-first-frame-panel,
.vg-compose-card--split .vg-first-last-panel {
flex-shrink: 0;
}
/* 首帧/首尾帧:参数模块允许在内部滚动 */
.vg-compose-card--split .vg-compose-mod--pick {
flex: 1;
min-height: 0;
}
/* 首帧/首尾帧:缩小左侧上传区,避免高度不足导致滚动 */
.vg-first-frame-panel,
.vg-first-last-panel {
gap: 8px;
}
.vg-first-frame-panel .vg-upload-placeholder,
.vg-first-last-panel .vg-upload-placeholder {
padding: 16px 10px;
border-radius: 10px;
}
.vg-first-frame-panel .vg-upload-icon,
.vg-first-last-panel .vg-upload-icon {
font-size: 24px;
margin-bottom: 4px;
}
.vg-first-frame-panel .vg-upload-text,
.vg-first-last-panel .vg-upload-text {
font-size: 12px;
line-height: 1.2;
}
.vg-first-frame-panel .vg-compose-media-item,
.vg-first-last-panel .vg-compose-media-item {
width: 60px;
height: 60px;
border-radius: 14px;
}
.vg-upload-single {
@ -1711,6 +2023,7 @@ defineExpose({
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.vg-reference-editor-row {
@ -1991,6 +2304,12 @@ defineExpose({
.vg-compose-left {
width: 100%;
}
.vg-compose-left--two-col {
display: flex;
flex-direction: column;
overflow-y: auto;
}
}
/* 中等宽度:四列改为「参考条 + 右侧两行」,富文本独占一行(平板横屏等) */

View File

@ -41,8 +41,8 @@ const state = {
showMessage: false,
messageData: {},
messageCount: 0,
// 是否显示18禁弹窗
showForbidden: true,
// 是否显示18禁弹窗:默认不拦截,直接进入首页等主页面
showForbidden: false,
// 阻止页面切换
showPrevent: false,
showLogin: false

View File

@ -11,27 +11,27 @@
<a-input v-model="filters.name" placeholder="按名称过滤" />
</div>
<div class="ag-field">
<label>GroupIds</label>
<a-input v-model="filters.groupIdsText" placeholder="多个ID用英文逗号分隔" />
<label>资源组编号</label>
<a-input v-model="filters.groupIdsText" placeholder="多个编号用英文逗号分隔" />
</div>
<div class="ag-field">
<label>GroupType</label>
<label>资源组类型</label>
<a-select v-model="filters.groupType">
<a-option value="AIGC">AIGC</a-option>
<a-option value="AIGC">AIGC生成类</a-option>
</a-select>
</div>
<div class="ag-field">
<label>SortBy</label>
<label>排序字段</label>
<a-select v-model="filters.sortBy">
<a-option value="CreateTime">CreateTime</a-option>
<a-option value="UpdateTime">UpdateTime</a-option>
<a-option value="CreateTime">创建时间</a-option>
<a-option value="UpdateTime">更新时间</a-option>
</a-select>
</div>
<div class="ag-field">
<label>SortOrder</label>
<label>排序方向</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">Desc</a-option>
<a-option value="Asc">Asc</a-option>
<a-option value="Desc">从新到旧</a-option>
<a-option value="Asc">从旧到新</a-option>
</a-select>
</div>
<div class="ag-actions">
@ -56,13 +56,13 @@
</colgroup>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>GroupType</th>
<th>ProjectName</th>
<th>CreateTime</th>
<th>UpdateTime</th>
<th>编号</th>
<th>名称</th>
<th>描述</th>
<th>类型</th>
<th>项目名称</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
@ -71,7 +71,7 @@
<td>{{ item.Id || item.id }}</td>
<td>{{ item.Name || item.name }}</td>
<td>{{ item.Description || item.description || '-' }}</td>
<td>{{ item.GroupType || item.groupType || '-' }}</td>
<td>{{ formatGroupTypeLabel(item) }}</td>
<td>{{ item.ProjectName || item.projectName || '-' }}</td>
<td>{{ item.CreateTime || item.createTime || '-' }}</td>
<td>{{ item.UpdateTime || item.updateTime || '-' }}</td>
@ -181,6 +181,12 @@ export default {
this.search(1)
},
methods: {
formatGroupTypeLabel(item) {
const t = String(item?.GroupType || item?.groupType || '').trim()
if (!t) return '-'
if (t === 'AIGC') return 'AIGC生成类'
return t
},
clampGroupPage(n) {
const page = Number(n) || 1
const size = Number(this.filters.pageSize) || 10
@ -294,7 +300,7 @@ export default {
const res = await this.$axios({
url: 'api/byteAssetGroup/getAssetGroup',
method: 'POST',
data: { Id: id }
data: { id: id }
})
if (res.code === 200) {
this.detailData = res.data || {}

View File

@ -44,31 +44,31 @@
</a-select>
</div>
<div class="field">
<label>Name</label>
<a-input v-model="filters.name" placeholder="按名称过滤" />
<label>素材名称</label>
<a-input v-model="filters.name" placeholder="按名称筛选" />
</div>
<div class="field">
<label>Status</label>
<a-select v-model="filters.status">
<label>状态</label>
<a-select v-model="filters.status" placeholder="全部状态">
<a-option value="">全部</a-option>
<a-option value="Active">Active</a-option>
<a-option value="Processing">Processing</a-option>
<a-option value="Failed">Failed</a-option>
<a-option value="Active">可用</a-option>
<a-option value="Processing">处理中</a-option>
<a-option value="Failed">失败</a-option>
</a-select>
</div>
<div class="field">
<label>SortBy</label>
<label>排序字段</label>
<a-select v-model="filters.sortBy">
<a-option value="CreateTime">CreateTime</a-option>
<a-option value="UpdateTime">UpdateTime</a-option>
<a-option value="GroupId">GroupId</a-option>
<a-option value="CreateTime">创建时间</a-option>
<a-option value="UpdateTime">更新时间</a-option>
<a-option value="GroupId">素材组</a-option>
</a-select>
</div>
<div class="field">
<label>SortOrder</label>
<label>排序方向</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">Desc</a-option>
<a-option value="Asc">Asc</a-option>
<a-option value="Desc">从新到旧</a-option>
<a-option value="Asc">从旧到新</a-option>
</a-select>
</div>
<div class="field actions">
@ -83,13 +83,13 @@
<table class="asset-table">
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>URL</th>
<th>GroupId</th>
<th>AssetType</th>
<th>Status</th>
<th>CreateTime</th>
<th>素材编号</th>
<th>名称</th>
<th>访问地址</th>
<th>所属素材组</th>
<th>类型</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
@ -135,8 +135,8 @@
</div>
</td>
<td>{{ it.GroupId || it.groupId || '-' }}</td>
<td>{{ it.AssetType || it.assetType || '-' }}</td>
<td>{{ it.Status || it.status || '-' }}</td>
<td>{{ formatAssetTypeLabel(it) }}</td>
<td>{{ formatAssetStatusLabel(it) }}</td>
<td>{{ it.CreateTime || it.createTime || '-' }}</td>
<td>
<a-button size="mini" type="outline" @click="getAsset(it)">详情</a-button>
@ -265,6 +265,26 @@ export default {
this.loadGroups()
},
methods: {
formatAssetTypeLabel(it) {
const t = String(it?.AssetType || it?.assetType || '').trim()
if (!t) return '-'
const map = {
Image: '图片',
Video: '视频',
Audio: '音频'
}
return map[t] || t
},
formatAssetStatusLabel(it) {
const s = String(it?.Status || it?.status || '').trim()
if (!s) return '-'
const map = {
Active: '可用',
Processing: '处理中',
Failed: '失败'
}
return map[s] || s
},
assetUrl(it) {
return String(it?.URL || it?.url || '').trim()
},
@ -356,7 +376,7 @@ export default {
},
async createAsset() {
const groupId = String(this.createForm.groupId || '').trim()
if (!groupId) return this.$message.error('请填写 GroupId')
if (!groupId) return this.$message.error('请选择素材组')
if (!this.createForm.file) return this.$message.error('请选择上传文件')
this.createLoading = true
try {
@ -476,7 +496,7 @@ export default {
const res = await this.$axios({
url: ASSET_DELETE_API,
method: 'POST',
data: { Id: id }
data: { id: id }
})
if (res.code === 200) {
this.$message.success('删除成功')

View File

@ -46,6 +46,29 @@
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>
</div>
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
</div>
<div v-else class="vg-chat-user-row">
@ -73,6 +96,29 @@
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>
</div>
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
</div>
<div class="vg-chat-user-col-status">
@ -98,6 +144,13 @@
<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">
@ -186,62 +239,11 @@
</div>
</template>
<template #toolbar>
<div class="vg-toolbar" :class="{ 'vg-toolbar--reference-submit': videoMode === 'image-reference' }">
<div v-if="videoMode !== 'image-reference'" class="vg-toolbar-settings">
<div class="vg-params-row">
<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>
</div>
<div class="vg-toolbar vg-toolbar--reference-submit">
<div
class="vg-toolbar-actions"
:class="{ 'vg-toolbar-actions--reference': videoMode === 'image-reference' }">
:class="{ 'vg-toolbar-actions--reference': true }">
<mf-button
v-if="videoMode === 'image-reference'"
size="small"
type="text"
class="vg-toolbar-clear"
@ -749,6 +751,182 @@ export default {
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',
@ -1422,6 +1600,8 @@ export default {
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;
@ -1759,6 +1939,42 @@ export default {
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;
}
.vg-chat-ai-top {
display: flex;
align-items: center;
@ -1768,6 +1984,22 @@ export default {
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);