fix: bug修改字段缺失
This commit is contained in:
parent
5ae8614b1d
commit
0e94beb477
|
|
@ -50,7 +50,7 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { uploadFile } from '@/utils/file'
|
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RichTextEditor',
|
name: 'RichTextEditor',
|
||||||
|
|
@ -105,13 +105,13 @@ export default {
|
||||||
|
|
||||||
// 上传到后端
|
// 上传到后端
|
||||||
const res = await uploadFile({
|
const res = await uploadFile({
|
||||||
url: '/api/cos/upload', // 使用腾讯云COS上传接口
|
url: PORTAL_TENCENT_COS_UPLOAD_URL,
|
||||||
file: file,
|
file: file,
|
||||||
name: 'file'
|
name: 'file'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res && res.code === 200 && res.data) {
|
const imageUrl = extractUploadUrlFromResponse(res)
|
||||||
const imageUrl = typeof res.data === 'string' ? res.data : (res.data.url || res.data)
|
if (res && (Number(res.code) === 200 || res.code === 200) && imageUrl) {
|
||||||
insertImage(imageUrl, file.name)
|
insertImage(imageUrl, file.name)
|
||||||
|
|
||||||
// 通知父组件有新图片上传成功(用于@功能)
|
// 通知父组件有新图片上传成功(用于@功能)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,549 @@
|
||||||
|
<template>
|
||||||
|
<div class="video-editor-root">
|
||||||
|
<div v-if="showToolbar" class="toolbar">
|
||||||
|
<button class="tool-btn" type="button" @click="openImagePicker">插入参考素材</button>
|
||||||
|
<button class="tool-btn" type="button" @click="clear">清空所有参考素材和文字</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
class="hidden-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*,audio/*"
|
||||||
|
multiple
|
||||||
|
@change="handleSelectFiles" />
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="user-input video-rich-editor"
|
||||||
|
contenteditable="true"
|
||||||
|
:data-placeholder="placeholder || '请输入文本生成视频...'"
|
||||||
|
@input="handleInput"
|
||||||
|
@paste="handlePaste"
|
||||||
|
@keyup="handleKeyup"
|
||||||
|
@click="handleEditorClick"></div>
|
||||||
|
<div v-if="mentionVisible" class="mention-panel">
|
||||||
|
<div
|
||||||
|
v-for="item in mentionImageList"
|
||||||
|
:key="item.url"
|
||||||
|
class="mention-item"
|
||||||
|
@mousedown.prevent="insertMentionImage(item)">
|
||||||
|
<img v-if="item.mediaType === 'image'" :src="item.url" class="mention-thumb" alt="" />
|
||||||
|
<span v-else class="mention-type-badge">{{ item.mediaType === 'video' ? '视频' : '音频' }}</span>
|
||||||
|
<span class="mention-label">参考{{ item.refNo }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mentionImageList.length === 0" class="mention-empty">暂无可引用素材</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
/** 文生视频等场景可隐藏工具栏,仅保留纯文本编辑 */
|
||||||
|
showToolbar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['text-change'])
|
||||||
|
|
||||||
|
const editorRef = ref(null)
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
const savedSelectionRange = ref(null)
|
||||||
|
const MAX_IMAGE_COUNT = 9
|
||||||
|
const mentionVisible = ref(false)
|
||||||
|
const mentionImageList = ref([])
|
||||||
|
|
||||||
|
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)$/.test(lowerName)) return 'video'
|
||||||
|
if (/\.(mp3|wav|aac|m4a|ogg|flac)$/.test(lowerName)) return 'audio'
|
||||||
|
return 'image'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlainText = () => (editorRef.value?.innerText || '').trim()
|
||||||
|
|
||||||
|
const focusEditorToEnd = () => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
editorRef.value.focus()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(editorRef.value)
|
||||||
|
range.collapse(false)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
savedSelectionRange.value = range.cloneRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSelection = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (editorRef.value?.contains(range.commonAncestorContainer)) {
|
||||||
|
savedSelectionRange.value = range.cloneRange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreSelection = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || !editorRef.value) return
|
||||||
|
selection.removeAllRanges()
|
||||||
|
if (savedSelectionRange.value) {
|
||||||
|
selection.addRange(savedSelectionRange.value)
|
||||||
|
} else {
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(editorRef.value)
|
||||||
|
range.collapse(false)
|
||||||
|
selection.addRange(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
emit('text-change', getPlainText())
|
||||||
|
saveSelection()
|
||||||
|
syncMentionPanelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPlainTextAtCursor = (text) => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
const normalizedText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
editorRef.value.focus()
|
||||||
|
restoreSelection()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return
|
||||||
|
let range = selection.getRangeAt(0)
|
||||||
|
if (!editorRef.value.contains(range.commonAncestorContainer)) {
|
||||||
|
focusEditorToEnd()
|
||||||
|
const currentSelection = window.getSelection()
|
||||||
|
if (!currentSelection || currentSelection.rangeCount === 0) return
|
||||||
|
range = currentSelection.getRangeAt(0)
|
||||||
|
}
|
||||||
|
range.deleteContents()
|
||||||
|
const lines = normalizedText.split('\n')
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line) fragment.appendChild(document.createTextNode(line))
|
||||||
|
if (index < lines.length - 1) fragment.appendChild(document.createElement('br'))
|
||||||
|
})
|
||||||
|
range.insertNode(fragment)
|
||||||
|
range.collapse(false)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
saveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (event) => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
const clipboardText = event.clipboardData?.getData('text/plain') || ''
|
||||||
|
if (!clipboardText) return
|
||||||
|
insertPlainTextAtCursor(clipboardText)
|
||||||
|
emit('text-change', getPlainText())
|
||||||
|
syncMentionPanelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMentionImageList = () => {
|
||||||
|
mentionImageList.value = mentionImageList.value.slice(0, MAX_IMAGE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideMentionPanel = () => {
|
||||||
|
mentionVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveMentionTriggerBeforeCursor = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return false
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!range.collapsed) return false
|
||||||
|
if (!editorRef.value?.contains(range.commonAncestorContainer)) return false
|
||||||
|
const container = range.startContainer
|
||||||
|
if (container.nodeType !== Node.TEXT_NODE) return false
|
||||||
|
const before = container.data.slice(0, range.startOffset)
|
||||||
|
const atIndex = before.lastIndexOf('@')
|
||||||
|
if (atIndex < 0) return false
|
||||||
|
const afterAt = before.slice(atIndex + 1)
|
||||||
|
return !/\s/.test(afterAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncMentionPanelVisibility = () => {
|
||||||
|
const shouldShow = hasActiveMentionTriggerBeforeCursor()
|
||||||
|
if (!shouldShow) {
|
||||||
|
hideMentionPanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMentionImageList()
|
||||||
|
mentionVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMentionKeywordBeforeCursor = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!range.collapsed) return
|
||||||
|
const container = range.startContainer
|
||||||
|
if (container.nodeType !== Node.TEXT_NODE) return
|
||||||
|
const before = container.data.slice(0, range.startOffset)
|
||||||
|
const atIndex = before.lastIndexOf('@')
|
||||||
|
if (atIndex < 0) return
|
||||||
|
const afterAt = before.slice(atIndex + 1)
|
||||||
|
if (/\s/.test(afterAt)) return
|
||||||
|
const removeRange = document.createRange()
|
||||||
|
removeRange.setStart(container, atIndex)
|
||||||
|
removeRange.setEnd(container, range.startOffset)
|
||||||
|
removeRange.deleteContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertReference = (item) => {
|
||||||
|
if (!item?.url || !editorRef.value) return
|
||||||
|
editorRef.value.focus()
|
||||||
|
restoreSelection()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) return
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!editorRef.value.contains(range.commonAncestorContainer)) return
|
||||||
|
|
||||||
|
const refNoText = `[图${item.refNo}]`
|
||||||
|
let node
|
||||||
|
if (item.mediaType === 'image') {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = item.url
|
||||||
|
img.alt = `reference-${item.refNo}`
|
||||||
|
img.className = 'inline-rich-image'
|
||||||
|
img.setAttribute('data-image-url', item.url)
|
||||||
|
img.setAttribute('data-mention-reference', '1')
|
||||||
|
img.setAttribute('data-reference-no', String(item.refNo))
|
||||||
|
node = img
|
||||||
|
} else {
|
||||||
|
const tag = document.createElement('span')
|
||||||
|
tag.className = 'inline-rich-reference'
|
||||||
|
tag.textContent = refNoText
|
||||||
|
tag.setAttribute('data-reference-url', item.url)
|
||||||
|
tag.setAttribute('data-mention-reference', '1')
|
||||||
|
tag.setAttribute('data-reference-no', String(item.refNo))
|
||||||
|
tag.setAttribute('data-reference-type', item.mediaType)
|
||||||
|
node = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
range.insertNode(node)
|
||||||
|
const space = document.createTextNode(' ')
|
||||||
|
range.setStartAfter(node)
|
||||||
|
range.insertNode(space)
|
||||||
|
range.setStartAfter(space)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
saveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertMentionImage = (item) => {
|
||||||
|
if (!item?.url || !editorRef.value) return
|
||||||
|
editorRef.value.focus()
|
||||||
|
restoreSelection()
|
||||||
|
removeMentionKeywordBeforeCursor()
|
||||||
|
insertReference(item)
|
||||||
|
hideMentionPanel()
|
||||||
|
emit('text-change', getPlainText())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyup = (event) => {
|
||||||
|
saveSelection()
|
||||||
|
if (event.key === '@') {
|
||||||
|
syncMentionPanelVisibility()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
hideMentionPanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncMentionPanelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditorClick = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
focusEditorToEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!editorRef.value?.contains(range.commonAncestorContainer)) {
|
||||||
|
focusEditorToEnd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveSelection()
|
||||||
|
syncMentionPanelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInsertedUniqueImageCount = () => mentionImageList.value.length
|
||||||
|
|
||||||
|
const openImagePicker = () => {
|
||||||
|
const current = getInsertedUniqueImageCount()
|
||||||
|
if (current >= MAX_IMAGE_COUNT) {
|
||||||
|
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadToCos = async (file) => {
|
||||||
|
const res = await uploadFile({
|
||||||
|
url: PORTAL_TENCENT_COS_UPLOAD_URL,
|
||||||
|
file,
|
||||||
|
name: 'file'
|
||||||
|
})
|
||||||
|
const codeOk = res && (Number(res.code) === 200 || res.code === 200)
|
||||||
|
const url = extractUploadUrlFromResponse(res)
|
||||||
|
if (codeOk && url) return url
|
||||||
|
throw new Error(res?.msg || '上传失败:未返回文件地址')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectFiles = async (event) => {
|
||||||
|
const input = event.target
|
||||||
|
const files = Array.from(input.files || [])
|
||||||
|
if (!files.length) return
|
||||||
|
const remain = MAX_IMAGE_COUNT - getInsertedUniqueImageCount()
|
||||||
|
if (remain <= 0) {
|
||||||
|
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材`)
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selected = files.slice(0, remain)
|
||||||
|
if (files.length > remain) {
|
||||||
|
Message.warning(`最多插入 ${MAX_IMAGE_COUNT} 个参考素材,本次仅插入 ${remain} 个`)
|
||||||
|
}
|
||||||
|
for (const file of selected) {
|
||||||
|
const mediaType = detectMediaType(file)
|
||||||
|
try {
|
||||||
|
const url = await uploadToCos(file)
|
||||||
|
if (url && !mentionImageList.value.some((item) => item.url === url)) {
|
||||||
|
const refNo = mentionImageList.value.length + 1
|
||||||
|
mentionImageList.value.push({ url, refNo, mediaType })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Message.error('参考素材上传失败')
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.value = ''
|
||||||
|
emit('text-change', getPlainText())
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContentItems = () => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
const items = []
|
||||||
|
if (!editor) return items
|
||||||
|
let textBuffer = ''
|
||||||
|
const flushText = () => {
|
||||||
|
const normalized = textBuffer.replace(/\u00a0/g, ' ').replace(/\s+\n/g, '\n')
|
||||||
|
if (normalized.trim()) items.push({ type: 'text', text: normalized.trim() })
|
||||||
|
textBuffer = ''
|
||||||
|
}
|
||||||
|
const walk = (node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
textBuffer += node.textContent || ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
|
const el = node
|
||||||
|
const referenceUrl = (
|
||||||
|
el.dataset?.referenceUrl ||
|
||||||
|
el.dataset?.imageUrl ||
|
||||||
|
el.getAttribute('src') ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
if (el.dataset?.mentionReference === '1' && referenceUrl) {
|
||||||
|
flushText()
|
||||||
|
items.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: referenceUrl },
|
||||||
|
role: 'reference_image'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (el.tagName === 'IMG' && (el.dataset?.imageUrl || el.getAttribute('src'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (el.tagName === 'BR') {
|
||||||
|
textBuffer += '\n'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
|
||||||
|
Array.from(el.childNodes).forEach(walk)
|
||||||
|
if (['DIV', 'P', 'LI'].includes(el.tagName) && textBuffer && !textBuffer.endsWith('\n')) textBuffer += '\n'
|
||||||
|
}
|
||||||
|
Array.from(editor.childNodes).forEach(walk)
|
||||||
|
flushText()
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
editorRef.value.innerHTML = ''
|
||||||
|
emit('text-change', '')
|
||||||
|
savedSelectionRange.value = null
|
||||||
|
mentionImageList.value = []
|
||||||
|
hideMentionPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getContentItems,
|
||||||
|
getPlainText,
|
||||||
|
clear
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
focusEditorToEnd()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-editor-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.editor-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.tool-btn {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.tool-btn:hover {
|
||||||
|
border-color: rgba(0, 202, 224, 0.4);
|
||||||
|
background: rgba(0, 202, 224, 0.08);
|
||||||
|
}
|
||||||
|
.video-rich-editor {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 220px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
caret-color: #00cae0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
.video-rich-editor:focus {
|
||||||
|
border-color: rgba(0, 202, 224, 0.45);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 202, 224, 0.15);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.video-rich-editor:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.video-rich-editor :deep(.inline-rich-image) {
|
||||||
|
height: 1.1em;
|
||||||
|
width: auto;
|
||||||
|
vertical-align: baseline;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.video-rich-editor :deep(.inline-rich-reference) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 202, 224, 0.15);
|
||||||
|
color: #5eebf5;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.mention-panel {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(22, 24, 30, 0.98);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.mention-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mention-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.mention-thumb {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.mention-type-badge {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.mention-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
.mention-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -50,6 +50,9 @@ import cloneDeep from 'lodash-es/cloneDeep'
|
||||||
import { constantRoutes } from '@/router/index'
|
import { constantRoutes } from '@/router/index'
|
||||||
import { generateTitle, generateLang } from '@/utils/i18n'
|
import { generateTitle, generateLang } from '@/utils/i18n'
|
||||||
|
|
||||||
|
/** 左侧导航仅显示这些路由(name 与 router/index.js 一致) */
|
||||||
|
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen']
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
collapsed: Boolean
|
collapsed: Boolean
|
||||||
})
|
})
|
||||||
|
|
@ -62,9 +65,14 @@ const $base = inject('$base')
|
||||||
const $message = inject('$message')
|
const $message = inject('$message')
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
return constantRoutes
|
const root = constantRoutes.find((d) => d.path === '/')
|
||||||
.find((d) => d.path == '/')
|
const children = root?.children ?? []
|
||||||
.children.map((route) => ({
|
return children
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.meta?.menuItem !== false && SIDEBAR_ONLY_ROUTE_NAMES.includes(r.name)
|
||||||
|
)
|
||||||
|
.map((route) => ({
|
||||||
key: route.name,
|
key: route.name,
|
||||||
label: generateTitle(route.meta?.title),
|
label: generateTitle(route.meta?.title),
|
||||||
meta: route.meta,
|
meta: route.meta,
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,23 @@ export const convertBase64ToUrl = (base64) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从若依 AjaxResult / 兼容仅返回顶层 url 的上传响应中解析访问地址
|
||||||
|
*/
|
||||||
|
export const extractUploadUrlFromResponse = (res) => {
|
||||||
|
if (!res) return ''
|
||||||
|
if (typeof res.data === 'string' && res.data.trim()) return res.data.trim()
|
||||||
|
if (res.data && typeof res.data === 'object') {
|
||||||
|
const u = res.data.url || res.data.path
|
||||||
|
if (typeof u === 'string' && u.trim()) return u.trim()
|
||||||
|
}
|
||||||
|
if (typeof res.url === 'string' && res.url.trim()) return res.url.trim()
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门户统一腾讯云 COS:后端 FileController `/api/file/upload`,与 mf-image-upload 一致 */
|
||||||
|
export const PORTAL_TENCENT_COS_UPLOAD_URL = '/api/file/upload'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文件上传
|
* 文件上传
|
||||||
* @param {Object} {url, file,name} 文件上传地址 file 文件 name 文件名参数
|
* @param {Object} {url, file,name} 文件上传地址 file 文件 name 文件名参数
|
||||||
|
|
@ -148,12 +165,10 @@ export const uploadFile = ({
|
||||||
formData.append(key, data[key])
|
formData.append(key, data[key])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 不要手动设置 Content-Type,否则缺少 boundary,服务端无法解析 multipart,文件参数字段为空
|
||||||
return request({
|
return request({
|
||||||
url: url,
|
url: url,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData,
|
data: formData
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue