一个文笔一般,想到哪是哪的唯心论前端小白。
🧠 - 简介
做完了才发现 element-plus 已经有了水印组件了 . . .
如下是配置相关参数:
仔细阅读了一下,发现跟我做的还是有点区别的:
既然做都做了,那就简单分享一下我的思路吧!
通过简单的比较,可以看到,我的这个水印呢,感觉更适合业务人员去使用,而 element-plus 则一改原来的 mini|small|large
,而变成了具体的值来控制。另一方面系统预设了一些内容,更方便私有化场景下定制使用。其实我的整个模版项目都是基于 element-plus 做的,这个水印如果需要增加表单配置,可能还需要二次开发。
👁️ - 分析
水印的作用主要防止用户对网页进行截图,然后拿来做有损平台利益的事情,所以做出水印是第一步,下一步则是防止用户用一些手段把水印干掉!
所以,社区大佬也有很多的新颖的设计,来防止用户破解。例如常见的:
- 用户找到水印层,使用
display: none
,来将水印干掉。对应方法:使用肉眼捕捉不到的频率来重置水印。- 用户通过先将页面缩放到很小的级别,刷新水印变成一个小点,然后再放大回来。对应方法:监听浏览器变化,重置水印。
- 用户直接找到水印层,删除dom节点。对应方法:跟1一样,也是自己来刷新重置。
- xxx...
水印层制作
水印的核心是一个css样式:[MDN] pointer-events。
然后就是具体实现一个蒙版层,画水印。
我知道的绘制思路如下:
- 使用 div 进行绘制
- 一个大的div,里面使用栈格布局的方式,绘制多个小div,实现满屏的水印
- 好多个小div,使用绝对定位的方式,计算每一个小div的位置,实现满屏的小水印,心情好了还可以让他们飞舞起来哟
- 使用canvas进行绘制
- 一个 canvas 绘制,设计一个矩阵的绘制方法,然后使用两层循环进行绘制,
- 两个 canvas 绘制,一个 canvas 用来根据参数绘制单个水印,在另一个水印里面把这个canvas的绘制结果铺满整个画布
- canvas 配合 div,canvs 生成一个水印,然后 div 使用背景图片的方式实现水印
- 使用 svg 绘制
安全性分析
在我的认知里,安全性跟前端的关系不是特别大。😂😂😂
为什么这么说呢,前端的安全性分为两块:
- 数据安全
- 文件:最终是通过数据库进行下载的,所以前端无法把控,只要接口安全性够高,站在浏览器的角度是无从下手的,但凡接口有漏洞可钻,前端也是很难拦截的。
- 数据:数据安全方面,前端只能阻止浏览器默认的复制粘贴行为,但是用户还是可以通过页面审查进行复制的,更有甚者使用爬虫,不胜其烦。
- 界面安全:主要是拦截用户截图和录屏的操作了,所以前端就出现了水印这个设计。然而水印这个设计就很尴尬,如果做的很安全(防止用户所有的隐藏),必然会很考验用户的设备,还要考虑到浏览器的兼容性。
我理解的安全性,是针对非专业人员来说的。如果要针对黑客级别的,那就只有报警了,开玩笑 🫣🫣🫣,谁家正经黑客,截你的屏还去水印啊 ~ ~ ~
🫀 - 拆解
言归正传,开搞 ~~~
首先是页面设计,其实就是一个抽屉,里面放了个表单:
vue
<template>
<Drawer
ref="watermarkDawer"
size="small"
:show-cancel="false"
:confirm-text="'关闭'"
@confirm="hendleConformDrawer"
>
<el-form
ref="waterFormRef"
:model="waterForm"
:rules="rules"
label-width="90px"
size="default"
>
<el-form-item
label="水印开关:"
prop="isOpen"
>
<el-switch
v-model="waterForm.isOpen"
inline-prompt
active-text="开启"
inactive-text="关闭"
/>
</el-form-item>
<el-form-item
v-show="waterForm.isOpen"
label="水印密度:"
prop="density"
>
<el-radio-group v-model="waterForm.density">
<el-radio-button label="low">
密集
</el-radio-button>
<el-radio-button label="medium">
正常
</el-radio-button>
<el-radio-button label="high">
宽松
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-show="waterForm.isOpen"
label="水印颜色:"
prop="dark"
>
<el-radio-group v-model="waterForm.dark">
<el-radio-button label="high">
深
</el-radio-button>
<el-radio-button label="medium">
中
</el-radio-button>
<el-radio-button label="low">
浅
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-show="waterForm.isOpen"
label="水印大小:"
prop="size"
>
<el-radio-group v-model="waterForm.size">
<el-radio-button label="high">
大
</el-radio-button>
<el-radio-button label="medium">
中
</el-radio-button>
<el-radio-button label="low">
小
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-show="waterForm.isOpen"
label="水印旋转:"
prop="rotate"
>
<el-radio-group v-model="waterForm.rotate">
<el-radio-button :label="45">
45°
</el-radio-button>
<el-radio-button :label="30">
30°
</el-radio-button>
<el-radio-button :label="0">
0°
</el-radio-button>
<el-radio-button :label="-30">
-30°
</el-radio-button>
<el-radio-button :label="-45">
-45°
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-show="waterForm.isOpen"
label="水印主题:"
prop="theme"
>
<el-radio-group v-model="waterForm.theme">
<el-radio-button label="preset">
系统预设
</el-radio-button>
<el-radio-button label="custom">
自定义
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'preset'"
label="水印类型:"
prop="type"
>
<el-radio-group v-model="waterForm.type">
<el-radio-button label="secretImage">
绝密图片
</el-radio-button> <!-- image -->
<el-radio-button label="secretText">
公司机密
</el-radio-button> <!-- 公司机密 -->
<el-radio-button label="tort">
侵权必究
</el-radio-button> <!-- 版权所有,侵权必究! -->
<el-radio-button label="username">
用户账号
</el-radio-button> <!-- 紫衣小生 -->
</el-radio-group>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom'"
label="水印类型:"
prop="customType"
>
<el-radio-group v-model="waterForm.customType">
<el-radio-button label="image">
图片
</el-radio-button>
<el-radio-button label="text">
文字
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom' && waterForm.customType === 'image'"
label="图片类型:"
prop="imageType"
>
<el-radio-group v-model="waterForm.imageType">
<el-radio-button label="internet">
网络图片
</el-radio-button>
<el-radio-button
label="upload"
disabled
>
本地上传
</el-radio-button>
<el-radio-button
label="imageWall"
disabled
>
图片墙
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom' && waterForm.customType === 'text'"
label="文字内容:"
prop="text"
>
<el-input
v-model="waterForm.text"
placeholder="请输入水印文字"
/>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom' && waterForm.customType === 'image' && waterForm.imageType === 'internet'"
label="图片地址:"
prop="imageType"
>
<el-input
v-model="waterForm.imageUrl"
placeholder="请输入图片地址"
/>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom' && waterForm.customType === 'image' && waterForm.imageType === 'upload'"
label="图片上传:"
prop="imageType"
>
<el-input
v-model="waterForm.imageUrl"
placeholder="请上传图片"
/>
</el-form-item>
<el-form-item
v-if="waterForm.isOpen && waterForm.theme === 'custom' && waterForm.customType === 'image' && waterForm.imageType === 'imageWall'"
label="图片墙:"
prop="imageType"
>
<el-input
v-model="waterForm.imageUrl"
placeholder="图片墙"
/>
</el-form-item>
</el-form>
</Drawer>
</template>
<script lang="ts" setup>
import { FormRules } from 'element-plus';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { Watermark, WatermarkConfig } from '@/utils/watermark.class'
import juemiPng from '@/assets/images/icon/juemi.png'
import useMainStore from '@/store/layoutMain';
import { objectEntries, useLocalStorage } from '@vueuse/core';
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
* @Name: CommonWatermark
* @Author: Zhang Ziyi
* @Email: --@163.com
* @Date: 2024-02-22 11:42
* @Introduce: --
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
const mainStore = useMainStore()
const userInfo = computed(() => mainStore.userInfo)
const watermarkDawer = ref()
const waterFormRef = ref()
interface WaterFormType {
isOpen: boolean
density: "low" | "medium" | "high"
dark: "low" | "medium" | "high"
theme: 'preset' | 'custom'
size: "low" | "medium" | "high"
type: string
customType: string
imageType: string
text: string
imageUrl: string
rotate: number
}
const waterForm = reactive<WaterFormType>({
isOpen: false,
theme: "preset",
density: 'medium',
dark: 'medium',
type: 'username',
customType: 'text',
imageType: 'internet',
text: '',
imageUrl: '',
rotate: 30,
size: 'medium'
})
watch(waterForm, (v) => initWatermark())
const rules = reactive<FormRules<WaterFormType>>({})
const Wm = new Watermark()
const initWatermark = () => {
const config: WatermarkConfig = {
isOpen: false,
type: 'text',
text: '用户名称',
dark: 'low',
density: 'medium',
imageUrl: juemiPng,
size: "medium",
rotate: -30
}
config.isOpen = waterForm.isOpen
config.density = waterForm.density
config.dark = waterForm.dark
config.size = waterForm.size
config.rotate = waterForm.rotate
if (waterForm.theme === 'custom') {
if (waterForm.customType === 'text') {
config.text = waterForm.text
config.type = waterForm.customType
}
if (waterForm.customType === 'image') {
config.type = waterForm.customType
if (!waterForm.imageUrl) return
config.imageUrl = waterForm.imageUrl
}
} else {
config.type = ['username', 'secretText', 'tort'].includes(waterForm.type) ? 'text' : 'image'
if (waterForm.type === 'username') {
config.text = userInfo.value.realname
} else if (waterForm.type === 'secretText') {
config.text = '公司机密!'
} else if (waterForm.type === 'tort') {
config.text = ['版权所有,', '侵权必究!']
} else if (waterForm.type === 'secretImage') {
config.imageUrl = juemiPng
}
}
Wm.applyWatermark(config)
localStorage.setItem('watermark', JSON.stringify(waterForm))
console.log('🎡 > 水印初始化完成,当前水印配置为:');
console.table(config)
}
onMounted(() => {
const waterwarkLocal = useLocalStorage('watermark', {}).value
Object.assign(waterForm, waterwarkLocal)
})
const hendleConformDrawer = (close: () => void) => close()
// 对外暴露方法
const open = () => {
watermarkDawer.value.open('水印管理')
}
defineExpose({
open
})
</script>
<style lang="scss" scoped></style>
我的设计就是,通过表单更新,触发 Wm.applyWatermark(config)
,这个方法,然后实现水印的更新,如果后续又更高的要求,比如说不停的刷新水印之类的安全性考虑,加个定时器就好了。当然,通过修改 config,也可以 在 Watermark 声明出动画来。极尽花里胡哨。
💪 - 落实
重头戏来了,在页面里面可以看到我定义了一个名为 Watermark 的 类,有了这个类,就可以实现所有的功能了。
最终我选择的方案是:一个铺满全屏的canvas,在里面随心所欲的绘制水印,理论上是可以绘制各种各样的水印的。
直接上代码:
ts
import { useWindowSize, useDebounceFn, useThrottleFn } from '@vueuse/core'
export interface WatermarkConfig {
isOpen: boolean
type: string;
density: "low" | "medium" | "high";
dark: "low" | "medium" | "high";
size: "low" | "medium" | "high";
text: string | string[];
imageUrl: string;
rotate: number
}
export class Watermark {
private config : WatermarkConfig
private cv: HTMLCanvasElement | null
private ctx: CanvasRenderingContext2D | null
private alpha: number
private padding: number
private size: number
constructor () {
this.config = {
isOpen: false,
type: 'text',
text: '水印',
dark: 'medium',
density: 'medium',
size: 'medium',
imageUrl: '',
rotate: 30
}
this.alpha = 0.6
this.padding = 24
this.size = 64
this.cv = null
this.ctx = null
this.initCanvas()
window.addEventListener('resize',useThrottleFn(() => this.applyWatermark(this.config), 500))
}
initCanvas(){
const _cv = document.getElementById('watermark') as HTMLCanvasElement
if(_cv){
this.cv = _cv
}else{
this.cv = document.createElement('canvas')
this.cv.id = 'watermark'
document.body.append(this.cv)
}
this.cv.width = useWindowSize().width.value
this.cv.height = useWindowSize().height.value
this.ctx = this.cv.getContext('2d') as CanvasRenderingContext2D
this.ctx.clearRect(0,0, this.cv.width, this.cv.height)
this.cv.style.position = 'fixed'
this.cv.style.top = '0px'
this.cv.style.left = '0px'
this.cv.style.zIndex = '99999'
this.cv.style.pointerEvents = 'none'
}
private mergeConfig(c: WatermarkConfig){
this.config = Object.assign({}, this.config ,c)
this.alpha = c.dark === 'high' ? 0.3 : c.dark === 'medium' ? 0.15 : 0.1
this.padding = c.density === 'high' ? 32 : c.density === 'medium' ? 24 : 12
this.size = c.size === 'high' ? 48 : c.size === 'medium' ? 32 : 24
if(!this.config.isOpen){
return false
}else{
return true
}
}
private drawText (x: number, y: number, rad: number, lineNum: number){
// 保存当前画布状态
this.ctx!.save();
// 将画布旋转 30 度
this.ctx!.translate(x, y); // 将坐标系移动到水印位置
this.ctx!.rotate(rad); // 旋转画布
this.ctx!.translate(-x, -y); // 将坐标系移回原点
// 在指定位置写入"水印"两个字
this.ctx!.textBaseline= "hanging"
this.ctx!.font = `${this.size}px Arial`;
this.ctx!.fillStyle = `rgba(0, 0, 0, ${this.alpha})`;
// this.ctx!.fillText(this.config.text, lineNum % 2 ? x : x - 50, y);
Array.isArray(this.config.text) ? this.config.text.forEach((text, _i) => {
this.ctx!.fillText(text, x, y + _i * this.size);
}) : this.ctx!.fillText(this.config.text, x, y)
// 恢复之前的画布状态
this.ctx!.restore();
}
private drawImage(image: HTMLImageElement, x:number, y:number, w:number, h:number,rad:number) {
// 保存当前画布状态
this.ctx!.save();
// 将画布旋转 30 度
this.ctx!.translate(x, y); // 将坐标系移动到水印位置
this.ctx!.rotate(rad); // 旋转画布
this.ctx!.translate(-x, -y); // 将坐标系移回原点
// 在指定位置写入"水印"两个字
this.ctx!.textBaseline= "hanging"
this.ctx!.font = `${this.size}px Arial`;
this.ctx!.fillStyle = `rgba(0, 0, 0, ${this.alpha})`;
this.ctx!.globalAlpha = this.alpha
this.ctx!.drawImage(image,x,y,w,h);
// 恢复之前的画布状态
this.ctx!.restore();
}
private draw() {
const rad = Math.PI / 180 * this.config.rotate;
if(this.config.type === 'text'){
const lineWidth = Array.isArray(this.config.text) ? this.config.text[0].length * this.size : this.config.text.length * this.size; // 每个字大小为 this.size px, 总宽度就是 this.sizepx * 字数
const lineHeight = Array.isArray(this.config.text) ? this.config.text.length * this.size : this.size // 高度就是 宽度 / tan(rotate)
let lineNum = 0 // 错位标记
for(let _y=0; _y < this.cv!.height; _y+= lineHeight + this.padding * 2){
for(let _x=0; _x < this.cv!.width; _x+= lineWidth + this.padding * 2){
this.drawText(_x, _y, rad, lineNum)
}
lineNum ++
}
} else {
const _img = new Image()
_img.src = this.config.imageUrl
// eslint-disable-next-line
const _self = this
_img.onload = function() {
const imgZoom = _img.width / _img.height
const imgWidth = _self.size * 1.5
const imgHeight = _self.size / imgZoom * 1.5
setTimeout(async () => {
let lineNum = 0 // 错位标记
for(let _y=0; _y < _self.cv!.height; _y+= imgHeight + _self.padding * 2){
for(let _x=0; _x < _self.cv!.width; _x+= imgWidth + _self.padding * 2){
_self.drawImage(this as HTMLImageElement ,_x, _y, imgWidth, imgHeight,rad)
}
lineNum ++
}
}, 20)
}
}
}
async applyWatermark(config: WatermarkConfig){
const isOpen = this.mergeConfig(config)
this.initCanvas()
if(isOpen) { this.draw() }
}
}
简单总结一下,在 WaterMark 这个类上,只有6个方法:
initCanvas
: 初始化画布,并把canvas放进body里面mergeConfig
:合并参数,也可以称为处理参数drawText
:绘制文本工具方法drawImage
:绘制图片工具方法draw
:绘制水印applyWatermark
:对外暴露,总调度函数
整个流程就是:
- 在 new 阶段,会把 canvas 放进 body 里面,并挂载到 this 上,方便后续使用
- 在页面中会读取上次的水印配置,如果有的话,会执行上次的水印配置哦!
- 读取到配置,则配置参数进行预处理,然后返回一个 isOpen 的状态,如果为 true 则继续绘制
- 在绘制水印方法里面将画布 矩阵化,然后每一个节点根据水印类型,分别绘制文本和图片
是不是很简单?
至于前文提到的水印安全问题,我只用到了:
ts
window.addEventListener('resize',useThrottleFn(() => this.applyWatermark(this.config), 500))
来监听浏览器变化,进行重新绘制水印。至于用户如果用display或者删除节点的方法去破解,再加个定时器去重复的执行这个方法就好了。对了,还要把 initCanvas 也挪进来。
为了延长我的小破本的寿命,所以我就把这段逻辑略过啦!
🛀 - 总结
水印功能不复杂,只是做一个工具,方便日后使用 ~ ~ ~
唯一的缺陷就是,如果到了没有引入这个水印的页面,水印加不上,但是无伤大雅呀,自己解决喽!
系列文章:
- 脚手架开发
- 模板项目初始化
- 模板项目开发规范与设计思路
- layout设计与开发
- login 设计与开发
- CURD页面的设计与开发
- 监控页面的设计与开发
- 富文本编辑器的使用与页面设开发设计
- 主题切换的设计与开发并页面
- 水印切换的设计与开发
- 全屏与取消全屏
- 开发提效之一键生成模块(页面)