263 lines
8.0 KiB
Vue
263 lines
8.0 KiB
Vue
<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>
|