Compare commits

..

No commits in common. "ff18ca4f511be8bd6b13bdf0bfd980698c4c7f5c" and "1edf9601c0afed4255ee355a44d509e7c1555064" have entirely different histories.

14 changed files with 107 additions and 1406 deletions

View File

@ -54,22 +54,16 @@
<div v-else-if="isReference" class="vg-reference-panel">
<div class="vg-compose-left-head">
<div class="vg-compose-left-title">
参考素材{{ mediaList.length }}/{{ maxMediaCount }}
参考{{ mediaList.length }}/{{ maxMediaCount }}
</div>
</div>
<div class="vg-asset-controls">
<input v-model.trim="assetGroupId" class="vg-asset-group-input" placeholder="请输入 GroupId" />
<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 class="vg-compose-empty-text">点击添加参考图</div>
</div>
<div v-else class="vg-compose-media-scroll">
@ -79,17 +73,7 @@
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>
<img :src="item.url" alt="" />
</div>
<button
type="button"
@ -127,21 +111,13 @@
@click="onEditorClick"></div>
<div v-if="mentionVisible" class="vg-mention-panel">
<div
v-for="(item, idx) in mentionCandidates"
v-for="item in mentionCandidates"
:key="item.key"
:class="['vg-mention-item', { active: idx === mentionActiveIndex }]"
class="vg-mention-item"
@mousedown.prevent="selectMentionItem(item)">
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" class="vg-mention-thumb" />
<video
v-else-if="item.mediaType === 'video'"
:src="item.url"
class="vg-mention-thumb"
muted
playsinline
preload="metadata" />
<span v-else class="vg-mention-audio">{{ item.name || '音频' }}</span>
<img :src="item.url" alt="" class="vg-mention-thumb" />
</div>
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用素材</div>
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用参考图</div>
</div>
</div>
@ -160,12 +136,12 @@
</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'
/** 参考素材:同类最多不同素材条数(同一素材可多次 @),与 maxMediaCount 对齐 */
const MAX_REFERENCE_UNIQUE_KIND = 9
/** 图生参考:不同参考图最多 4 张图1图4同一 URL 可多次 @ */
const MAX_REFERENCE_UNIQUE = 4
const props = defineProps({
modelValue: {
@ -198,15 +174,11 @@ 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
const savedSelectionRange = ref(null)
const mentionVisible = ref(false)
const mentionActiveIndex = ref(-1)
const assetGroupId = ref('')
const assetLoading = ref(false)
watch(
() => props.modelValue,
@ -249,19 +221,10 @@ onMounted(() => {
const mediaList = computed(() => internalMediaList.value)
const mentionCandidates = computed(() =>
(mediaList.value || [])
.filter((i) => {
const u = String(i?.url || '').trim()
const t = i?.mediaType
if (!['image', 'video', 'audio'].includes(t) || i?.isUploading) return false
if (!i?.assetId && !/^https?:\/\//i.test(u)) return false
return true
})
.filter((i) => i?.mediaType === 'image' && i?.url)
.map((i, idx) => ({
key: i.id || i.url || String(idx),
url: i.url,
assetId: i.assetId || '',
mediaType: i.mediaType || 'image',
name: i.name || ''
url: i.url
}))
)
@ -292,16 +255,9 @@ const openFilePickerFor = (index) => {
const acceptAttr = computed(() => {
const types = new Set(props.allowedMediaTypes || [])
const hasI = types.has('image')
const hasV = types.has('video')
const hasA = types.has('audio')
if (hasI && hasV && hasA) return 'image/*,video/*,audio/*'
if (hasI && hasV) return 'image/*,video/*'
if (hasI && hasA) return 'image/*,audio/*'
if (hasV && hasA) return 'video/*,audio/*'
if (hasI) return 'image/*'
if (hasV) return 'video/*'
if (hasA) return 'audio/*'
if (types.has('image') && types.has('video')) return 'image/*,video/*'
if (types.has('image')) return 'image/*'
if (types.has('video')) return 'video/*'
return 'image/*,video/*'
})
@ -309,11 +265,9 @@ const detectMediaType = (file) => {
const mime = (file?.type || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
const lowerName = (file?.name || '').toLowerCase()
if (/\.(png|jpe?g|gif|webp|bmp|svg|image)$/.test(lowerName)) return 'image'
if (/\.(mp4|mov|webm|m4v|avi|mkv|video)$/.test(lowerName)) return 'video'
if (/\.(mp3|wav|m4a|aac|flac|wma|audio)$/.test(lowerName)) return 'audio'
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
if (/\.(mp4|mov|webm|m4v|avi|mkv|ogg)$/.test(lowerName)) return 'video'
return 'image'
}
@ -332,7 +286,7 @@ const removeItem = (item) => {
const saveReferenceToStorage = (list) => {
try {
localStorage.setItem('video_reference_media', JSON.stringify(list))
localStorage.setItem('video_reference_images', JSON.stringify(list))
} catch (e) {
console.warn('Failed to save reference images to localStorage')
}
@ -340,7 +294,7 @@ const saveReferenceToStorage = (list) => {
const loadReferenceFromStorage = () => {
try {
const saved = localStorage.getItem('video_reference_media') || localStorage.getItem('video_reference_images')
const saved = localStorage.getItem('video_reference_images')
if (saved) {
return JSON.parse(saved)
}
@ -355,82 +309,12 @@ const clearAll = () => {
mentionVisible.value = false
}
const mergeAssetsToMediaList = (assets) => {
const existing = new Set((mediaList.value || []).map((x) => x.assetId || x.url))
const next = [...mediaList.value]
for (const item of assets) {
const key = item.assetId || item.url
if (!key || existing.has(key)) continue
next.push(item)
existing.add(key)
if (next.length >= props.maxMediaCount) break
}
setMediaList(next.slice(0, props.maxMediaCount))
}
const loadAssetsByGroup = async () => {
const gid = String(assetGroupId.value || '').trim()
if (!gid) {
Message.warning('请先填写 GroupId 再查询')
return
}
if (!proxy?.$axios) {
Message.error('查询失败axios 未就绪')
return
}
assetLoading.value = true
try {
const res = await proxy.$axios({
url: 'api/portal/asset/listAssets',
method: 'POST',
data: {
Filter: {
GroupIds: [gid],
GroupType: 'AIGC',
Statuses: ['Active']
},
PageNumber: 1,
PageSize: 100,
SortBy: 'CreateTime',
SortOrder: 'Desc'
}
})
const rows = Array.isArray(res?.data?.Items) ? res.data.Items : []
const assets = rows
.map((row) => {
const at = String(row?.AssetType || row?.assetType || 'Image').toLowerCase()
let mediaType = 'image'
if (at.includes('video')) mediaType = 'video'
else if (at.includes('audio')) mediaType = 'audio'
return {
id: row?.Id || row?.id || `asset_${Math.random().toString(16).slice(2)}`,
assetId: row?.Id || row?.id || '',
url: row?.URL || row?.url || '',
mediaType,
name: row?.Name || row?.name || ''
}
})
.filter((x) => x.assetId && /^https?:\/\//i.test(String(x.url || '').trim()))
mergeAssetsToMediaList(assets)
Message.success(`已加载 ${assets.length} 条可用素材`)
} catch (err) {
Message.error(err?.message || '查询素材失败')
} finally {
assetLoading.value = false
}
}
const handleSelectFiles = async (event) => {
const input = event.target
const files = Array.from(input.files || [])
if (!files.length) return
const isReferenceMode = isReference.value
if (isReferenceMode && !String(assetGroupId.value || '').trim()) {
Message.warning('参考图模式请先填写 GroupId')
input.value = ''
return
}
let targetList = [...mediaList.value]
//
@ -501,39 +385,15 @@ const loadAssetsByGroup = async () => {
})
const url = extractUploadUrlFromResponse(res)
if (!url) throw new Error(res?.msg || '未返回文件地址')
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 未就绪')
const mt = entry.mediaType || 'image'
const assetType =
mt === 'video' ? 'Video' : mt === 'audio' ? 'Audio' : 'Image'
const createRes = await proxy.$axios({
url: 'api/portal/asset/createAsset',
method: 'POST',
data: {
GroupId: gid,
URL: url,
Name: entry?.name || '',
AssetType: assetType
}
})
assetId = createRes?.data?.Id || createRes?.data?.id || ''
if (!assetId) throw new Error(createRes?.msg || '创建素材失败未返回资产ID')
}
const localPreview = entry.url
setMediaList(
mediaList.value.map((x) =>
normalizeItemKey(x) === normalizeItemKey(entry)
? { ...x, url, assetId, isUploading: false }
? { ...x, url, isUploading: false }
: x
)
)
if (isReferenceMode) {
Message.success('已上传完成')
}
try {
URL.revokeObjectURL(localPreview)
@ -612,7 +472,7 @@ const isBlockElement = (n) =>
n.nodeType === Node.ELEMENT_NODE &&
['DIV', 'P', 'LI'].includes(n.tagName)
/** 与编辑器 DOM 顺序一致:描述与 [图片n]/[视频n]/[音频n] 在文中的位置即用户输入/插入的位置 */
/** 与编辑器 DOM 顺序一致:描述与 [图n] 在文中的位置即用户输入/插入的位置 */
const getEditorPlainText = () => {
const editor = editorRef.value
if (!editor) return ''
@ -644,139 +504,97 @@ const getEditorPlainText = () => {
return out.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim()
}
const refKeyForEl = (el) => {
const kind = el.getAttribute('data-reference-kind') || 'image'
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
const url = (el.getAttribute('data-reference-url') || '').trim()
return `${kind}:${assetId || url}`
}
const getUniqueRefKeysInDocForKind = (kind) => {
const getUniqueRefUrlsInDoc = () => {
const s = new Set()
editorRef.value?.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
if ((el.getAttribute('data-reference-kind') || 'image') !== kind) return
const k = refKeyForEl(el)
if (k && k !== `${kind}:`) s.add(k)
const u = el.getAttribute('data-reference-url')
if (u) s.add(u)
})
return s
}
/** 按文档顺序:同类素材独立编号 [图片n][视频n][音频n],同一素材多次 @ 共用同一序号 */
/** 按文档顺序为不同 URL 分配 图1图4并同步 data-token / 展示 */
const renumberAllReferenceMentions = () => {
if (!editorRef.value || !isReference.value) return
const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]'))
const imgNo = new Map()
const vidNo = new Map()
const audNo = new Map()
let imgNext = 1
let vidNext = 1
let audNext = 1
const urlToNo = new Map()
let next = 1
let droppedExtra = false
for (const el of refs) {
const kind = el.getAttribute('data-reference-kind') || 'image'
const keyCore = (el.getAttribute('data-reference-asset-id') || el.getAttribute('data-reference-url') || '').trim()
if (!keyCore) {
const u = el.getAttribute('data-reference-url') || ''
if (!u) {
el.remove()
continue
}
let map
let nextRef
let maxU
if (kind === 'video') {
map = vidNo
nextRef = () => vidNext++
maxU = MAX_REFERENCE_UNIQUE_KIND
} else if (kind === 'audio') {
map = audNo
nextRef = () => audNext++
maxU = MAX_REFERENCE_UNIQUE_KIND
} else {
map = imgNo
nextRef = () => imgNext++
maxU = MAX_REFERENCE_UNIQUE_KIND
}
const fullKey = `${kind}:${keyCore}`
if (!map.has(fullKey)) {
if (map.size >= maxU) {
if (!urlToNo.has(u)) {
if (next > MAX_REFERENCE_UNIQUE) {
el.remove()
droppedExtra = true
continue
}
map.set(fullKey, nextRef())
urlToNo.set(u, next++)
}
const n = map.get(fullKey)
const token =
kind === 'video' ? `[视频${n}]` : kind === 'audio' ? `[音频${n}]` : `[图片${n}]`
const n = urlToNo.get(u)
const token = `[图${n}]`
el.setAttribute('data-token', token)
const prev = el.querySelector('.vg-inline-ref-image, .vg-inline-ref-video, .vg-inline-ref-audio')
if (prev) {
if (prev.tagName === 'IMG' || prev.tagName === 'VIDEO') prev.setAttribute('alt', token)
}
const imgEl = el.querySelector('.vg-inline-ref-image')
if (imgEl) imgEl.alt = token
}
if (droppedExtra) {
Message.warning(`同类参考素材最多 ${MAX_REFERENCE_UNIQUE_KIND},已移除多余引用`)
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,已移除多余引用`)
}
}
const reconcileReferenceMentions = (nextList) => {
if (!editorRef.value) return
const allowed = new Set(
(nextList || [])
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetId || x?.url))
.map((x) => `${x.mediaType}:${x.assetId || x.url}`)
(nextList || []).filter((x) => x?.mediaType === 'image' && x?.url).map((x) => x.url)
)
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
const k = refKeyForEl(el)
if (!allowed.has(k)) el.remove()
const u = el.getAttribute('data-reference-url') || ''
if (!allowed.has(u)) el.remove()
})
renumberAllReferenceMentions()
setPrompt(getEditorPlainText())
}
/** 文档顺序下去重后的参考素材(用于 content同类内顺序即 [图片n] 等序号依据 */
const collectReferenceContentInOrder = () => {
/** 文档顺序下首次出现的参考图 URL对应 图1、图2… */
const collectReferenceUrlsInDocOrder = () => {
const editor = editorRef.value
if (!editor) return []
const out = []
const urls = []
const seen = new Set()
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) return
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node
if (el.dataset?.mentionReference === '1') {
const kind = el.getAttribute('data-reference-kind') || 'image'
const assetId = (el.getAttribute('data-reference-asset-id') || '').trim()
const url = (el.getAttribute('data-reference-url') || '').trim()
const key = `${kind}:${assetId || url}`
if (!key || key === `${kind}:` || seen.has(key)) return
seen.add(key)
out.push({ kind, assetId, url })
const url = el.getAttribute('data-reference-url') || ''
if (url && !seen.has(url)) {
seen.add(url)
urls.push(url)
}
return
}
Array.from(el.childNodes).forEach(walk)
}
Array.from(editor.childNodes).forEach(walk)
return out
return urls
}
/**
* 参考图模式提交用首条 type=text [图片n]/[视频n]/[音频n]不含 Asset ID
* 其后为各类 reference_*url 可为 asset:// http(s)
* 参考图模式提交用1 条必须是 text整段文案仅含 [图n]不含 URL
* 其后图1图n 顺序各一条 image_url + reference_image
*/
const getImageReferenceContentItems = () => {
const text = getEditorPlainText()
const first = { type: 'text', text: text || '' }
const refs = collectReferenceContentInOrder()
const rest = refs.map((ref) => {
const urlStr = ref.assetId ? `asset://${ref.assetId}` : ref.url
if (ref.kind === 'video') {
return { type: 'video_url', video_url: { url: urlStr }, role: 'reference_video' }
}
if (ref.kind === 'audio') {
return { type: 'audio_url', audio_url: { url: urlStr }, role: 'reference_audio' }
}
return { type: 'image_url', image_url: { url: urlStr }, role: 'reference_image' }
})
const urls = collectReferenceUrlsInDocOrder()
const rest = urls.map((url) => ({
type: 'image_url',
image_url: { url },
role: 'reference_image'
}))
return [first, ...rest]
}
@ -785,12 +603,6 @@ const onEditorInput = () => {
setPrompt(getEditorPlainText())
saveSelection()
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
if (mentionVisible.value) {
const max = mentionCandidates.value.length - 1
mentionActiveIndex.value = max >= 0 ? Math.min(Math.max(mentionActiveIndex.value, 0), max) : -1
} else {
mentionActiveIndex.value = -1
}
}
const findClosestMentionInEditor = (el) => {
@ -824,41 +636,9 @@ const onEditorClick = (e) => {
}
saveSelection()
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
}
const onEditorKeydown = (e) => {
if (mentionVisible.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const len = mentionCandidates.value.length
if (len > 0) {
if (mentionActiveIndex.value < 0) {
mentionActiveIndex.value = 0
} else {
const delta = e.key === 'ArrowDown' ? 1 : -1
mentionActiveIndex.value = (mentionActiveIndex.value + delta + len) % len
}
}
return
}
if (e.key === 'Enter') {
const idx = mentionActiveIndex.value >= 0 ? mentionActiveIndex.value : 0
const picked = mentionCandidates.value[idx]
if (picked) {
e.preventDefault()
selectMentionItem(picked)
return
}
}
if (e.key === 'Escape') {
e.preventDefault()
mentionVisible.value = false
mentionActiveIndex.value = -1
return
}
}
if (!isReference.value || !editorRef.value) return
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return
@ -951,27 +731,16 @@ const onEditorKeyup = (e) => {
saveSelection()
if (e.key === 'Escape') {
mentionVisible.value = false
mentionActiveIndex.value = -1
return
}
mentionVisible.value = isReference.value && hasActiveMentionTrigger()
if (mentionVisible.value && e.key === '@' && mentionCandidates.value.length === 0) {
Message.warning('请等待上传完成后再引用')
mentionVisible.value = false
mentionActiveIndex.value = -1
return
}
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
}
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} 种,无法再插入新素材`)
const urls = getUniqueRefUrlsInDoc()
if (!urls.has(item.url) && urls.size >= MAX_REFERENCE_UNIQUE) {
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,无法再插入新图`)
mentionVisible.value = false
return
}
@ -985,42 +754,25 @@ const selectMentionItem = (item) => {
const range = selection.getRangeAt(0)
if (!editorRef.value.contains(range.commonAncestorContainer)) return
const token = '[?]'
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-reference-asset-id', item.assetId || '')
holder.setAttribute('data-reference-kind', kind)
holder.setAttribute('contenteditable', 'false')
if (kind === 'video') {
const v = document.createElement('video')
v.src = item.url
v.className = 'vg-inline-ref-video'
v.muted = true
v.playsInline = true
v.setAttribute('preload', 'metadata')
v.setAttribute('draggable', 'false')
v.setAttribute('contenteditable', 'false')
holder.appendChild(v)
} else if (kind === 'audio') {
const span = document.createElement('span')
span.className = 'vg-inline-ref-audio'
span.textContent = item.name ? `${item.name}` : '♪ 音频'
holder.appendChild(span)
} else {
const img = document.createElement('img')
img.src = item.url
img.alt = ''
img.setAttribute('draggable', 'false')
img.setAttribute('contenteditable', 'false')
img.className = 'vg-inline-ref-image'
holder.appendChild(img)
}
const img = document.createElement('img')
img.src = item.url
img.alt = ''
img.setAttribute('draggable', 'false')
img.setAttribute('contenteditable', 'false')
img.className = 'vg-inline-ref-image'
holder.appendChild(img)
range.insertNode(holder)
// text [1][2]
range.setStartAfter(holder)
range.collapse(true)
selection.removeAllRanges()
@ -1028,7 +780,6 @@ const selectMentionItem = (item) => {
saveSelection()
mentionVisible.value = false
mentionActiveIndex.value = -1
renumberAllReferenceMentions()
setPrompt(getEditorPlainText())
}
@ -1040,7 +791,6 @@ defineExpose({
setPrompt('')
if (editorRef.value) editorRef.value.innerHTML = ''
mentionVisible.value = false
mentionActiveIndex.value = -1
}
})
</script>
@ -1174,29 +924,6 @@ defineExpose({
color: #a8f4f9;
}
.vg-asset-controls {
display: flex;
gap: 8px;
align-items: center;
}
.vg-asset-group-input {
flex: 1;
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;
}
.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);
}
.hidden-input {
display: none;
}
@ -1409,55 +1136,6 @@ defineExpose({
pointer-events: none;
}
.vg-rich-editor :deep(.vg-inline-ref-video) {
display: block;
max-width: 120px;
max-height: 72px;
border-radius: 6px;
object-fit: cover;
pointer-events: none;
vertical-align: middle;
}
.vg-rich-editor :deep(.vg-inline-ref-audio) {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 202, 224, 0.12);
border-radius: 8px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
pointer-events: none;
}
.vg-audio-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 100%;
color: rgba(255, 255, 255, 0.75);
font-size: 12px;
}
.vg-audio-tile-icon {
font-size: 22px;
color: rgba(0, 202, 224, 0.85);
}
.vg-mention-audio {
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
padding: 0 6px;
}
.vg-mention-panel {
position: absolute;
left: 8px;
@ -1485,10 +1163,6 @@ defineExpose({
background: rgba(255, 255, 255, 0.06);
}
.vg-mention-item.active {
background: rgba(0, 202, 224, 0.18);
}
.vg-mention-thumb {
width: 30px;
height: 30px;

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'إنشاء فيديو',
recharge: 'شحن سريع',
help: 'مركز المساعدة',
moneyInvite: 'دعوة مكافآت',
assetGroupManage: 'إدارة مجموعات الأصول',
assetManage: 'إدارة الأصول'
moneyInvite: 'دعوة مكافآت'
}

View File

@ -9,7 +9,5 @@ export default {
videoGen: 'Video Generation',
recharge: 'Quick Recharge',
help: 'Help Center',
moneyInvite: 'Reward Invitation',
assetGroupManage: 'Asset Group Manage',
assetManage: 'Asset Manage'
moneyInvite: 'Reward Invitation'
}

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'Generar video',
recharge: 'Recarga rápida',
help: 'Centro de ayuda',
moneyInvite: 'Invitación con recompensa',
assetGroupManage: 'Gestión de grupos de activos',
assetManage: 'Gestión de activos'
moneyInvite: 'Invitación con recompensa'
}

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'Générer une vidéo',
recharge: 'Recharge rapide',
help: 'Centre d\'aide',
moneyInvite: 'Invitation avec récompense',
assetGroupManage: 'Gestion des groupes d\'actifs',
assetManage: 'Gestion des actifs'
moneyInvite: 'Invitation avec récompense'
}

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'वीडियो जनरेट करें',
recharge: 'त्वरित रिचार्ज',
help: 'सहायता केंद्र',
moneyInvite: 'इनाम निमंत्रण',
assetGroupManage: 'एसेट समूह प्रबंधन',
assetManage: 'एसेट प्रबंधन'
moneyInvite: 'इनाम निमंत्रण'
}

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'Gerar vídeo',
recharge: 'Recarga rápida',
help: 'Central de ajuda',
moneyInvite: 'Convite com recompensa',
assetGroupManage: 'Gerenciamento de grupos de ativos',
assetManage: 'Gerenciamento de ativos'
moneyInvite: 'Convite com recompensa'
}

View File

@ -8,7 +8,5 @@ export default {
fastVideo: 'Создать видео',
recharge: 'Быстрая пополнение',
help: 'Центр помощи',
moneyInvite: 'Приглашение с наградой',
assetGroupManage: 'Управление группами ресурсов',
assetManage: 'Управление ресурсами'
moneyInvite: 'Приглашение с наградой'
}

View File

@ -9,7 +9,5 @@ export default {
videoGen: '視頻生成',
recharge: '快速充值',
help: '幫助中心',
moneyInvite: '有獎邀請',
assetGroupManage: '資源組管理',
assetManage: '素材管理'
moneyInvite: '有獎邀請'
}

View File

@ -51,7 +51,7 @@ import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由name 与 router/index.js 一致) */
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage']
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen']
defineProps({
collapsed: Boolean

View File

@ -129,26 +129,6 @@ export const constantRoutes = [{
permission: "pass",
icon: 'btn_video'
}
}, {
path: 'asset-group-manage',
name: 'asset-group-manage',
component: () => import('@/views/AssetGroupManage.vue'),
meta: {
title: 'assetGroupManage',
menuItem: true,
permission: "pass",
icon: 'btn_video'
}
}, {
path: 'asset-manage',
name: 'asset-manage',
component: () => import('@/views/AssetManage.vue'),
meta: {
title: 'assetManage',
menuItem: true,
permission: "pass",
icon: 'btn_video'
}
}, {
path: 'recharge',
name: 'recharge',

View File

@ -1,387 +0,0 @@
<template>
<div class="asset-group-page">
<section class="ag-panel">
<h3 class="ag-title">新增资源组</h3>
<div class="ag-form">
<div class="ag-field">
<label>名称</label>
<a-input v-model="createForm.name" placeholder="请输入资源组名称(<=64字符" />
</div>
<div class="ag-field">
<label>描述</label>
<a-textarea
v-model="createForm.description"
:max-length="300"
show-word-limit
placeholder="请输入描述(<=300字符" />
</div>
<div class="ag-field">
<label>GroupType</label>
<a-select v-model="createForm.groupType" :disabled="true">
<a-option value="AIGC">AIGC</a-option>
</a-select>
</div>
<div class="ag-actions">
<a-button type="primary" :loading="createLoading" @click="createGroup">新增</a-button>
</div>
</div>
</section>
<section class="ag-panel">
<h3 class="ag-title">查询资源组</h3>
<div class="ag-filter">
<div class="ag-field">
<label>名称</label>
<a-input v-model="filters.name" placeholder="按名称过滤" />
</div>
<div class="ag-field">
<label>GroupIds</label>
<a-input v-model="filters.groupIdsText" placeholder="多个ID用英文逗号分隔" />
</div>
<div class="ag-field">
<label>GroupType</label>
<a-select v-model="filters.groupType">
<a-option value="AIGC">AIGC</a-option>
</a-select>
</div>
<div class="ag-field">
<label>SortBy</label>
<a-select v-model="filters.sortBy">
<a-option value="CreateTime">CreateTime</a-option>
<a-option value="UpdateTime">UpdateTime</a-option>
</a-select>
</div>
<div class="ag-field">
<label>SortOrder</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">Desc</a-option>
<a-option value="Asc">Asc</a-option>
</a-select>
</div>
<div class="ag-actions">
<a-button type="primary" :loading="listLoading" @click="search(1)">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</div>
</div>
<a-spin :loading="listLoading">
<div class="ag-total">总数{{ totalCount }}</div>
<div class="ag-table-wrap">
<table class="ag-table">
<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>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.Id || item.id">
<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>{{ item.ProjectName || item.projectName || '-' }}</td>
<td>{{ item.CreateTime || item.createTime || '-' }}</td>
<td>{{ item.UpdateTime || item.updateTime || '-' }}</td>
<td>
<a-button
size="mini"
type="outline"
:loading="detailLoadingId === (item.Id || item.id)"
@click="getDetail(item)">
详情
</a-button>
</td>
</tr>
<tr v-if="!items.length">
<td colspan="8" class="ag-empty">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<div class="ag-pagination">
<a-pagination
:total="totalCount"
:current="filters.pageNumber"
:page-size="filters.pageSize"
show-total
show-jumper
@change="search"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
</section>
<a-modal v-model:visible="detailVisible" title="资源组详情" :footer="false" width="680px">
<pre class="ag-detail">{{ prettyDetail }}</pre>
</a-modal>
</div>
</template>
<script>
export default {
name: 'AssetGroupManage',
data() {
return {
createLoading: false,
listLoading: false,
detailLoadingId: '',
detailVisible: false,
detailData: null,
createForm: {
name: '',
description: '',
groupType: 'AIGC'
},
filters: {
name: '',
groupIdsText: '',
groupType: 'AIGC',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
},
totalCount: 0,
items: []
}
},
computed: {
prettyDetail() {
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
}
},
mounted() {
this.search(1)
},
methods: {
buildGroupIds() {
return String(this.filters.groupIdsText || '')
.split(',')
.map((x) => x.trim())
.filter((x) => !!x)
},
async createGroup() {
const name = String(this.createForm.name || '').trim()
if (!name) {
this.$message.error('请填写名称')
return
}
this.createLoading = true
try {
const res = await this.$axios({
url: 'api/portal/asset/post',
method: 'POST',
data: {
Name: name,
Description: String(this.createForm.description || '').trim(),
GroupType: 'AIGC'
}
})
if (res.code === 200) {
this.$message.success('新增成功')
this.createForm.name = ''
this.createForm.description = ''
this.search(1)
} else {
this.$message.error(res.msg || '新增失败')
}
} catch (e) {
this.$message.error(e?.message || '新增失败')
} finally {
this.createLoading = false
}
},
async search(page = this.filters.pageNumber) {
this.filters.pageNumber = Number(page) || 1
this.listLoading = true
try {
const payload = {
Filter: {
GroupType: this.filters.groupType || 'AIGC'
},
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
const ids = this.buildGroupIds()
if (ids.length) payload.Filter.GroupIds = ids
const res = await this.$axios({
url: 'api/portal/asset/list',
method: 'POST',
data: payload
})
const data = res.data || {}
this.totalCount = Number(data.TotalCount || 0)
this.items = Array.isArray(data.Items) ? data.Items : []
if (res.code !== 200) {
this.$message.error(res.msg || '查询失败')
}
} catch (e) {
this.$message.error(e?.message || '查询失败')
} finally {
this.listLoading = false
}
},
onPageSizeChange(size) {
this.filters.pageSize = Number(size) || 10
this.search(1)
},
resetFilters() {
this.filters = {
name: '',
groupIdsText: '',
groupType: 'AIGC',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
this.search(1)
},
async getDetail(item) {
const id = item?.Id || item?.id
if (!id) return
this.detailLoadingId = id
try {
const res = await this.$axios({
url: 'api/portal/asset/get',
method: 'POST',
data: { Id: id }
})
if (res.code === 200) {
this.detailData = res.data || {}
this.detailVisible = true
} else {
this.$message.error(res.msg || '查询详情失败')
}
} catch (e) {
this.$message.error(e?.message || '查询详情失败')
} finally {
this.detailLoadingId = ''
}
}
}
}
</script>
<style scoped lang="less">
.asset-group-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
background: #0a0b0d;
color: rgba(255, 255, 255, 0.88);
min-height: 100%;
}
.ag-panel {
background: rgba(22, 24, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
}
.ag-title {
margin: 0 0 14px;
font-size: 15px;
font-weight: 600;
}
.ag-form,
.ag-filter {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
}
.ag-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.ag-field label {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.ag-actions {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ag-total {
margin: 6px 0 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.ag-table-wrap {
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.ag-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.ag-table th,
.ag-table td {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
.ag-table th {
color: rgba(255, 255, 255, 0.7);
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
.ag-empty {
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.ag-pagination {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.ag-detail {
margin: 0;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
color: #d8f4f7;
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
@media (max-width: 960px) {
.ag-form,
.ag-filter {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,496 +0,0 @@
<template>
<div class="asset-manage-page">
<section class="asset-left">
<div class="panel-title">素材组树</div>
<a-button size="mini" type="outline" :loading="groupLoading" @click="loadGroups">刷新分组</a-button>
<div class="group-tree">
<div
v-for="g in groups"
:key="g.Id || g.id"
:class="['group-node', { active: selectedGroupId === (g.Id || g.id) }]"
@click="selectGroup(g)">
<span class="folder-icon">📁</span>
<div class="group-meta">
<div class="group-name">{{ g.Name || g.name || (g.Id || g.id) }}</div>
<div class="group-id">{{ g.Id || g.id }}</div>
</div>
</div>
<div v-if="!groups.length" class="empty-tip">暂无素材组请先在资源组管理创建</div>
</div>
</section>
<section class="asset-right">
<div class="panel-title">新增素材</div>
<div class="form-grid">
<div class="field">
<label>GroupId</label>
<a-input v-model="createForm.groupId" placeholder="请选择左侧分组或手动输入" />
</div>
<div class="field">
<label>URL</label>
<a-input v-model="createForm.url" placeholder="素材公网 URLhttp/https" />
</div>
<div class="field">
<label>Name</label>
<a-input v-model="createForm.name" placeholder="素材名称(可选)" />
</div>
<div class="field">
<label>AssetType</label>
<a-select v-model="createForm.assetType">
<a-option value="Image">Image</a-option>
<a-option value="Video">Video</a-option>
<a-option value="Audio">Audio</a-option>
</a-select>
</div>
<div class="field actions">
<a-button type="primary" :loading="createLoading" @click="createAsset">新增素材</a-button>
</div>
</div>
<div class="panel-title mtop">查询素材</div>
<div class="form-grid">
<div class="field">
<label>GroupId</label>
<a-input v-model="filters.groupId" placeholder="按组过滤(默认选中左侧)" />
</div>
<div class="field">
<label>Name</label>
<a-input v-model="filters.name" placeholder="按名称过滤" />
</div>
<div class="field">
<label>Status</label>
<a-select v-model="filters.status">
<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-select>
</div>
<div class="field">
<label>SortBy</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-select>
</div>
<div class="field">
<label>SortOrder</label>
<a-select v-model="filters.sortOrder">
<a-option value="Desc">Desc</a-option>
<a-option value="Asc">Asc</a-option>
</a-select>
</div>
<div class="field actions">
<a-button type="primary" :loading="listLoading" @click="searchAssets(1)">查询</a-button>
<a-button @click="resetFilters">重置</a-button>
</div>
</div>
<a-spin :loading="listLoading">
<div class="total-line">总数{{ totalCount }}</div>
<div class="table-wrap">
<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>
</tr>
</thead>
<tbody>
<tr v-for="it in items" :key="it.Id || it.id">
<td>{{ it.Id || it.id }}</td>
<td>{{ it.Name || it.name || '-' }}</td>
<td class="url-cell">{{ it.URL || it.url || '-' }}</td>
<td>{{ it.GroupId || it.groupId || '-' }}</td>
<td>{{ it.AssetType || it.assetType || '-' }}</td>
<td>{{ it.Status || it.status || '-' }}</td>
<td>{{ it.CreateTime || it.createTime || '-' }}</td>
<td>
<a-button size="mini" type="outline" @click="getAsset(it)">详情</a-button>
<a-button size="mini" status="danger" @click="deleteAsset(it)">删除</a-button>
</td>
</tr>
<tr v-if="!items.length">
<td colspan="8" class="empty-tip">暂无素材</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<a-pagination
:total="totalCount"
:current="filters.pageNumber"
:page-size="filters.pageSize"
show-total
show-jumper
@change="searchAssets"
@page-size-change="onPageSizeChange" />
</div>
</a-spin>
</section>
<a-modal v-model:visible="detailVisible" title="素材详情" :footer="false" width="700px">
<pre class="detail-box">{{ detailText }}</pre>
</a-modal>
</div>
</template>
<script>
const GROUP_LIST_API = 'api/portal/asset/list'
const ASSET_CREATE_API = 'api/portal/asset/createAsset'
const ASSET_LIST_API = 'api/portal/asset/listAssets'
const ASSET_GET_API = 'api/portal/asset/getAsset'
const ASSET_DELETE_API = 'api/portal/asset/deleteAsset'
export default {
name: 'AssetManage',
data() {
return {
groupLoading: false,
createLoading: false,
listLoading: false,
groups: [],
selectedGroupId: '',
createForm: {
groupId: '',
url: '',
name: '',
assetType: 'Image'
},
filters: {
groupId: '',
name: '',
status: '',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
},
totalCount: 0,
items: [],
detailVisible: false,
detailData: null
}
},
computed: {
detailText() {
return this.detailData ? JSON.stringify(this.detailData, null, 2) : '{}'
}
},
mounted() {
this.loadGroups()
},
methods: {
async loadGroups() {
this.groupLoading = true
try {
const res = await this.$axios({
url: GROUP_LIST_API,
method: 'POST',
data: {
Filter: { GroupType: 'AIGC' },
PageNumber: 1,
PageSize: 100,
SortBy: 'CreateTime',
SortOrder: 'Desc'
}
})
this.groups = Array.isArray(res?.data?.Items) ? res.data.Items : []
if (!this.selectedGroupId && this.groups.length) {
const gid = this.groups[0].Id || this.groups[0].id
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
}
} catch (e) {
this.$message.error(e?.message || '加载分组失败')
} finally {
this.groupLoading = false
}
},
selectGroup(g) {
const gid = g?.Id || g?.id
this.selectedGroupId = gid
this.createForm.groupId = gid
this.filters.groupId = gid
this.searchAssets(1)
},
async createAsset() {
const groupId = String(this.createForm.groupId || '').trim()
const url = String(this.createForm.url || '').trim()
if (!groupId) return this.$message.error('请填写 GroupId')
if (!/^https?:\/\//i.test(url)) return this.$message.error('URL 必须是 http(s) 地址')
this.createLoading = true
try {
const res = await this.$axios({
url: ASSET_CREATE_API,
method: 'POST',
data: {
GroupId: groupId,
URL: url,
Name: String(this.createForm.name || '').trim(),
AssetType: this.createForm.assetType
}
})
if (res.code === 200) {
this.$message.success('新增素材成功')
this.createForm.url = ''
this.createForm.name = ''
this.searchAssets(1)
} else {
this.$message.error(res.msg || '新增素材失败')
}
} catch (e) {
this.$message.error(e?.message || '新增素材失败')
} finally {
this.createLoading = false
}
},
buildListPayload() {
const filter = {
GroupType: 'AIGC'
}
const gid = String(this.filters.groupId || '').trim()
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]
return {
Filter: 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.listLoading = true
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 : []
if (res.code !== 200) {
this.$message.error(res.msg || '查询素材失败')
}
} catch (e) {
this.$message.error(e?.message || '查询素材失败')
} finally {
this.listLoading = false
}
},
onPageSizeChange(size) {
this.filters.pageSize = Number(size) || 10
this.searchAssets(1)
},
resetFilters() {
this.filters = {
groupId: this.selectedGroupId || '',
name: '',
status: '',
pageNumber: 1,
pageSize: 10,
sortBy: 'CreateTime',
sortOrder: 'Desc'
}
this.searchAssets(1)
},
async getAsset(it) {
const id = it?.Id || it?.id
if (!id) return
try {
const res = await this.$axios({
url: ASSET_GET_API,
method: 'POST',
data: { Id: id }
})
if (res.code === 200) {
this.detailData = res.data || {}
this.detailVisible = true
} else {
this.$message.error(res.msg || '查询详情失败')
}
} catch (e) {
this.$message.error(e?.message || '查询详情失败')
}
},
deleteAsset(it) {
const id = it?.Id || it?.id
if (!id) return
this.$confirm({
title: '删除素材',
content: `确认删除素材 ${id} 吗?`,
onOk: async () => {
try {
const res = await this.$axios({
url: ASSET_DELETE_API,
method: 'POST',
data: { Id: id }
})
if (res.code === 200) {
this.$message.success('删除成功')
this.searchAssets(this.filters.pageNumber)
} else {
this.$message.error(res.msg || '删除失败')
}
} catch (e) {
this.$message.error(e?.message || '删除失败')
}
}
})
}
}
}
</script>
<style scoped lang="less">
.asset-manage-page {
display: grid;
grid-template-columns: 300px 1fr;
gap: 14px;
padding: 16px;
min-height: 100%;
background: #0a0b0d;
color: rgba(255, 255, 255, 0.9);
}
.asset-left,
.asset-right {
background: rgba(22, 24, 30, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 12px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
}
.group-tree {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 72vh;
overflow: auto;
}
.group-node {
display: flex;
gap: 8px;
align-items: flex-start;
padding: 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.group-node:hover {
background: rgba(255, 255, 255, 0.04);
}
.group-node.active {
border-color: rgba(0, 202, 224, 0.45);
background: rgba(0, 202, 224, 0.12);
}
.folder-icon {
font-size: 14px;
line-height: 1.6;
}
.group-name {
font-size: 13px;
}
.group-id {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
word-break: break-all;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 10px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.field.actions {
align-self: end;
flex-direction: row;
align-items: center;
}
.mtop {
margin-top: 14px;
}
.total-line {
margin: 10px 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.table-wrap {
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
.asset-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.asset-table th,
.asset-table td {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
.asset-table th {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.02);
}
.url-cell {
max-width: 280px;
word-break: break-all;
}
.pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.empty-tip {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
text-align: center;
padding: 10px 0;
}
.detail-box {
margin: 0;
padding: 10px;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
max-height: 420px;
overflow: auto;
color: #d8f4f7;
}
@media (max-width: 980px) {
.asset-manage-page {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -23,23 +23,10 @@
<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'"
v-else
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>
@ -267,7 +254,7 @@ export default {
},
allowedMediaTypes() {
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return ['image']
if (this.videoMode === 'image-reference') return ['image', 'video', 'audio']
if (this.videoMode === 'image-reference') return ['image']
return ['image', 'video']
},
posterUrl() {
@ -566,32 +553,22 @@ export default {
}))
},
getRowReferenceMediaByType(row) {
const images = []
const videos = []
const audios = []
if (!row?.videoParams) return { images, videos, audios }
getRowReferenceImageUrls(row) {
const urls = []
if (!row?.videoParams) return urls
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)
}
if (item?.type !== 'image_url') continue
if (item?.role && item.role !== 'reference_image') continue
const url = item?.image_url?.url
if (url) urls.push(url)
}
} catch (_) {
// ignore parse error
}
return { images, videos, audios }
return urls
},
getRowPromptSegments(row) {
@ -612,43 +589,25 @@ export default {
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) {
// [n] reference_image
const refs = this.getRowReferenceImageUrls(row)
if (!refs.length) {
return [{ type: 'text', text }]
}
const tokenReg = /(\[图片(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]|\[图(\d+)\])/g
const tokenReg = /\[图(\d+)\]/g
const segments = []
let last = 0
let m
while ((m = tokenReg.exec(text)) !== null) {
const token = m[0]
const idx = Number(m[1]) - 1
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 })
const url = idx >= 0 ? refs[idx] : ''
if (url) {
segments.push({ type: 'image', url, token })
} else {
segments.push({ type: 'text', text: token })
}
@ -805,6 +764,10 @@ export default {
}
if (this.videoMode === 'image-reference') {
if (attachments.length === 0) {
this.$message.error('请至少上传一张参考图')
return
}
const compose = this.$refs.videoComposeRef
const contentItems =
compose && typeof compose.getImageReferenceContentItems === 'function'
@ -815,12 +778,13 @@ export default {
this.$message.error('参考图内容格式异常,请重试')
return
}
const refUrls = contentItems.filter((x, idx) => idx > 0 && x?.type === 'image_url')
if (!refUrls.length) {
this.$message.error('请通过 @ 在描述中插入参考图')
return
}
params.text = first.text || text
params.content = contentItems
const firstPreview = attachments.find((x) => x?.mediaType === 'image' && /^https?:\/\//i.test(String(x?.url || '')))
if (firstPreview) {
params.referenceUrl = firstPreview.url
}
}
const urlMap = {
@ -1502,24 +1466,6 @@ export default {
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;