一、目的
本文通过 Vue2 + ElementUI 实现图片上传时添加自定义水印,支持图片水印、文字水印,以及水印大小位置等样式的控制。目前查阅网上的一些资料,发现各篇文章对这方面内容杂乱零散,也不全面。所以结合自己的学习和思考,对此进行归纳整理,给出相关的解决方案,实现效果如下视频所示。
二、功能实现要点
- 使用 element 的 el-upload 组件,关键属性有 action、http-request、file-list、on-preview 等,既然是需要对上传的图片进行处理(加水印操作),所以一定会用到 http-request 属性来实现自定义上传。
- 读取图片并获取临时的 URL 需要 new Image() 实例,然后需要创建 Canvas 画布进行重绘,根据图片大小自适应控制水印大小(包括文字和图片),同时设有相关样式的默认值。
- 可以通过传入参数方式来控制图片水印所在位置,以及配置其他相关样式,画出最终的 Canvas,并转成图片文件,最后调用上传接口。
- 上传后的图片可以使用 element 源码的单独 image-viewer 组件进行查看图片。
三、完整源码(开箱即用)
1、组件源码:WatermarkComp.vue
javascript
<template>
<div
v-loading="uploading"
element-loading-text="图片处理中..."
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(0, 0, 0, 0.8)">
<el-upload
action="#"
:headers="headers"
list-type="picture-card"
:multiple="false"
:limit="limit"
:with-credentials="withCredentials"
:show-file-list="showFileList"
:file-list="fileListValue"
:before-upload="uploadFileBeforeUpload"
:on-exceed="uploadFileOnExceed"
:on-preview="uploadFilePreview"
:on-remove="uploadFileOnRemove"
:http-request="httpRequest">
<i class="el-icon-plus"></i>
</el-upload>
<span v-if="uploadTips">{{ uploadTips }}</span>
<ElImageViewer v-if="imgPreviewOpen" :on-close="() => { imgPreviewOpen = false }" :url-list="imgPreviewUrl.split()" style="z-index: 99999;"></ElImageViewer>
</div>
</template>
<script>
import ElImageViewer from 'element-ui/packages/image/src/image-viewer' // 前提是安装使用 element-ui 的组件库
import { getToken } from '@/utils/auth' // 用户自定义,获取 token 方法(一般使用 js-cookie)
import { fileUpload } from '@/api/common' // 用户自定义,上传文件接口(后端提供)
export default {
name: 'WatermarkComp', // 水印上传
components: {
ElImageViewer
},
data(){
return {
headers: { // 用户自定义头部
Authorization: 'Bearer ' + getToken()
},
imgPreviewUrl: '', // 图片预览 url
imgPreviewOpen: false, // 图片弹窗是否打开
uploading: false // 是否上传中...
}
},
props: {
limit: { // 最大允许上传个数
type: Number,
default: 0
},
showFileList: { // 是否显示已上传文件列表
type: Boolean,
default: true
},
fileListValue: { // 上传的文件列表, 例如: [{name: 'food.jpg', url: 'https://xxx.cdn.com/xxx.jpg'}]
type: Array,
default: () => []
},
withCredentials: { // 支持发送 cookie 凭证信息
type: Boolean,
default: false
},
uploadTips: { // 提示语
type: String,
default: '点击此处上传文件'
},
fileSize: { // 大小限制(MB)
type: Number,
default: 0
},
params: { // 水印参数
type: Object,
default: () => {}
}
},
watch: {
fileListValue: {
deep: true,
handler(val){
console.log('文件列表', val)
}
}
},
methods: {
async handleWatermark(file){ // 对原图进行处理,加水印操作
const img = new Image()
// 创建指向内存中 Blob/File 对象的临时 URL,允许在浏览器中直接访问本地文件内容,无需将文件上传到服务器即可预览
// 与 FileReader 的区别:createObjectURL 返回 URL,适合直接用于 src/href 属性,FileReader 返回 Data URL(base64编码),适合小文件
img.src = URL.createObjectURL(file.file || file.raw || file)
await new Promise((resolve) => (img.onload = resolve))
const logoImg = new Image()
logoImg.src = this.params.logoImgUrl || require('@/assets/logo.png')
await new Promise((resolve) => (logoImg.onload = resolve))
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width // 设置 canvas 尺寸就是原始图片尺寸
canvas.height = img.height
ctx.drawImage(img, 0, 0) // 绘制原始图片
// 添加文字水印
const { fontSize, lineHeight } = this.calculateTextStyle(img.width, img.height)
ctx.fillStyle = this.params.fontColor || '#fff' // 设置填充颜色
ctx.font = `500 ${this.params.fontSize || fontSize}px Microsoft YaHei` // 设置字体样式
if (this.params.textList && this.params.textList.length) { // 遍历存在多行的文字水印(如果有的话)
let startTextH = 0.98 * img.height - this.params.textList.length * lineHeight
this.params.textList.forEach(text => {
ctx.fillText(text, 0.02 * img.width, startTextH, img.width)
startTextH = startTextH + lineHeight
})
}
// 默认都会有"上传时间水印"
ctx.fillText(`上传时间:${this.getFormattedTime('YYYY-MM-DD HH:mm:ss')}`, 0.02 * img.width, 0.98 * img.height, img.width)
// 添加 Logo 图片水印
const elementSize = this.calculateWatermarkSize(img, logoImg)
const positionCoords = this.calculatePosition(img, elementSize, this.params.logoPosition || '')
ctx.drawImage(logoImg, positionCoords.x, positionCoords.y, elementSize.width, elementSize.height)
file.url = canvas.toDataURL('image')
return file
},
async httpRequest(file) { // 将文件转成上传接口所需格式并请求
try {
console.log('原始上传文件', file)
this.uploading = true
const handleFile = await this.handleWatermark(file)
const uploadFile = this.base64ToFile(handleFile.url, handleFile.file.name || handleFile.name)
const res = await fileUpload(uploadFile)
if (res.code === 200) {
// 根据接口返回内容自行添加到文件列表中
this.fileListValue.push({
...file,
id: res.data.fileId,
name: res.data.fileName,
url: res.data.viewUrl
})
}
} catch (error) {
console.error('处理并上传报错', error)
} finally {
this.uploading = false
}
},
uploadFileOnExceed(){ // 文件个数超出
this.$message.error(`上传文件数量不能超过 ${this.limit} 个`)
},
uploadFileBeforeUpload(file){ // 上传文件之前的钩子
if (file.type.indexOf('image') <= -1) {
this.$message.error('上传文件类型不是图片')
return false
}
if (this.fileSize && file.size / 1024 / 1024 > this.fileSize) {
this.$message.error(`上传文件大小不能超过 ${this.fileSize} MB`)
return false
}
},
uploadFileOnRemove(file, fileList){ // 文件列表删除完成后
this.fileListValue = fileList.length ? fileList : []
// console.log('上传文件删除', file, this.fileListValue)
},
uploadFilePreview(file){ // 点击已上传的文件
console.log('查看文件', file)
let tempFileUrl = ''
if (file.response && file.response.url) {
tempFileUrl = file.response.url
} else if (file.url) {
tempFileUrl = file.url
}
if ((file.raw && /image\/[a-zA-z]+/.test(file.raw.type)) || (file.file && /image\/[a-zA-z]+/.test(file.file.type))) { // 图片
this.imgPreviewUrl = tempFileUrl
this.imgPreviewOpen = true
} else { // 其他文件
this.$confirm(`是否确定<b style="color: #4c91ff;"> 下载 ${file.name || file.file.name} </b>`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true,
type: 'warning'
}).then(() => {
if (tempFileUrl) {
window.open(tempFileUrl)
} else {
this.$message.error('下载失败!')
}
})
}
},
calculateTextStyle(imageWidth, imageHeight){ // 计算自适应文字大小和行间距
// 以图片较小边为基准计算基础尺寸
const baseSize = Math.min(imageWidth, imageHeight)
// 文字大小:图片宽度的 3%-5%,根据图片大小动态调整
const fontSize = Math.max(
baseSize * 0.03, // 最小为图片尺寸的 3%
Math.min(baseSize * 0.05, 40) // 最大不超过 40px,避免过大
)
// 行间距:字体大小的 1.2-1.5 倍,根据文字大小调整
const lineHeight = fontSize * (fontSize < 20 ? 1.5 : 1.2)
return {
fontSize: Math.round(fontSize),
lineHeight: Math.round(lineHeight)
}
},
calculateWatermarkSize(originalImg, watermarkImg, options = {}){ // 计算水印图片自适应尺寸
const {
scale = 0.1, // 水印相对于原图的比例
maxWidthRatio = 1, // 最大宽度比例
maxHeightRatio = 1, // 最大高度比例
minWidth = 20, // 最小宽度
minHeight = 20 // 最小高度
} = options
const originalWidth = originalImg.width || originalImg.naturalWidth // 原始图片宽度
const originalHeight = originalImg.height || originalImg.naturalHeight // 原始图片高度
const watermarkWidth = watermarkImg.width || watermarkImg.naturalWidth // 水印图片宽度
const watermarkHeight = watermarkImg.height || watermarkImg.naturalHeight // 水印图片高度
// 计算基于比例的尺寸
let targetWidth = originalWidth * scale
let targetHeight = (targetWidth / watermarkWidth) * watermarkHeight
// 如果高度超过限制,以高度为基准重新计算
if (targetHeight > originalHeight * maxHeightRatio) {
targetHeight = originalHeight * maxHeightRatio
targetWidth = (targetHeight / watermarkHeight) * watermarkWidth
}
// 如果宽度超过限制,以宽度为基准重新计算
if (targetWidth > originalWidth * maxWidthRatio) {
targetWidth = originalWidth * maxWidthRatio
targetHeight = (targetWidth / watermarkWidth) * watermarkHeight
}
// 确保不小于最小尺寸
targetWidth = Math.max(targetWidth, minWidth)
targetHeight = Math.max(targetHeight, minHeight)
// 保持水印图片的宽高比
const aspectRatio = watermarkWidth / watermarkHeight
if (targetWidth / targetHeight > aspectRatio) {
targetWidth = targetHeight * aspectRatio
} else {
targetHeight = targetWidth / aspectRatio
}
return {
width: Math.round(targetWidth),
height: Math.round(targetHeight)
}
},
calculatePosition(containerSize, elementSize, position, padding = 7){ // 位置计算方法的封装
const { width: containerWidth, height: containerHeight } = containerSize // 容器大小
const { width: elementWidth, height: elementHeight } = elementSize // 操作目标元素大小
const positions = {
// 中心位置
'center': { x: (containerWidth - elementWidth) / 2, y: (containerHeight - elementHeight) / 2 },
// 四角位置
'top-left': { x: padding, y: padding },
'top-right': { x: containerWidth - elementWidth - padding, y: padding },
'bottom-left': { x: padding, y: containerHeight - elementHeight - padding },
'bottom-right': { x: containerWidth - elementWidth - padding, y: containerHeight - elementHeight - padding },
// 边缘居中位置
'top-center': { x: (containerWidth - elementWidth) / 2, y: padding },
'bottom-center': { x: (containerWidth - elementWidth) / 2, y: containerHeight - elementHeight - padding },
'left-center': { x: padding, y: (containerHeight - elementHeight) / 2 },
'right-center': { x: containerWidth - elementWidth - padding, y: (containerHeight - elementHeight) / 2 },
// 九宫格位置
'top-left-inner': { x: containerWidth * 0.1, y: containerHeight * 0.1 },
'top-right-inner': { x: containerWidth * 0.7 - elementWidth, y: containerHeight * 0.1 },
'bottom-left-inner': { x: containerWidth * 0.1, y: containerHeight * 0.7 - elementHeight },
'bottom-right-inner': { x: containerWidth * 0.7 - elementWidth, y: containerHeight * 0.7 - elementHeight }
}
return positions[position] || positions['center'] // 默认中心位置
},
getFormattedTime(format) { // 当前时间格式化(也可以借助第三方库 day.js 或者 moment.js)
const now = new Date()
const pad = (num) => String(num).padStart(2, '0')
const replacements = {
'YYYY': now.getFullYear(),
'MM': pad(now.getMonth() + 1), // 月份从 0 开始,需 +1
'DD': pad(now.getDate()),
'HH': pad(now.getHours()),
'mm': pad(now.getMinutes()),
'ss': pad(now.getSeconds()),
}
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, match => replacements[match]) // 一次替换所有占位符(注意匹配顺序不影响结果)
},
base64ToFile(str, fileName){ // base64 转成 file 或者 blob
const arr = str.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bStr = atob(arr[1])
let n = bStr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bStr.charCodeAt(n)
}
return new File([u8arr], fileName, { type: mime }) // file
// return new Blob([u8arr], { type: mime }) // blob
}
}
}
</script>
2、调用方式:某 vue 文件(比如 index.vue)直接使用
javascript
<template>
<div>
<!-- 调用组件 -->
<WatermarkComp :limit="6" :params="wImgParams" />
</div>
</template>
<script>
import WatermarkComp from '@/components/WatermarkComp/index' // 引入组件
export default {
components: { // 注册组件
WatermarkComp
},
data() {
return {
wImgParams: {
logoImgUrl: require('@/assets/icons/bg1.png'), // 自定义水印图片,从本地导入
logoPosition: 'top-right', // 图片水印的位置
textList: ['用户:hxhpg', '操作平台:PC', '地址:浙江省杭州市'] // 文字水印内容
}
}
}
}
</script>
这是我本人在工作学习中做的一些总结,同时也分享出来给需要的小伙伴哈 ~ 供参考学习,虽然现在 AI 已经很强大了,但是 AI 只能帮我节省时间提高开发效率,却省不了自身的学习成本和经验,加油!有什么建议欢迎评论留言,转载请注明出处哈,感谢支持!