Files
lckt-admin/src/components/richtext/rich-edit.vue
2025-09-02 22:08:50 +08:00

263 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="border border-solid border-gray-100 h-full">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="valueHtml"
class="overflow-y-hidden mt-0.5"
style="min-height: 18rem"
:default-config="editorConfig"
mode="default"
@onCreated="handleCreated"
@onChange="change"
/>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
const basePath = import.meta.env.VITE_BASE_API
import { onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image'
import botLogo from '@/assets/bot_logo.png'
const emits = defineEmits(['change', 'update:modelValue'])
const change = (editor) => {
emits('change', editor)
emits('update:modelValue', valueHtml.value)
}
const props = defineProps({
modelValue: {
type: String,
default: ''
},
useWatermark: {
type: Boolean,
default: false
}
})
const editorRef = shallowRef()
const valueHtml = ref('')
const toolbarConfig = {}
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 10, // 限制图片大小为10MB
maxNumberOfFiles: 1,
async customUpload(file, insertFn) {
// 未开启水印则直接上传原图
if (!props.useWatermark) {
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
}
try {
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
})
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 || '上传失败')
}
}
},
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)
}
}
}
}
editorConfig.MENU_CONF['uploadImage','uploadVideo'] = {
fieldName: 'file',
server: basePath + '/fileUploadAndDownload/upload?noSave=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
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor
valueHtml.value = props.modelValue
}
watch(
() => props.modelValue,
() => {
valueHtml.value = props.modelValue
}
)
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"></style>