🎨 优化富文本编辑器&新增图片水印

This commit is contained in:
2025-09-03 02:21:03 +08:00
parent a1906d2a36
commit 3c7ec9803a
9 changed files with 279 additions and 210 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div class="border border-solid border-gray-100 h-full z-10">
<div class="border border-solid border-gray-100 h-full">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
@@ -8,7 +8,7 @@
<Editor
v-model="valueHtml"
class="overflow-y-hidden mt-0.5"
style="height: 18rem"
style="min-height: 25rem; height: 400px;"
:default-config="editorConfig"
mode="default"
@onCreated="handleCreated"
@@ -22,12 +22,12 @@
const basePath = import.meta.env.VITE_BASE_API
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { onBeforeUnmount, ref, shallowRef, watch, computed } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image'
import { useUserStore } from '@/pinia/modules/user'
import botLogo from '@/assets/bot_logo.png'
const emits = defineEmits(['change', 'update:modelValue'])
@@ -36,38 +36,137 @@
emits('update:modelValue', valueHtml.value)
}
const userStore = useUserStore()
const props = defineProps({
modelValue: {
type: String,
default: ''
},
useWatermark: {
type: Boolean,
default: false
}
})
// 强制启用水印功能(临时解决方案)
const forceWatermark = true
// 调试:监听 props 变化
watch(() => props.useWatermark, (newVal) => {
console.log('useWatermark prop changed to:', newVal)
}, { immediate: true })
// 调试:打印所有 props
console.log('All props:', props)
console.log('useWatermark value:', props.useWatermark)
const editorRef = shallowRef()
const valueHtml = ref('')
const toolbarConfig = {}
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {}
}
editorConfig.MENU_CONF['uploadImage'] = {
fieldName: 'file',
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
headers: {
'x-token': userStore.token,
},
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
// 创建自定义上传函数,直接检查当前 props 值
const createCustomUpload = () => {
return async (file, insertFn) => {
// 直接获取当前的 props.useWatermark 值
const shouldUseWatermark = props.useWatermark || forceWatermark
console.log('customUpload called, useWatermark:', shouldUseWatermark)
console.log('props.useWatermark:', props.useWatermark)
console.log('forceWatermark:', forceWatermark)
// 未开启水印则直接上传原图
if (!shouldUseWatermark) {
console.log('直接上传原图,不加水印')
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
return
}
ElMessage.error(res.msg)
try {
console.log('开始添加水印')
const watermarkedBlob = await addBottomWatermark(file, {
stripRatio: 0.18, // 水印条高度占原图高度比例
background: 'rgba(255,255,255,0.96)',
text: '老陈机器人',
textColor: '#333',
fontFamily: 'PingFang SC, Microsoft YaHei, Arial',
logo: botLogo
})
console.log('水印处理完成')
const newFile = new File([watermarkedBlob], file.name, { type: watermarkedBlob.type || file.type })
const formData = new FormData()
formData.append('file', newFile)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('处理水印失败')
// 降级:直接走原图上传
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
}
}
}
// 将 editorConfig 改为计算属性,使其响应 props 变化
const editorConfig = computed(() => ({
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 10, // 限制图片大小为10MB
maxNumberOfFiles: 1,
customUpload: createCustomUpload(),
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
},
uploadVideo: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 100, // 限制视频大小为100MB
maxNumberOfFiles: 1,
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
}
}
}))
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
@@ -78,6 +177,33 @@
const handleCreated = (editor) => {
editorRef.value = editor
valueHtml.value = props.modelValue
// 动态更新上传配置
if (editor && editor.getConfig) {
const config = editor.getConfig()
if (config.MENU_CONF && config.MENU_CONF.uploadImage) {
config.MENU_CONF.uploadImage.customUpload = createCustomUpload()
console.log('动态更新上传配置useWatermark:', props.useWatermark)
}
}
// 修复点击区域问题
setTimeout(() => {
const editorContainer = editor.getEditableContainer()
if (editorContainer) {
// 确保整个编辑器区域都可以点击
editorContainer.style.cursor = 'text'
editorContainer.style.minHeight = '300px'
// 添加点击事件监听器
editorContainer.addEventListener('click', (e) => {
if (e.target === editorContainer) {
// 如果点击的是容器本身,聚焦到编辑器
editor.focus()
}
})
}
}, 100)
}
watch(
@@ -86,8 +212,135 @@
valueHtml.value = props.modelValue
}
)
// 监听 useWatermark 变化,动态更新编辑器配置
watch(
() => props.useWatermark,
(newVal) => {
console.log('useWatermark changed to:', newVal)
if (editorRef.value && editorRef.value.getConfig) {
const config = editorRef.value.getConfig()
if (config.MENU_CONF && config.MENU_CONF.uploadImage) {
config.MENU_CONF.uploadImage.customUpload = createCustomUpload()
console.log('动态更新上传配置useWatermark:', newVal)
}
}
}
)
async function addBottomWatermark(file, options) {
const { stripRatio = 0.18, background = 'rgba(255,255,255,0.96)', text = '', textColor = '#333', fontFamily = 'Arial', logo } = options || {}
const img = await fileToImage(file)
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
const stripHeight = Math.max(60, Math.floor(height * stripRatio))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height + stripHeight
const ctx = canvas.getContext('2d')
// 原图
ctx.drawImage(img, 0, 0, width, height)
// 底部水印条背景
ctx.fillStyle = background
ctx.fillRect(0, height, width, stripHeight)
// 左侧 Logo可选
let logoSize = Math.floor(stripHeight * 0.6)
let logoPadding = Math.floor(stripHeight * 0.2)
if (logo) {
try {
const logoImg = await srcToImage(logo)
const ratio = logoImg.width / logoImg.height
const drawW = logoSize
const drawH = Math.floor(drawW / ratio)
const y = height + Math.floor((stripHeight - drawH) / 2)
ctx.drawImage(logoImg, logoPadding, y, drawW, drawH)
} catch { void 0 }
}
// 右侧文字
ctx.fillStyle = textColor
const fontSize = Math.floor(stripHeight * 0.38)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
const textPadding = Math.floor(stripHeight * 0.25)
ctx.fillText(text, width - textPadding, height + Math.floor(stripHeight / 2))
const blob = await canvasToBlob(canvas, file.type || 'image/png')
return blob
}
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function srcToImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob((blob) => resolve(blob), mime, 0.92)
} else {
// 兼容处理
const dataURL = canvas.toDataURL(mime)
const arr = dataURL.split(',')
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
resolve(new Blob([u8arr], { type: mime }))
}
})
}
</script>
<style scoped lang="scss">
// 确保富文本编辑器的点击区域
:deep(.w-e-text-container) {
min-height: 300px !important;
cursor: text !important;
}
</style>
:deep(.w-e-scroll) {
min-height: 300px !important;
cursor: text !important;
}
:deep(.w-e-text-placeholder) {
pointer-events: none;
}
// 确保整个编辑器区域都可以点击
:deep(.w-e-text-container .w-e-scroll) {
cursor: text !important;
}
// 修复编辑器内容区域的点击问题
:deep(.w-e-text-container .w-e-scroll .w-e-text) {
min-height: 300px !important;
cursor: text !important;
}
</style>