fix: bug修改字段缺失

This commit is contained in:
old burden 2026-03-30 12:30:05 +08:00
parent 5ae8614b1d
commit 0e94beb477
4 changed files with 583 additions and 11 deletions

View File

@ -50,7 +50,7 @@
<script>
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 {
name: 'RichTextEditor',
@ -105,13 +105,13 @@ export default {
//
const res = await uploadFile({
url: '/api/cos/upload', // 使COS
url: PORTAL_TENCENT_COS_UPLOAD_URL,
file: file,
name: 'file'
})
if (res && res.code === 200 && res.data) {
const imageUrl = typeof res.data === 'string' ? res.data : (res.data.url || res.data)
const imageUrl = extractUploadUrlFromResponse(res)
if (res && (Number(res.code) === 200 || res.code === 200) && imageUrl) {
insertImage(imageUrl, file.name)
// @

View File

@ -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>

View File

@ -50,6 +50,9 @@ import cloneDeep from 'lodash-es/cloneDeep'
import { constantRoutes } from '@/router/index'
import { generateTitle, generateLang } from '@/utils/i18n'
/** 左侧导航仅显示这些路由name 与 router/index.js 一致) */
const SIDEBAR_ONLY_ROUTE_NAMES = ['video-gen']
defineProps({
collapsed: Boolean
})
@ -62,9 +65,14 @@ const $base = inject('$base')
const $message = inject('$message')
const menuItems = computed(() => {
return constantRoutes
.find((d) => d.path == '/')
.children.map((route) => ({
const root = constantRoutes.find((d) => d.path === '/')
const children = root?.children ?? []
return children
.filter(
(r) =>
r.meta?.menuItem !== false && SIDEBAR_ONLY_ROUTE_NAMES.includes(r.name)
)
.map((route) => ({
key: route.name,
label: generateTitle(route.meta?.title),
meta: route.meta,

View File

@ -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 文件名参数
@ -148,12 +165,10 @@ export const uploadFile = ({
formData.append(key, data[key])
})
}
// 不要手动设置 Content-Type否则缺少 boundary服务端无法解析 multipart文件参数字段为空
return request({
url: url,
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
data: formData
})
}