fix: 支持视频音频的参考图模式,资产管理模块初步上线
This commit is contained in:
parent
4cb3f4eb88
commit
ff18ca4f51
|
|
@ -54,16 +54,22 @@
|
|||
<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">点击添加参考图</div>
|
||||
<div class="vg-compose-empty-text">点击上传资产或按 GroupId 查询资产</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="vg-compose-media-scroll">
|
||||
|
|
@ -73,7 +79,17 @@
|
|||
class="vg-compose-media-item"
|
||||
:title="item.name || ''">
|
||||
<div class="vg-compose-media-preview">
|
||||
<img :src="item.url" alt="" />
|
||||
<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"
|
||||
|
|
@ -115,9 +131,17 @@
|
|||
:key="item.key"
|
||||
:class="['vg-mention-item', { active: idx === mentionActiveIndex }]"
|
||||
@mousedown.prevent="selectMentionItem(item)">
|
||||
<img :src="item.url" alt="" class="vg-mention-thumb" />
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
|
@ -136,12 +160,12 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||
|
||||
/** 图生参考:不同参考图最多 4 张(图1–图4),同一 URL 可多次 @ */
|
||||
const MAX_REFERENCE_UNIQUE = 4
|
||||
/** 参考素材:同类最多不同素材条数(同一素材可多次 @),与 maxMediaCount 对齐 */
|
||||
const MAX_REFERENCE_UNIQUE_KIND = 9
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
|
@ -174,12 +198,15 @@ 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,
|
||||
|
|
@ -224,11 +251,17 @@ const mentionCandidates = computed(() =>
|
|||
(mediaList.value || [])
|
||||
.filter((i) => {
|
||||
const u = String(i?.url || '').trim()
|
||||
return i?.mediaType === 'image' && !i?.isUploading && /^https?:\/\//i.test(u)
|
||||
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
|
||||
})
|
||||
.map((i, idx) => ({
|
||||
key: i.id || i.url || String(idx),
|
||||
url: i.url
|
||||
url: i.url,
|
||||
assetId: i.assetId || '',
|
||||
mediaType: i.mediaType || 'image',
|
||||
name: i.name || ''
|
||||
}))
|
||||
)
|
||||
|
||||
|
|
@ -259,9 +292,16 @@ const openFilePickerFor = (index) => {
|
|||
|
||||
const acceptAttr = computed(() => {
|
||||
const types = new Set(props.allowedMediaTypes || [])
|
||||
if (types.has('image') && types.has('video')) return 'image/*,video/*'
|
||||
if (types.has('image')) return 'image/*'
|
||||
if (types.has('video')) return 'video/*'
|
||||
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/*'
|
||||
return 'image/*,video/*'
|
||||
})
|
||||
|
||||
|
|
@ -269,9 +309,11 @@ 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)$/.test(lowerName)) return 'image'
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv|ogg)$/.test(lowerName)) return 'video'
|
||||
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'
|
||||
return 'image'
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +332,7 @@ const removeItem = (item) => {
|
|||
|
||||
const saveReferenceToStorage = (list) => {
|
||||
try {
|
||||
localStorage.setItem('video_reference_images', JSON.stringify(list))
|
||||
localStorage.setItem('video_reference_media', JSON.stringify(list))
|
||||
} catch (e) {
|
||||
console.warn('Failed to save reference images to localStorage')
|
||||
}
|
||||
|
|
@ -298,7 +340,7 @@ const saveReferenceToStorage = (list) => {
|
|||
|
||||
const loadReferenceFromStorage = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('video_reference_images')
|
||||
const saved = localStorage.getItem('video_reference_media') || localStorage.getItem('video_reference_images')
|
||||
if (saved) {
|
||||
return JSON.parse(saved)
|
||||
}
|
||||
|
|
@ -313,12 +355,82 @@ 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]
|
||||
|
||||
// 首尾帧模式特殊处理索引
|
||||
|
|
@ -389,12 +501,33 @@ const clearAll = () => {
|
|||
})
|
||||
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, isUploading: false }
|
||||
? { ...x, url, assetId, isUploading: false }
|
||||
: x
|
||||
)
|
||||
)
|
||||
|
|
@ -479,7 +612,7 @@ const isBlockElement = (n) =>
|
|||
n.nodeType === Node.ELEMENT_NODE &&
|
||||
['DIV', 'P', 'LI'].includes(n.tagName)
|
||||
|
||||
/** 与编辑器 DOM 顺序一致:描述与 [图n] 在文中的位置即用户输入/插入的位置 */
|
||||
/** 与编辑器 DOM 顺序一致:描述与 [图片n]/[视频n]/[音频n] 在文中的位置即用户输入/插入的位置 */
|
||||
const getEditorPlainText = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return ''
|
||||
|
|
@ -511,97 +644,139 @@ const getEditorPlainText = () => {
|
|||
return out.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
const getUniqueRefUrlsInDoc = () => {
|
||||
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 s = new Set()
|
||||
editorRef.value?.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||
const u = el.getAttribute('data-reference-url')
|
||||
if (u) s.add(u)
|
||||
if ((el.getAttribute('data-reference-kind') || 'image') !== kind) return
|
||||
const k = refKeyForEl(el)
|
||||
if (k && k !== `${kind}:`) s.add(k)
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
/** 按文档顺序为不同 URL 分配 图1–图4,并同步 data-token / 展示 */
|
||||
/** 按文档顺序:同类素材独立编号 [图片n][视频n][音频n],同一素材多次 @ 共用同一序号 */
|
||||
const renumberAllReferenceMentions = () => {
|
||||
if (!editorRef.value || !isReference.value) return
|
||||
const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]'))
|
||||
const urlToNo = new Map()
|
||||
let next = 1
|
||||
const imgNo = new Map()
|
||||
const vidNo = new Map()
|
||||
const audNo = new Map()
|
||||
let imgNext = 1
|
||||
let vidNext = 1
|
||||
let audNext = 1
|
||||
let droppedExtra = false
|
||||
for (const el of refs) {
|
||||
const u = el.getAttribute('data-reference-url') || ''
|
||||
if (!u) {
|
||||
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||
const keyCore = (el.getAttribute('data-reference-asset-id') || el.getAttribute('data-reference-url') || '').trim()
|
||||
if (!keyCore) {
|
||||
el.remove()
|
||||
continue
|
||||
}
|
||||
if (!urlToNo.has(u)) {
|
||||
if (next > MAX_REFERENCE_UNIQUE) {
|
||||
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) {
|
||||
el.remove()
|
||||
droppedExtra = true
|
||||
continue
|
||||
}
|
||||
urlToNo.set(u, next++)
|
||||
map.set(fullKey, nextRef())
|
||||
}
|
||||
const n = urlToNo.get(u)
|
||||
const token = `[图${n}]`
|
||||
const n = map.get(fullKey)
|
||||
const token =
|
||||
kind === 'video' ? `[视频${n}]` : kind === 'audio' ? `[音频${n}]` : `[图片${n}]`
|
||||
el.setAttribute('data-token', token)
|
||||
const imgEl = el.querySelector('.vg-inline-ref-image')
|
||||
if (imgEl) imgEl.alt = 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)
|
||||
}
|
||||
}
|
||||
if (droppedExtra) {
|
||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,已移除多余引用`)
|
||||
Message.warning(`同类参考素材最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,已移除多余引用`)
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileReferenceMentions = (nextList) => {
|
||||
if (!editorRef.value) return
|
||||
const allowed = new Set(
|
||||
(nextList || []).filter((x) => x?.mediaType === 'image' && x?.url).map((x) => x.url)
|
||||
(nextList || [])
|
||||
.filter((x) => ['image', 'video', 'audio'].includes(x?.mediaType) && (x?.assetId || x?.url))
|
||||
.map((x) => `${x.mediaType}:${x.assetId || x.url}`)
|
||||
)
|
||||
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||
const u = el.getAttribute('data-reference-url') || ''
|
||||
if (!allowed.has(u)) el.remove()
|
||||
const k = refKeyForEl(el)
|
||||
if (!allowed.has(k)) el.remove()
|
||||
})
|
||||
renumberAllReferenceMentions()
|
||||
setPrompt(getEditorPlainText())
|
||||
}
|
||||
|
||||
/** 文档顺序下首次出现的参考图 URL(对应 图1、图2…) */
|
||||
const collectReferenceUrlsInDocOrder = () => {
|
||||
/** 文档顺序下去重后的参考素材(用于 content),同类内顺序即 [图片n] 等序号依据 */
|
||||
const collectReferenceContentInOrder = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return []
|
||||
const urls = []
|
||||
const out = []
|
||||
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 url = el.getAttribute('data-reference-url') || ''
|
||||
if (url && !seen.has(url)) {
|
||||
seen.add(url)
|
||||
urls.push(url)
|
||||
}
|
||||
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 })
|
||||
return
|
||||
}
|
||||
Array.from(el.childNodes).forEach(walk)
|
||||
}
|
||||
Array.from(editor.childNodes).forEach(walk)
|
||||
return urls
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考图模式提交用:第 1 条必须是 text(整段文案,仅含 [图n],不含 URL);
|
||||
* 其后按 图1→图n 顺序各一条 image_url + reference_image。
|
||||
* 参考图模式提交用:首条 type=text(含 [图片n]/[视频n]/[音频n],不含 Asset ID);
|
||||
* 其后为各类 reference_*,url 可为 asset:// 或 http(s)。
|
||||
*/
|
||||
const getImageReferenceContentItems = () => {
|
||||
const text = getEditorPlainText()
|
||||
const first = { type: 'text', text: text || '' }
|
||||
const urls = collectReferenceUrlsInDocOrder()
|
||||
const rest = urls.map((url) => ({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: 'reference_image'
|
||||
}))
|
||||
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' }
|
||||
})
|
||||
return [first, ...rest]
|
||||
}
|
||||
|
||||
|
|
@ -791,9 +966,12 @@ const onEditorKeyup = (e) => {
|
|||
|
||||
const selectMentionItem = (item) => {
|
||||
if (!item?.url || !editorRef.value) return
|
||||
const urls = getUniqueRefUrlsInDoc()
|
||||
if (!urls.has(item.url) && urls.size >= MAX_REFERENCE_UNIQUE) {
|
||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,无法再插入新图`)
|
||||
const kind = item.mediaType || 'image'
|
||||
const key = `${kind}:${(item.assetId || '').trim() || String(item.url || '').trim()}`
|
||||
const keys = getUniqueRefKeysInDocForKind(kind)
|
||||
if (!keys.has(key) && keys.size >= MAX_REFERENCE_UNIQUE_KIND) {
|
||||
const label = kind === 'video' ? '视频' : kind === 'audio' ? '音频' : '图片'
|
||||
Message.warning(`同类参考${label}最多 ${MAX_REFERENCE_UNIQUE_KIND} 种,无法再插入新素材`)
|
||||
mentionVisible.value = false
|
||||
return
|
||||
}
|
||||
|
|
@ -807,25 +985,42 @@ 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)
|
||||
}
|
||||
|
||||
range.insertNode(holder)
|
||||
// 不在引用后自动加空格,保证导出 text 为 「描述[图1]描述[图2]…」,占位与描述紧挨在用户输入的位置
|
||||
range.setStartAfter(holder)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
|
|
@ -979,6 +1174,29 @@ 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;
|
||||
}
|
||||
|
|
@ -1191,6 +1409,55 @@ 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;
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'إنشاء فيديو',
|
||||
recharge: 'شحن سريع',
|
||||
help: 'مركز المساعدة',
|
||||
moneyInvite: 'دعوة مكافآت'
|
||||
moneyInvite: 'دعوة مكافآت',
|
||||
assetGroupManage: 'إدارة مجموعات الأصول',
|
||||
assetManage: 'إدارة الأصول'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,7 @@ export default {
|
|||
videoGen: 'Video Generation',
|
||||
recharge: 'Quick Recharge',
|
||||
help: 'Help Center',
|
||||
moneyInvite: 'Reward Invitation'
|
||||
moneyInvite: 'Reward Invitation',
|
||||
assetGroupManage: 'Asset Group Manage',
|
||||
assetManage: 'Asset Manage'
|
||||
}
|
||||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Generar video',
|
||||
recharge: 'Recarga rápida',
|
||||
help: 'Centro de ayuda',
|
||||
moneyInvite: 'Invitación con recompensa'
|
||||
moneyInvite: 'Invitación con recompensa',
|
||||
assetGroupManage: 'Gestión de grupos de activos',
|
||||
assetManage: 'Gestión de activos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Générer une vidéo',
|
||||
recharge: 'Recharge rapide',
|
||||
help: 'Centre d\'aide',
|
||||
moneyInvite: 'Invitation avec récompense'
|
||||
moneyInvite: 'Invitation avec récompense',
|
||||
assetGroupManage: 'Gestion des groupes d\'actifs',
|
||||
assetManage: 'Gestion des actifs'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'वीडियो जनरेट करें',
|
||||
recharge: 'त्वरित रिचार्ज',
|
||||
help: 'सहायता केंद्र',
|
||||
moneyInvite: 'इनाम निमंत्रण'
|
||||
moneyInvite: 'इनाम निमंत्रण',
|
||||
assetGroupManage: 'एसेट समूह प्रबंधन',
|
||||
assetManage: 'एसेट प्रबंधन'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Gerar vídeo',
|
||||
recharge: 'Recarga rápida',
|
||||
help: 'Central de ajuda',
|
||||
moneyInvite: 'Convite com recompensa'
|
||||
moneyInvite: 'Convite com recompensa',
|
||||
assetGroupManage: 'Gerenciamento de grupos de ativos',
|
||||
assetManage: 'Gerenciamento de ativos'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ export default {
|
|||
fastVideo: 'Создать видео',
|
||||
recharge: 'Быстрая пополнение',
|
||||
help: 'Центр помощи',
|
||||
moneyInvite: 'Приглашение с наградой'
|
||||
moneyInvite: 'Приглашение с наградой',
|
||||
assetGroupManage: 'Управление группами ресурсов',
|
||||
assetManage: 'Управление ресурсами'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,7 @@ export default {
|
|||
videoGen: '視頻生成',
|
||||
recharge: '快速充值',
|
||||
help: '幫助中心',
|
||||
moneyInvite: '有獎邀請'
|
||||
moneyInvite: '有獎邀請',
|
||||
assetGroupManage: '資源組管理',
|
||||
assetManage: '素材管理'
|
||||
}
|
||||
|
|
@ -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']
|
||||
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen', 'asset-group-manage', 'asset-manage']
|
||||
|
||||
defineProps({
|
||||
collapsed: Boolean
|
||||
|
|
|
|||
|
|
@ -129,6 +129,26 @@ 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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
<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="素材公网 URL(http/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>
|
||||
|
|
@ -23,10 +23,23 @@
|
|||
<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
|
||||
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>
|
||||
|
||||
|
|
@ -254,7 +267,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']
|
||||
if (this.videoMode === 'image-reference') return ['image', 'video', 'audio']
|
||||
return ['image', 'video']
|
||||
},
|
||||
posterUrl() {
|
||||
|
|
@ -553,22 +566,32 @@ export default {
|
|||
}))
|
||||
},
|
||||
|
||||
getRowReferenceImageUrls(row) {
|
||||
const urls = []
|
||||
if (!row?.videoParams) return urls
|
||||
getRowReferenceMediaByType(row) {
|
||||
const images = []
|
||||
const videos = []
|
||||
const audios = []
|
||||
if (!row?.videoParams) return { images, videos, audios }
|
||||
try {
|
||||
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
|
||||
const content = Array.isArray(vp?.content) ? vp.content : []
|
||||
for (const item of content) {
|
||||
if (item?.type !== 'image_url') continue
|
||||
if (item?.role && item.role !== 'reference_image') continue
|
||||
if (item?.type === 'image_url' && (!item.role || item.role === 'reference_image')) {
|
||||
const url = item?.image_url?.url
|
||||
if (url) urls.push(url)
|
||||
if (url) images.push(url)
|
||||
}
|
||||
if (item?.type === 'video_url' && item?.role === 'reference_video') {
|
||||
const url = item?.video_url?.url
|
||||
if (url) videos.push(url)
|
||||
}
|
||||
if (item?.type === 'audio_url' && item?.role === 'reference_audio') {
|
||||
const url = item?.audio_url?.url
|
||||
if (url) audios.push(url)
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore parse error
|
||||
}
|
||||
return urls
|
||||
return { images, videos, audios }
|
||||
},
|
||||
|
||||
getRowPromptSegments(row) {
|
||||
|
|
@ -589,25 +612,43 @@ export default {
|
|||
return segs
|
||||
}
|
||||
|
||||
// 参考图模式:按 [图n] 替换为对应 reference_image
|
||||
const refs = this.getRowReferenceImageUrls(row)
|
||||
if (!refs.length) {
|
||||
// 参考素材:[图片n][视频n][音频n] 与 content 中同类序号对应;兼容旧 [图n]
|
||||
const refs = this.getRowReferenceMediaByType(row)
|
||||
const hasRefPayload =
|
||||
refs.images.length > 0 || refs.videos.length > 0 || refs.audios.length > 0
|
||||
const hasRefTokens = /\[图片\d+\]|\[视频\d+\]|\[音频\d+\]|\[图\d+\]/.test(text)
|
||||
if (!hasRefPayload && !hasRefTokens) {
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
const tokenReg = /\[图(\d+)\]/g
|
||||
const tokenReg = /(\[图片(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]|\[图(\d+)\])/g
|
||||
const segments = []
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = tokenReg.exec(text)) !== null) {
|
||||
const token = m[0]
|
||||
const idx = Number(m[1]) - 1
|
||||
const start = m.index
|
||||
if (start > last) {
|
||||
segments.push({ type: 'text', text: text.slice(last, start) })
|
||||
}
|
||||
const url = idx >= 0 ? refs[idx] : ''
|
||||
if (url) {
|
||||
segments.push({ type: 'image', url, token })
|
||||
let url = ''
|
||||
let segType = 'image'
|
||||
if (m[2] != null) {
|
||||
url = refs.images[Number(m[2]) - 1] || ''
|
||||
segType = 'image'
|
||||
} else if (m[3] != null) {
|
||||
url = refs.videos[Number(m[3]) - 1] || ''
|
||||
segType = 'video'
|
||||
} else if (m[4] != null) {
|
||||
url = refs.audios[Number(m[4]) - 1] || ''
|
||||
segType = 'audio'
|
||||
} else if (m[5] != null) {
|
||||
url = refs.images[Number(m[5]) - 1] || ''
|
||||
segType = 'image'
|
||||
}
|
||||
if (url && /^asset:\/\//i.test(url)) {
|
||||
segments.push({ type: 'text', text: token })
|
||||
} else if (url) {
|
||||
segments.push({ type: segType, url, token })
|
||||
} else {
|
||||
segments.push({ type: 'text', text: token })
|
||||
}
|
||||
|
|
@ -776,6 +817,10 @@ export default {
|
|||
}
|
||||
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 = {
|
||||
|
|
@ -1457,6 +1502,24 @@ 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue