Vue + Element 实现图片上传添加自定义水印(图片水印、文字水印都可以)

一、目的

本文通过 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 只能帮我节省时间提高开发效率,却省不了自身的学习成本和经验,加油!有什么建议欢迎评论留言,转载请注明出处哈,感谢支持!