fix: 对接资产库和优化布局

This commit is contained in:
old burden 2026-04-02 12:42:46 +08:00
parent 089ffca4f5
commit aec9dc0152
5 changed files with 1269 additions and 404 deletions

View File

@ -1,7 +1,99 @@
<template>
<div class="vg-compose-card">
<!-- 左侧面板 - 根据模式动态渲染 -->
<div class="vg-compose-left" v-if="!isTextToVideo">
<div class="vg-compose-root">
<div
class="vg-compose-card"
:class="{
'vg-compose-card--reference': isReference,
'vg-compose-card--split': !isReference && !isTextToVideo,
'vg-compose-card--solo': isTextToVideo
}">
<!-- 参考图模式四列 参考图 / 资产 / 选择区域 / 富文本 -->
<template v-if="isReference">
<div class="vg-compose-mod vg-compose-mod--ref">
<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>
<div v-else class="vg-compose-media-scroll vg-compose-media-scroll--vertical">
<div
v-for="item in mediaList"
:key="item.id || item.url"
class="vg-compose-media-item vg-compose-media-item--tile"
:title="item.name || ''">
<div class="vg-compose-media-preview">
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" />
<video
v-else-if="item.mediaType === 'video'"
:src="item.url"
muted
playsinline
preload="metadata" />
<div v-else-if="item.mediaType === 'audio'" class="vg-audio-tile">
<span class="vg-audio-tile-icon"></span>
<span class="vg-audio-tile-text">音频</span>
</div>
</div>
<button type="button" class="vg-compose-remove-btn" @click.stop="removeItem(item)">×</button>
</div>
</div>
</div>
<div class="vg-mod-foot muted">{{ mediaList.length }}/{{ maxMediaCount }}</div>
</div>
<div class="vg-compose-mod vg-compose-mod--asset">
<div class="vg-mod-body vg-mod-body--asset">
<a-select
v-model="assetGroupId"
class="vg-asset-group-input"
placeholder="请选择素材组"
allow-clear
:loading="groupLoading || groupLoadingMore"
:disabled="groupLoading && assetGroups.length === 0"
@dropdown-reach-bottom="onAssetGroupDropdownReachBottom">
<a-option v-for="g in assetGroups" :key="g.value" :value="g.value">
{{ g.label }}
</a-option>
</a-select>
<span
class="vg-asset-btn-wrap"
:title="!hasAssetGroupId ? '请先选择素材组' : ''">
<mf-button
class="vg-compose-left-upload vg-mod-btn"
size="small"
:disabled="!hasAssetGroupId"
:loading="assetLoading"
@click="loadAssetsByGroup">
选择资产
</mf-button>
</span>
<span
class="vg-asset-btn-wrap"
:title="!hasAssetGroupId ? '请先选择素材组' : ''">
<mf-button
class="vg-compose-left-upload vg-mod-btn"
size="small"
type="primary"
:disabled="!hasAssetGroupId"
@click="openFilePicker">
上传资产
</mf-button>
</span>
</div>
</div>
<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>
</template>
<!-- 首帧 / 首尾帧保留左侧素材列 -->
<div class="vg-compose-left" v-else-if="!isTextToVideo">
<!-- 首帧模式 -->
<div v-if="isFirstFrame" class="vg-first-frame-panel">
<div class="vg-panel-title">首帧图</div>
@ -49,102 +141,17 @@
</div>
</div>
</div>
<!-- 参考图模式 -->
<div v-else-if="isReference" class="vg-reference-panel">
<div class="vg-compose-left-head">
<div class="vg-compose-left-title">
参考素材{{ mediaList.length }}/{{ maxMediaCount }}
</div>
</div>
<div class="vg-asset-controls">
<select v-model="assetGroupId" class="vg-asset-group-input">
<option value="">请选择素材组</option>
<option v-for="g in assetGroups" :key="g.Id || g.id" :value="g.Id || g.id">
{{ g.Name || g.name || g.Id || g.id }}
</option>
</select>
<mf-button class="vg-compose-left-upload" size="small" @click="loadAssetGroups" :loading="groupLoading">
刷新分组
</mf-button>
<mf-button class="vg-compose-left-upload" size="small" @click="loadAssetsByGroup" :loading="assetLoading">
查询资产
</mf-button>
<mf-button class="vg-compose-left-upload" size="small" type="primary" @click="openFilePicker">
上传资产
</mf-button>
</div>
<div v-if="mediaList.length === 0" class="vg-compose-empty" @click="openFilePicker">
<div class="vg-compose-empty-icon" aria-hidden="true">+</div>
<div class="vg-compose-empty-text">点击上传资产或按 GroupId 查询资产</div>
</div>
<div v-else class="vg-compose-media-scroll">
<div
v-for="(item, index) in mediaList"
:key="item.id || item.url"
class="vg-compose-media-item"
:title="item.name || ''">
<div class="vg-compose-media-preview">
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" />
<video
v-else-if="item.mediaType === 'video'"
:src="item.url"
muted
playsinline
preload="metadata" />
<div v-else-if="item.mediaType === 'audio'" class="vg-audio-tile">
<span class="vg-audio-tile-icon"></span>
<span class="vg-audio-tile-text">音频</span>
</div>
</div>
<button
type="button"
class="vg-compose-remove-btn"
@click.stop="removeItem(item)">
×
</button>
</div>
</div>
</div>
</div>
<!-- 文生视频模式 - 左侧不显示素材 -->
<div v-else class="vg-compose-left hidden">
<!-- 文生视频无需参考素材 -->
</div>
<a-modal
v-model:visible="assetPickerVisible"
title="选择素材"
:ok-button-props="{ disabled: !assetPickerSelectedKeys.length }"
@ok="confirmPickAssets">
<div class="vg-asset-picker">
<div v-if="assetQueryResults.length === 0" class="vg-asset-picker-empty">当前分组暂无可用素材</div>
<label v-for="it in assetQueryResults" :key="it.assetId || it.id" class="vg-asset-picker-item">
<input
type="checkbox"
:value="String(it.assetId || it.id)"
v-model="assetPickerSelectedKeys"
/>
<div class="vg-asset-picker-preview">
<img v-if="it.mediaType === 'image'" :src="it.url" alt="" />
<video v-else-if="it.mediaType === 'video'" :src="it.url" muted playsinline preload="metadata"></video>
<div v-else class="vg-audio-tile"> 音频</div>
</div>
<div class="vg-asset-picker-name">{{ it.name || it.assetId }}</div>
</label>
</div>
</a-modal>
<div class="vg-compose-right">
<div class="vg-compose-right-head">
<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-rich-editor-wrap">
<div
ref="editorRef"
@ -175,9 +182,14 @@
</div>
</div>
<!-- 选择区域和提交按钮将从父组件传入 -->
<div v-if="isReference" class="vg-reference-actions-col">
<slot name="toolbar"></slot>
</div>
<template v-else>
<slot name="toolbar"></slot>
</template>
</div>
</div>
<input
ref="fileInputRef"
@ -187,12 +199,56 @@
:multiple="isReference"
@change="handleSelectFiles" />
</div>
<a-modal
v-model:visible="assetPickerVisible"
title="选择素材"
:ok-button-props="{ disabled: !assetPickerSelectedKeys.length }"
@ok="confirmPickAssets">
<div class="vg-asset-picker">
<div v-if="assetQueryResults.length === 0" class="vg-asset-picker-empty">当前分组暂无可用素材</div>
<label v-for="it in assetQueryResults" :key="it.assetId || it.id" class="vg-asset-picker-item">
<input
type="checkbox"
:value="String(it.assetId || it.id)"
v-model="assetPickerSelectedKeys"
/>
<div class="vg-asset-picker-preview">
<img v-if="it.mediaType === 'image'" :src="it.url" alt="" />
<video v-else-if="it.mediaType === 'video'" :src="it.url" muted playsinline preload="metadata"></video>
<div v-else class="vg-audio-tile"> 音频</div>
</div>
<div class="vg-asset-picker-name">{{ it.name || it.assetId }}</div>
</label>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
import request from '@/utils/request'
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
const ASSET_GROUP_LIST_PAGE_SIZE = 20
const buildAssetGroupListBody = (pageNumber) => ({
filter: { groupType: 'AIGC' },
pageNumber,
pageSize: ASSET_GROUP_LIST_PAGE_SIZE,
sortBy: 'CreateTime',
sortOrder: 'Desc'
})
const normalizeAssetGroupOption = (g) => {
const value = String(g?.Id ?? g?.id ?? '').trim()
const name = String(g?.Name ?? g?.name ?? '').trim()
return {
value,
label: name || '-'
}
}
/** 参考素材:@ 引用同一类(图/视频/音频)最多 9 种不同素材;左侧列表最多可插入 12 条(由 maxMediaCount 控制) */
const MAX_REFERENCE_UNIQUE_KIND = 9
@ -228,7 +284,6 @@ const emit = defineEmits(['update:modelValue', 'update:mediaList'])
const fileInputRef = ref(null)
const editorRef = ref(null)
const { proxy } = getCurrentInstance() || {}
const localPrompt = ref(props.modelValue || '')
const internalMediaList = ref(Array.isArray(props.mediaList) ? [...props.mediaList] : [])
const currentUploadIndex = ref(-1) // for first/last frame mode
@ -238,6 +293,9 @@ const mentionActiveIndex = ref(-1)
const assetGroupId = ref('')
const assetLoading = ref(false)
const groupLoading = ref(false)
const groupLoadingMore = ref(false)
const assetGroupPageNumber = ref(0)
const assetGroupHasMore = ref(false)
const assetGroups = ref([])
const assetPickerVisible = ref(false)
const assetQueryResults = ref([])
@ -263,25 +321,10 @@ watch(
}
)
//
watch(() => props.videoMode, (newMode) => {
mentionVisible.value = false
if (newMode === 'image-reference') {
const saved = loadReferenceFromStorage()
if (saved.length > 0 && (!internalMediaList.value || internalMediaList.value.length === 0)) {
internalMediaList.value = saved
emit('update:mediaList', saved)
}
}
}, { immediate: true })
onMounted(() => {
if (editorRef.value && localPrompt.value) {
editorRef.value.innerText = localPrompt.value
}
if (isReference.value) {
loadAssetGroups()
}
})
const mediaList = computed(() => internalMediaList.value)
@ -308,6 +351,9 @@ const isFirstFrame = computed(() => props.videoMode === 'image-first-frame')
const isFirstLastFrame = computed(() => props.videoMode === 'image-first-last-frame')
const isReference = computed(() => props.videoMode === 'image-reference')
/** 参考图模式:已选素材组 ID与素材管理页一致非空才可选择/上传资产) */
const hasAssetGroupId = computed(() => !!String(assetGroupId.value || '').trim())
const setPrompt = (next) => {
localPrompt.value = next
emit('update:modelValue', next)
@ -318,7 +364,36 @@ const setMediaList = (next) => {
emit('update:mediaList', next)
}
/** 与 AssetManage.buildListPayload 结构一致,用于当前素材组下列出素材 */
const buildReferenceListAssetsPayload = () => {
const gid = String(assetGroupId.value || '').trim()
const filter = {
groupType: 'AIGC',
groupIds: [gid],
statuses: ['Active']
}
return {
filter,
pageNumber: 1,
pageSize: 100,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
}
const onReferenceEmptyAreaClick = () => {
if (!hasAssetGroupId.value) {
Message.warning('请先选择素材组')
return
}
openFilePicker()
}
const openFilePicker = () => {
if (isReference.value && !hasAssetGroupId.value) {
Message.warning('请先选择素材组')
return
}
currentUploadIndex.value = -1
fileInputRef.value?.click()
}
@ -406,61 +481,90 @@ const mergeAssetsToMediaList = (assets) => {
setMediaList(next.slice(0, props.maxMediaCount))
}
const loadAssetGroups = async () => {
if (!proxy?.$axios) return
const loadAssetGroups = async (reset = true) => {
if (reset) {
groupLoading.value = true
assetGroupPageNumber.value = 0
assetGroups.value = []
assetGroupHasMore.value = false
} else {
if (!assetGroupHasMore.value || groupLoadingMore.value || groupLoading.value) return
groupLoadingMore.value = true
}
const nextPage = reset ? 1 : assetGroupPageNumber.value + 1
try {
const res = await proxy.$axios({
const res = await request({
url: 'api/byteAssetGroup/listAssetGroups',
method: 'POST',
data: {
Filter: { GroupType: 'AIGC' },
PageNumber: 1,
PageSize: 100,
SortBy: 'CreateTime',
SortOrder: 'Desc'
}
data: buildAssetGroupListBody(nextPage)
})
const rows = Array.isArray(res?.data?.Items) ? res.data.Items : []
const payload = res?.data
const rawRows = byteApiItems(payload)
const rows = rawRows.map(normalizeAssetGroupOption).filter((x) => x.value)
const total = byteApiTotalCount(payload)
if (reset) {
assetGroups.value = rows
if (!assetGroupId.value && rows.length) {
assetGroupId.value = rows[0]?.Id || rows[0]?.id || ''
} else {
const seen = new Set(assetGroups.value.map((x) => x.value))
for (const r of rows) {
if (!seen.has(r.value)) {
assetGroups.value.push(r)
seen.add(r.value)
}
}
}
assetGroupPageNumber.value = nextPage
assetGroupHasMore.value = assetGroups.value.length < total && rows.length > 0
if (!assetGroupId.value && assetGroups.value.length) {
assetGroupId.value = assetGroups.value[0].value
}
} catch (err) {
Message.error(err?.message || '加载素材组失败')
} finally {
groupLoading.value = false
groupLoadingMore.value = false
}
}
// loadReferenceFromStorage / loadAssetGroups immediate const listAssetGroups
watch(
() => props.videoMode,
(newMode) => {
mentionVisible.value = false
if (newMode === 'image-reference') {
const saved = loadReferenceFromStorage()
if (saved.length > 0 && (!internalMediaList.value || internalMediaList.value.length === 0)) {
internalMediaList.value = saved
emit('update:mediaList', saved)
}
loadAssetGroups(true)
}
},
{ immediate: true }
)
const onAssetGroupDropdownReachBottom = () => {
loadAssetGroups(false)
}
const loadAssetsByGroup = async () => {
const gid = String(assetGroupId.value || '').trim()
if (!gid) {
Message.warning('请先填写 GroupId 再查询')
return
}
if (!proxy?.$axios) {
Message.error('查询失败axios 未就绪')
Message.warning('请先选择素材组')
return
}
assetLoading.value = true
try {
const res = await proxy.$axios({
const res = await request({
url: 'api/byteAsset/listAssets',
method: 'POST',
data: {
Filter: {
GroupIds: [gid],
GroupType: 'AIGC',
Statuses: ['Active']
},
PageNumber: 1,
PageSize: 100,
SortBy: 'CreateTime',
SortOrder: 'Desc'
}
data: buildReferenceListAssetsPayload()
})
const rows = Array.isArray(res?.data?.Items) ? res.data.Items : []
if (res.code !== 200) {
Message.error(res.msg || '查询素材失败')
return
}
const rows = byteApiItems(res?.data)
const assets = rows
.map((row) => {
const at = String(row?.AssetType || row?.assetType || 'Image').toLowerCase()
@ -504,7 +608,7 @@ const confirmPickAssets = () => {
const isReferenceMode = isReference.value
if (isReferenceMode && !String(assetGroupId.value || '').trim()) {
Message.warning('参考图模式请先填写 GroupId')
Message.warning('请先选择素材组')
input.value = ''
return
}
@ -581,8 +685,7 @@ const confirmPickAssets = () => {
let assetId = entry.assetId || ''
if (isReferenceMode) {
const gid = String(assetGroupId.value || '').trim()
if (!gid) throw new Error('请先填写 GroupId')
if (!proxy?.$axios) throw new Error('上传资产失败axios 未就绪')
if (!gid) throw new Error('请先选择素材组')
const mt = entry.mediaType || 'image'
const assetType =
mt === 'video' ? 'Video' : mt === 'audio' ? 'Audio' : 'Image'
@ -591,11 +694,14 @@ const confirmPickAssets = () => {
fd.append('groupId', gid)
fd.append('assetType', assetType)
fd.append('name', entry?.name || '')
const createRes = await proxy.$axios({
const createRes = await request({
url: 'api/byteAsset/createAsset',
method: 'POST',
data: fd
})
if (createRes.code !== 200) {
throw new Error(createRes?.msg || '创建素材失败')
}
assetId = createRes?.data?.Id || createRes?.data?.id || ''
if (!assetId) throw new Error(createRes?.msg || '创建素材失败未返回资产ID')
}
@ -1125,6 +1231,7 @@ const selectMentionItem = (item) => {
defineExpose({
getEditorPlainText,
getImageReferenceContentItems,
clearAll,
clearPromptOnly: () => {
setPrompt('')
if (editorRef.value) editorRef.value.innerHTML = ''
@ -1135,7 +1242,16 @@ defineExpose({
</script>
<style scoped lang="less">
.vg-compose-root {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.vg-compose-card {
flex: 1;
display: flex;
gap: 12px;
align-items: stretch;
@ -1144,7 +1260,138 @@ defineExpose({
border-radius: 18px;
padding: 8px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.06);
min-height: 200px;
min-height: 0;
box-sizing: border-box;
}
/* 参考图:参考图 | 资产(组+操作) | 选择区域(生成参数) | 富文本 */
.vg-compose-card--reference {
display: grid;
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;
align-items: stretch;
}
/* 避免网格子项按内容撑开高度,导致第四列富文本溢出卡片 */
.vg-compose-card--reference > * {
min-height: 0;
}
.vg-compose-card--split {
display: flex;
}
.vg-compose-card--solo {
display: flex;
}
.vg-compose-card--solo .vg-compose-right {
flex: 1;
width: 100%;
}
.vg-compose-mod {
min-height: 0;
display: flex;
flex-direction: column;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(0, 0, 0, 0.22);
box-sizing: border-box;
}
.vg-mod-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.vg-mod-body--asset {
gap: 8px;
}
.vg-asset-btn-wrap {
display: block;
width: 100%;
}
.vg-mod-body--params {
gap: 6px;
justify-content: flex-start;
overflow-x: hidden;
overflow-y: auto;
padding-right: 2px;
}
.vg-mod-btn {
width: 100%;
}
.vg-mod-btn:deep(.arco-btn) {
width: 100%;
}
.vg-mod-foot {
margin-top: 6px;
flex-shrink: 0;
font-size: 10px;
color: rgba(255, 255, 255, 0.38);
}
.vg-mod-foot.muted {
color: rgba(255, 255, 255, 0.38);
}
.vg-compose-mod--ref .vg-mod-body {
min-height: 72px;
overflow-x: hidden;
overflow-y: auto;
}
.vg-compose-empty--compact {
min-height: 72px;
flex: 1;
padding: 10px 6px;
justify-content: center;
}
.vg-compose-empty--compact .vg-compose-empty-icon {
width: 40px;
height: 40px;
font-size: 22px;
flex-shrink: 0;
}
.vg-compose-empty--compact .vg-compose-empty-text {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.88);
padding: 0 4px;
line-height: 1.45;
word-break: break-word;
overflow-wrap: anywhere;
}
.vg-compose-media-scroll--vertical {
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
gap: 8px;
padding: 2px;
scroll-snap-type: y proximity;
touch-action: pan-y;
}
.vg-compose-media-item--tile {
width: 100%;
height: 56px;
flex-shrink: 0;
scroll-snap-align: start;
}
.vg-compose-left {
@ -1270,20 +1517,25 @@ defineExpose({
}
.vg-asset-group-input {
flex: 1;
width: 100%;
max-width: 100%;
min-width: 0;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.9);
padding: 0 8px;
outline: none;
}
font-size: 11px;
.vg-asset-group-input:focus {
border-color: rgba(0, 202, 224, 0.55);
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.12);
:deep(.arco-select-view-single) {
background: rgba(0, 0, 0, 0.25) !important;
border: 1px solid rgba(255, 255, 255, 0.16) !important;
border-radius: 8px !important;
color: rgba(255, 255, 255, 0.9) !important;
min-height: 30px !important;
}
:deep(.arco-select-view-value) {
color: rgba(255, 255, 255, 0.9) !important;
font-size: 11px;
}
:deep(.arco-select-view-single.arco-select-view-disabled) {
opacity: 0.65;
}
}
.vg-asset-picker {
@ -1441,6 +1693,61 @@ defineExpose({
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.vg-compose-right--reference {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 8px 10px 10px;
background: rgba(0, 0, 0, 0.18);
box-sizing: border-box;
overflow: hidden;
}
.vg-compose-right-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.vg-reference-editor-row {
flex-direction: row;
align-items: stretch;
gap: 10px;
flex: 1 1 0%;
min-height: 0;
overflow: hidden;
}
.vg-reference-actions-col {
flex-shrink: 0;
width: 52px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 14px;
padding: 4px 0 8px;
box-sizing: border-box;
}
.vg-compose-card--reference .vg-rich-editor-wrap {
flex: 1 1 0%;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.vg-compose-card--reference .vg-compose-textarea.vg-rich-editor {
flex: 1 1 0%;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.vg-compose-toolbar {
@ -1650,20 +1957,76 @@ defineExpose({
box-shadow: 0 0 0 3px rgba(0, 202, 224, 0.15);
}
@media (max-width: 640px) {
.vg-compose-card {
/* 小分辨率:参考图四块改为自上而下竖版(顺序:参考图 → 资产 → 参数 → 富文本) */
@media (max-width: 768px) {
.vg-compose-card--split {
flex-direction: column;
min-height: 0;
}
.vg-compose-card--reference {
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
min-height: 0;
}
.vg-compose-mod--ref {
max-height: 220px;
flex-shrink: 0;
min-height: 0;
}
.vg-compose-mod--asset,
.vg-compose-mod--pick {
flex-shrink: 0;
}
.vg-compose-right--reference {
flex: 1;
min-height: 160px;
}
.vg-compose-left {
width: 100%;
}
}
/* 小于 1920 宽:参考素材与富文本上下排列,避免横向过窄时布局错乱 */
/* 中等宽度:四列改为「参考条 + 右侧两行」,富文本独占一行(平板横屏等) */
@media (max-width: 1200px) and (min-width: 769px) {
.vg-compose-card--reference {
display: grid;
grid-template-columns: minmax(72px, 32%) 1fr;
grid-template-rows: auto auto minmax(100px, 1fr);
column-gap: 10px;
row-gap: 10px;
}
.vg-compose-mod--ref {
grid-column: 1;
grid-row: 1 / 3;
}
.vg-compose-mod--asset {
grid-column: 2;
grid-row: 1;
}
.vg-compose-mod--pick {
grid-column: 2;
grid-row: 2;
}
.vg-compose-right--reference {
grid-column: 1 / -1;
grid-row: 3;
}
}
/* 非参考模式:窄屏上下排 */
@media (max-width: 1919px) {
.vg-compose-card {
.vg-compose-card--split {
flex-direction: column;
align-items: stretch;
min-height: 0;
@ -1673,14 +2036,6 @@ defineExpose({
width: 100%;
}
.vg-reference-panel {
min-height: 0;
}
.vg-compose-media-scroll {
max-width: 100%;
}
.vg-compose-right {
flex: 1;
min-width: 0;

View File

@ -0,0 +1,18 @@
/**
* 火山素材相关接口 data 层字段兼容
* Spring/Jackson 序列化为 camelCaseitemstotalCount
* 旧前端曾按 PascalCaseItemsTotalCount取值此处统一解析
*/
export function byteApiItems(data) {
if (!data) return []
if (Array.isArray(data.items)) return data.items
if (Array.isArray(data.Items)) return data.Items
return []
}
export function byteApiTotalCount(data) {
if (!data) return 0
const raw = data.totalCount ?? data.TotalCount
const n = Number(raw)
return Number.isFinite(n) ? n : 0
}

View File

@ -44,6 +44,16 @@
<div class="ag-total">总数{{ totalCount }}</div>
<div class="ag-table-wrap">
<table class="ag-table">
<colgroup>
<col class="ag-col-id" />
<col class="ag-col-name" />
<col class="ag-col-desc" />
<col class="ag-col-type" />
<col class="ag-col-project" />
<col class="ag-col-time" />
<col class="ag-col-time" />
<col class="ag-col-action" />
</colgroup>
<thead>
<tr>
<th>ID</th>
@ -88,7 +98,9 @@
:page-size="filters.pageSize"
show-total
show-jumper
@change="search"
show-page-size
:page-size-options="listPageSizeOptions"
@change="onGroupPageChange"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
@ -100,27 +112,37 @@
<a-modal
v-model:visible="createVisible"
title="新增资源组"
width="520px"
:confirm-loading="createLoading"
@ok="createGroup">
<div class="ag-create-form">
<div class="ag-field">
<label>名称</label>
<a-input v-model="createForm.name" placeholder="请输入资源组名称(<=64字符" />
</div>
<div class="ag-field">
<label>描述</label>
<!-- 弹层 teleported body使用 FormItem 保证标签与 dark 样式生效 -->
<a-form :model="createForm" layout="horizontal" auto-label-width class="ag-create-modal-form">
<a-form-item label="名称" required>
<a-input
v-model="createForm.name"
placeholder="请输入资源组名称≤64 字符)"
:max-length="64"
allow-clear />
</a-form-item>
<a-form-item label="描述">
<a-textarea
v-model="createForm.description"
:max-length="300"
show-word-limit
placeholder="请输入描述(<=300字符" />
</div>
</div>
:auto-size="{ minRows: 3, maxRows: 8 }"
placeholder="请输入描述≤300 字符)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
const LIST_PAGE_NUMBER_MAX = 100
const LIST_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
export default {
name: 'AssetGroupManage',
data() {
@ -146,7 +168,8 @@ export default {
sortOrder: 'Desc'
},
totalCount: 0,
items: []
items: [],
listPageSizeOptions: LIST_PAGE_SIZE_OPTIONS
}
},
computed: {
@ -158,6 +181,19 @@ export default {
this.search(1)
},
methods: {
clampGroupPage(n) {
const page = Number(n) || 1
const size = Number(this.filters.pageSize) || 10
let maxPage = LIST_PAGE_NUMBER_MAX
if (this.totalCount > 0) {
const totalPages = Math.ceil(this.totalCount / size)
maxPage = Math.min(LIST_PAGE_NUMBER_MAX, Math.max(1, totalPages))
}
return Math.min(Math.max(1, page), maxPage)
},
onGroupPageChange(page) {
this.search(this.clampGroupPage(page))
},
openCreateDialog() {
this.createVisible = true
},
@ -179,10 +215,8 @@ export default {
url: 'api/byteAssetGroup/createAssetGroup',
method: 'POST',
data: {
Name: name,
Description: String(this.createForm.description || '').trim(),
GroupType: 'AIGC',
ProjectName: 'default'
name,
description: String(this.createForm.description || '').trim()
}
})
if (res.code === 200) {
@ -201,22 +235,22 @@ export default {
}
},
async search(page = this.filters.pageNumber) {
this.filters.pageNumber = Number(page) || 1
this.filters.pageNumber = this.clampGroupPage(page)
this.listLoading = true
try {
const payload = {
Filter: {
GroupType: this.filters.groupType || 'AIGC'
filter: {
groupType: this.filters.groupType || 'AIGC'
},
PageNumber: this.filters.pageNumber,
PageSize: this.filters.pageSize,
SortBy: this.filters.sortBy,
SortOrder: this.filters.sortOrder
pageNumber: this.filters.pageNumber,
pageSize: this.filters.pageSize,
sortBy: this.filters.sortBy,
sortOrder: this.filters.sortOrder
}
const name = String(this.filters.name || '').trim()
if (name) payload.Filter.name = name
if (name) payload.filter.name = name
const ids = this.buildGroupIds()
if (ids.length) payload.Filter.GroupIds = ids
if (ids.length) payload.filter.groupIds = ids
const res = await this.$axios({
url: 'api/byteAssetGroup/listAssetGroups',
@ -224,8 +258,8 @@ export default {
data: payload
})
const data = res.data || {}
this.totalCount = Number(data.TotalCount || 0)
this.items = Array.isArray(data.Items) ? data.Items : []
this.totalCount = byteApiTotalCount(data)
this.items = byteApiItems(data)
if (res.code !== 200) {
this.$message.error(res.msg || '查询失败')
}
@ -236,7 +270,8 @@ export default {
}
},
onPageSizeChange(size) {
this.filters.pageSize = Number(size) || 10
const s = Number(size) || 10
this.filters.pageSize = LIST_PAGE_SIZE_OPTIONS.includes(s) ? s : 10
this.search(1)
},
resetFilters() {
@ -313,12 +348,6 @@ export default {
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
}
.ag-create-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.ag-field {
display: flex;
flex-direction: column;
@ -343,23 +372,53 @@ export default {
}
.ag-table-wrap {
overflow: auto;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.ag-table {
width: 100%;
max-width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 12px;
}
/* 列宽按百分比分配随容器100% 宽)随分辨率伸缩 */
.ag-col-id {
width: 10%;
}
.ag-col-name {
width: 12%;
}
.ag-col-desc {
width: 24%;
}
.ag-col-type {
width: 8%;
}
.ag-col-project {
width: 12%;
}
.ag-col-time {
width: 11%;
}
.ag-col-action {
width: 12%;
}
.ag-table th,
.ag-table td {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
word-break: break-word;
overflow-wrap: anywhere;
}
.ag-table th {

View File

@ -3,9 +3,6 @@
<section class="asset-left">
<div class="asset-left-head">
<div class="panel-title">素材组树</div>
<div class="field actions">
<a-button size="mini" type="outline" :loading="groupLoading" @click="loadGroups">刷新分组</a-button>
</div>
</div>
<div class="group-tree">
<div
@ -34,8 +31,17 @@
<div class="panel-title mtop">查询素材</div>
<div class="form-grid">
<div class="field">
<label>GroupId</label>
<a-input v-model="filters.groupId" placeholder="按组过滤(默认选中左侧)" />
<label>素材组</label>
<a-select
v-model="filters.groupId"
placeholder="请选择素材组"
allow-clear
:loading="groupLoading"
@change="onFilterGroupChange">
<a-option v-for="g in groups" :key="g.Id || g.id" :value="String(g.Id || g.id)">
{{ (g.Name || g.name || g.Id || g.id) + ' · ' + (g.Id || g.id) }}
</a-option>
</a-select>
</div>
<div class="field">
<label>Name</label>
@ -89,9 +95,45 @@
</thead>
<tbody>
<tr v-for="it in items" :key="it.Id || it.id">
<td>{{ it.Id || it.id }}</td>
<td class="asset-td-id">{{ it.Id || it.id }}</td>
<td>{{ it.Name || it.name || '-' }}</td>
<td class="url-cell">{{ it.URL || it.url || '-' }}</td>
<td class="asset-url-cell">
<template v-if="assetUrlPreviewKind(it) === 'image' && !thumbError[it.Id || it.id]">
<a
:href="assetUrl(it)"
target="_blank"
rel="noreferrer"
class="asset-thumb-link">
<img
class="asset-url-thumb"
:src="assetUrl(it)"
alt=""
loading="lazy"
@error="onAssetThumbError(it)" />
</a>
</template>
<template v-else-if="assetUrlPreviewKind(it) === 'video'">
<video
class="asset-url-video"
:src="assetUrl(it)"
muted
playsinline
preload="metadata"
controls />
</template>
<template v-else-if="assetUrl(it)">
<a :href="assetUrl(it)" target="_blank" rel="noreferrer" class="asset-url-plain">{{
assetUrl(it)
}}</a>
</template>
<span v-else class="asset-url-empty">-</span>
<div
v-if="assetUrlCaptionVisible(it)"
class="asset-url-caption"
:title="assetUrl(it)">
{{ assetUrl(it) }}
</div>
</td>
<td>{{ it.GroupId || it.groupId || '-' }}</td>
<td>{{ it.AssetType || it.assetType || '-' }}</td>
<td>{{ it.Status || it.status || '-' }}</td>
@ -114,7 +156,9 @@
:page-size="filters.pageSize"
show-total
show-jumper
@change="searchAssets"
show-page-size
:page-size-options="listPageSizeOptions"
@change="onAssetPageChange"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
@ -126,46 +170,58 @@
<a-modal
v-model:visible="createAssetVisible"
title="新增素材"
width="520px"
:confirm-loading="createLoading"
@ok="createAsset">
<div class="create-form-vertical">
<div class="field">
<label>素材组</label>
<a-select v-model="createForm.groupId" placeholder="请选择素材组">
<a-form :model="createForm" layout="horizontal" auto-label-width class="asset-create-modal-form">
<a-form-item label="素材组" required>
<a-select v-model="createForm.groupId" placeholder="请选择素材组" allow-clear>
<a-option v-for="g in groups" :key="g.Id || g.id" :value="String(g.Id || g.id)">
{{ g.Name || g.name || (g.Id || g.id) }}
</a-option>
</a-select>
</div>
<div class="field">
<label>上传素材</label>
<input type="file" @change="onFileChange" />
<div class="group-id">{{ createForm.fileName || '未选择文件' }}</div>
</div>
<div class="field">
<label>素材名称</label>
<a-input v-model="createForm.name" placeholder="素材名称(可选)" />
</div>
<div class="field">
<label>类型</label>
</a-form-item>
<a-form-item label="上传文件" required>
<input class="asset-file-input" type="file" @change="onFileChange" />
<div class="asset-file-hint">{{ createForm.fileName || '未选择文件' }}</div>
</a-form-item>
<a-form-item label="素材名称">
<a-input v-model="createForm.name" placeholder="素材名称(可选)" allow-clear />
</a-form-item>
<a-form-item label="类型">
<a-select v-model="createForm.assetType">
<a-option value="Image">图片</a-option>
<a-option value="Video">视频</a-option>
<a-option value="Audio">音频</a-option>
</a-select>
</div>
</div>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script>
import { byteApiItems, byteApiTotalCount } from '@/utils/byteAssetApi'
/** 素材树与筛选区素材组列表统一请求体 */
const GROUP_LIST_REQUEST_BODY = {
filter: { groupType: 'AIGC' },
pageNumber: 1,
pageSize: 100,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
const GROUP_LIST_API = 'api/byteAssetGroup/listAssetGroups'
const ASSET_CREATE_API = 'api/byteAsset/createAsset'
const ASSET_LIST_API = 'api/byteAsset/listAssets'
const ASSET_GET_API = 'api/byteAsset/getAsset'
const ASSET_DELETE_API = 'api/byteAsset/deleteAsset'
/** 列表页码上限(与接口分页一致);每页条数可选项 */
const LIST_PAGE_NUMBER_MAX = 100
const LIST_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
export default {
name: 'AssetManage',
data() {
@ -195,7 +251,9 @@ export default {
totalCount: 0,
items: [],
detailVisible: false,
detailData: null
detailData: null,
listPageSizeOptions: LIST_PAGE_SIZE_OPTIONS,
thumbError: {}
}
},
computed: {
@ -207,6 +265,46 @@ export default {
this.loadGroups()
},
methods: {
assetUrl(it) {
return String(it?.URL || it?.url || '').trim()
},
/** image | video | link */
assetUrlPreviewKind(it) {
const u = this.assetUrl(it)
if (!u || !/^https?:\/\//i.test(u)) return 'link'
const t = String(it?.AssetType || it?.assetType || '')
if (/image/i.test(t)) return 'image'
if (/video/i.test(t)) return 'video'
if (/audio/i.test(t)) return 'link'
if (/\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(u)) return 'image'
if (/\.(mp4|webm|mov|m4v|ogg)(\?|#|$)/i.test(u)) return 'video'
return 'link'
},
onAssetThumbError(it) {
const id = it?.Id || it?.id
if (id) this.thumbError = { ...this.thumbError, [id]: true }
},
assetUrlCaptionVisible(it) {
const u = this.assetUrl(it)
if (!u) return false
const k = this.assetUrlPreviewKind(it)
if (k === 'video') return true
if (k === 'image' && !this.thumbError[it?.Id || it?.id]) return true
return false
},
clampAssetPage(n) {
const page = Number(n) || 1
const size = Number(this.filters.pageSize) || 10
let maxPage = LIST_PAGE_NUMBER_MAX
if (this.totalCount > 0) {
const totalPages = Math.ceil(this.totalCount / size)
maxPage = Math.min(LIST_PAGE_NUMBER_MAX, Math.max(1, totalPages))
}
return Math.min(Math.max(1, page), maxPage)
},
onAssetPageChange(page) {
this.searchAssets(this.clampAssetPage(page))
},
async openCreateAssetDialog() {
await this.loadGroups()
this.createForm.groupId = this.selectedGroupId || this.createForm.groupId || ''
@ -222,17 +320,11 @@ export default {
const res = await this.$axios({
url: GROUP_LIST_API,
method: 'POST',
data: {
Filter: { GroupType: 'AIGC' },
PageNumber: 1,
PageSize: 100,
SortBy: 'CreateTime',
SortOrder: 'Desc'
}
data: { ...GROUP_LIST_REQUEST_BODY }
})
this.groups = Array.isArray(res?.data?.Items) ? res.data.Items : []
this.groups = byteApiItems(res?.data)
if (!this.selectedGroupId && this.groups.length) {
const gid = this.groups[0].Id || this.groups[0].id
const gid = String(this.groups[0].Id || this.groups[0].id || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
@ -245,12 +337,18 @@ export default {
}
},
selectGroup(g) {
const gid = g?.Id || g?.id
const gid = String(g?.Id || g?.id || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
},
onFilterGroupChange(val) {
const gid = val != null && val !== '' ? String(val) : ''
this.selectedGroupId = gid
this.createForm.groupId = gid
this.searchAssets(1)
},
onFileChange(e) {
const file = e?.target?.files?.[0]
this.createForm.file = file || null
@ -290,32 +388,33 @@ export default {
},
buildListPayload() {
const filter = {
GroupType: 'AIGC'
groupType: 'AIGC'
}
const gid = String(this.filters.groupId || '').trim()
if (gid) filter.GroupIds = [gid]
if (gid) filter.groupIds = [gid]
const name = String(this.filters.name || '').trim()
if (name) filter.Name = name
if (this.filters.status) filter.Statuses = [this.filters.status]
if (name) filter.name = name
if (this.filters.status) filter.statuses = [this.filters.status]
return {
Filter: filter,
PageNumber: this.filters.pageNumber,
PageSize: this.filters.pageSize,
SortBy: this.filters.sortBy,
SortOrder: this.filters.sortOrder
filter,
pageNumber: this.filters.pageNumber,
pageSize: this.filters.pageSize,
sortBy: this.filters.sortBy,
sortOrder: this.filters.sortOrder
}
},
async searchAssets(page = this.filters.pageNumber) {
this.filters.pageNumber = Number(page) || 1
this.filters.pageNumber = this.clampAssetPage(page)
this.listLoading = true
this.thumbError = {}
try {
const res = await this.$axios({
url: ASSET_LIST_API,
method: 'POST',
data: this.buildListPayload()
})
this.totalCount = Number(res?.data?.TotalCount || 0)
this.items = Array.isArray(res?.data?.Items) ? res.data.Items : []
this.totalCount = byteApiTotalCount(res?.data)
this.items = byteApiItems(res?.data)
if (res.code !== 200) {
this.$message.error(res.msg || '查询素材失败')
}
@ -326,12 +425,18 @@ export default {
}
},
onPageSizeChange(size) {
this.filters.pageSize = Number(size) || 10
const s = Number(size) || 10
this.filters.pageSize = LIST_PAGE_SIZE_OPTIONS.includes(s) ? s : 10
this.searchAssets(1)
},
resetFilters() {
const gid = this.groups.length
? String(this.groups[0].Id || this.groups[0].id || '')
: String(this.selectedGroupId || '')
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters = {
groupId: this.selectedGroupId || '',
groupId: gid,
name: '',
status: '',
pageNumber: 1,
@ -406,6 +511,10 @@ export default {
border-radius: 12px;
padding: 12px;
}
.asset-right {
min-width: 0;
width: 100%;
}
.panel-title {
font-size: 14px;
font-weight: 600;
@ -472,11 +581,6 @@ export default {
flex-direction: row;
align-items: center;
}
.create-form-vertical {
display: flex;
flex-direction: column;
gap: 12px;
}
.mtop {
margin-top: 14px;
}
@ -487,14 +591,53 @@ export default {
}
.table-wrap {
overflow: auto;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.asset-table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
font-size: 12px;
}
.asset-table th:nth-child(1),
.asset-table td:nth-child(1) {
width: 88px;
word-break: break-all;
}
.asset-table th:nth-child(2),
.asset-table td:nth-child(2) {
width: 14%;
word-break: break-word;
}
.asset-table th:nth-child(3),
.asset-table td:nth-child(3) {
width: 30%;
min-width: 160px;
}
.asset-table th:nth-child(4),
.asset-table td:nth-child(4) {
width: 10%;
word-break: break-all;
}
.asset-table th:nth-child(5),
.asset-table td:nth-child(5) {
width: 9%;
}
.asset-table th:nth-child(6),
.asset-table td:nth-child(6) {
width: 9%;
}
.asset-table th:nth-child(7),
.asset-table td:nth-child(7) {
width: 14%;
word-break: break-word;
}
.asset-table th:nth-child(8),
.asset-table td:nth-child(8) {
width: 16%;
}
.asset-table th,
.asset-table td {
padding: 10px;
@ -506,10 +649,51 @@ export default {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.02);
}
.url-cell {
max-width: 280px;
.asset-url-cell {
min-width: 0;
word-break: break-word;
}
.asset-thumb-link {
display: inline-block;
max-width: 100%;
vertical-align: top;
}
.asset-url-thumb {
display: block;
max-width: 100%;
max-height: 120px;
width: auto;
height: auto;
object-fit: contain;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
}
.asset-url-video {
display: block;
max-width: 100%;
max-height: 160px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: #000;
}
.asset-url-plain {
color: rgba(0, 202, 224, 0.95);
word-break: break-all;
}
.asset-url-empty {
color: rgba(255, 255, 255, 0.35);
}
.asset-url-caption {
margin-top: 6px;
font-size: 11px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.45);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.pager {
margin-top: 10px;
display: flex;
@ -539,3 +723,28 @@ export default {
}
}
</style>
<style lang="less">
/* 弹层挂载到 body与深色页搭配的标签可读性 */
.asset-create-modal-form.arco-form .arco-form-item-label-col .arco-form-item-label,
.ag-create-modal-form.arco-form .arco-form-item-label-col .arco-form-item-label {
color: var(--color-text-2, rgba(255, 255, 255, 0.78));
font-weight: 500;
}
.asset-file-input {
display: block;
width: 100%;
max-width: 100%;
padding: 4px 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.asset-file-hint {
margin-top: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
word-break: break-all;
}
</style>

View File

@ -16,9 +16,13 @@
<div
v-for="row in sortedTaskRows"
:key="row.id"
class="vg-chat-block">
class="vg-chat-block"
:class="{ 'vg-chat-block--compact': !isChatRowSuccessWithMedia(row) }">
<!-- 用户输入部分 -->
<div class="vg-chat-user-section">
<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>
@ -42,14 +46,53 @@
preload="metadata" />
</template>
</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-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>
<!-- AI响应部分视频结果 -->
<div class="vg-chat-ai-section">
<div class="vg-chat-ai-top">
<div class="vg-chat-ai-status">
@ -57,14 +100,7 @@
</div>
<div class="vg-chat-ai-time">{{ formatCreateTime(row.updateTime || row.createTime) }}</div>
</div>
<div v-if="row.status === 0" class="vg-chat-loading">
生成中请稍后
</div>
<div
v-else-if="row.status === 1 && row.result && isHttpOrHttpsUrl(row.result)"
class="vg-chat-result">
<div class="vg-chat-result">
<video
v-if="isVideoUrl(row.result)"
:src="row.result"
@ -74,31 +110,8 @@
<a :href="row.result" target="_blank" rel="noreferrer">查看结果</a>
</div>
</div>
<div
v-else-if="row.status === 1 && row.result && !isHttpOrHttpsUrl(row.result)"
class="vg-chat-loading">
执行任务中请稍后
</div>
<div v-else-if="row.status === 1 && !row.result" class="vg-chat-loading">
执行任务中请稍后
</div>
<div v-else class="vg-chat-failed">
生成失败或已取消
</div>
<div class="vg-chat-ai-actions">
<button
type="button"
class="vg-link"
v-if="row.result && row.status === 0"
@click="cancelRowTask(row)">
取消
</button>
</div>
</div>
</template>
</div>
</div>
<div v-if="chatLoadingMore" class="vg-chat-loadmore">
@ -122,10 +135,59 @@
'描述画面与动态,例如:阳光下的女孩在海边起舞…'
"
: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">
<div class="vg-toolbar-settings">
<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>
@ -175,7 +237,17 @@
</div>
</div>
</div>
<div class="vg-toolbar-actions">
<div
class="vg-toolbar-actions"
:class="{ 'vg-toolbar-actions--reference': videoMode === 'image-reference' }">
<mf-button
v-if="videoMode === 'image-reference'"
size="small"
type="text"
class="vg-toolbar-clear"
@click="clearReferenceCompose">
清空
</mf-button>
<button
type="button"
class="vg-submit-circle"
@ -513,6 +585,16 @@ export default {
return '进行中'
},
/** 是否展示完整「结果区」(视频 / 链接);其余状态仅紧凑展示在用户行右侧 */
isChatRowSuccessWithMedia(row) {
return row.status === 1 && row.result && this.isHttpOrHttpsUrl(row.result)
},
chatRowInlineStatusClass(row) {
if (row.status === 2) return 'vg-chat-inline-status--failed'
return 'vg-chat-inline-status--running'
},
parseDateTimeToMs(value) {
if (!value) return 0
const ms = new Date(value).getTime()
@ -719,6 +801,10 @@ export default {
}
},
clearReferenceCompose() {
this.$refs.videoComposeRef?.clearAll?.()
},
async generateVideo() {
if (this.generateLoading) return
@ -998,13 +1084,13 @@ export default {
radial-gradient(80% 50% at 100% 30%, rgba(33, 151, 255, 0.08), transparent 45%), var(--vg-ink);
}
/* —— Body —— */
/* —— Body:与对话区约 1:3整体约 25% / 75% —— */
.vg-body {
display: flex;
flex: 0 1 auto;
flex: 1 1 0%;
order: 1;
min-height: 0;
max-height: min(48vh, 560px);
max-height: none;
gap: 12px;
align-items: stretch;
}
@ -1069,15 +1155,14 @@ export default {
min-width: 280px;
min-height: 0;
display: flex;
overflow: visible;
flex-direction: column;
overflow: hidden;
}
.vg-generator-inner {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 10px;
@ -1176,6 +1261,13 @@ export default {
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 {
@ -1253,9 +1345,74 @@ export default {
}
}
/* 参考图:工具条在富文本右侧窄列内,清空与提交纵向排列 */
.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: 1fr 1fr;
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: auto;
min-width: 0;
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.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 {
@ -1388,10 +1545,12 @@ export default {
gap: 10px;
}
@media (max-width: 640px) {
@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;
@ -1418,6 +1577,12 @@ export default {
min-height: 260px;
max-height: min(55vh, 480px);
}
/* 对话内视频小屏占满宽,避免横向截断 */
.vg-chat-result video {
width: 100%;
max-width: 100%;
}
}
.vg-task-empty {
@ -1428,13 +1593,13 @@ export default {
.vg-chat-section {
order: 0;
flex: 1 1 auto;
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: min(42vh, 480px);
min-height: 0;
display: flex;
flex-direction: column;
}
@ -1477,12 +1642,29 @@ export default {
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;
}
@ -1530,6 +1712,53 @@ export default {
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-ai-top {
display: flex;
align-items: center;
@ -1690,7 +1919,7 @@ export default {
text-decoration: underline;
}
/* 小于 1920×1080 常见宽度:底部参数区纵向排布,避免挤压富文本 */
/* 小于 1920×1080:工具条参数纵向排布;整体仍保持约 75%/25% 弹性分配 */
@media (max-width: 1919px) {
.video-gen {
padding: 14px 12px 12px;
@ -1698,14 +1927,9 @@ export default {
}
.vg-chat-section {
min-height: min(40vh, 440px);
padding: 12px 14px;
}
.vg-body {
max-height: min(50vh, 540px);
}
.vg-generator-inner {
padding: 10px 12px;
gap: 8px;