YGG-CLI-10-水印切换的设计与开发

一个文笔一般,想到哪是哪的唯心论前端小白。

🧠 - 简介

做完了才发现 element-plus 已经有了水印组件了 . . .

如下是配置相关参数:

仔细阅读了一下,发现跟我做的还是有点区别的:

既然做都做了,那就简单分享一下我的思路吧!

通过简单的比较,可以看到,我的这个水印呢,感觉更适合业务人员去使用,而 element-plus 则一改原来的 mini|small|large,而变成了具体的值来控制。另一方面系统预设了一些内容,更方便私有化场景下定制使用。其实我的整个模版项目都是基于 element-plus 做的,这个水印如果需要增加表单配置,可能还需要二次开发。

👁️ - 分析

水印的作用主要防止用户对网页进行截图,然后拿来做有损平台利益的事情,所以做出水印是第一步,下一步则是防止用户用一些手段把水印干掉!

所以,社区大佬也有很多的新颖的设计,来防止用户破解。例如常见的:

  1. 用户找到水印层,使用 display: none,来将水印干掉。对应方法:使用肉眼捕捉不到的频率来重置水印。
  2. 用户通过先将页面缩放到很小的级别,刷新水印变成一个小点,然后再放大回来。对应方法:监听浏览器变化,重置水印。
  3. 用户直接找到水印层,删除dom节点。对应方法:跟1一样,也是自己来刷新重置。
  4. xxx...

水印层制作

水印的核心是一个css样式:[MDN] pointer-events

然后就是具体实现一个蒙版层,画水印

我知道的绘制思路如下:

  1. 使用 div 进行绘制
    • 一个大的div,里面使用栈格布局的方式,绘制多个小div,实现满屏的水印
    • 好多个小div,使用绝对定位的方式,计算每一个小div的位置,实现满屏的小水印,心情好了还可以让他们飞舞起来哟
  2. 使用canvas进行绘制
    • 一个 canvas 绘制,设计一个矩阵的绘制方法,然后使用两层循环进行绘制,
    • 两个 canvas 绘制,一个 canvas 用来根据参数绘制单个水印,在另一个水印里面把这个canvas的绘制结果铺满整个画布
    • canvas 配合 div,canvs 生成一个水印,然后 div 使用背景图片的方式实现水印
  3. 使用 svg 绘制

安全性分析

在我的认知里,安全性跟前端的关系不是特别大。😂😂😂

为什么这么说呢,前端的安全性分为两块:

  1. 数据安全
    • 文件:最终是通过数据库进行下载的,所以前端无法把控,只要接口安全性够高,站在浏览器的角度是无从下手的,但凡接口有漏洞可钻,前端也是很难拦截的。
    • 数据:数据安全方面,前端只能阻止浏览器默认的复制粘贴行为,但是用户还是可以通过页面审查进行复制的,更有甚者使用爬虫,不胜其烦。
  2. 界面安全:主要是拦截用户截图和录屏的操作了,所以前端就出现了水印这个设计。然而水印这个设计就很尴尬,如果做的很安全(防止用户所有的隐藏),必然会很考验用户的设备,还要考虑到浏览器的兼容性。

我理解的安全性,是针对非专业人员来说的。如果要针对黑客级别的,那就只有报警了,开玩笑 🫣🫣🫣,谁家正经黑客,截你的屏还去水印啊 ~ ~ ~

🫀 - 拆解

言归正传,开搞 ~~~

首先是页面设计,其实就是一个抽屉,里面放了个表单:

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:对外暴露,总调度函数

整个流程就是:

  1. 在 new 阶段,会把 canvas 放进 body 里面,并挂载到 this 上,方便后续使用
  2. 在页面中会读取上次的水印配置,如果有的话,会执行上次的水印配置哦!
  3. 读取到配置,则配置参数进行预处理,然后返回一个 isOpen 的状态,如果为 true 则继续绘制
  4. 在绘制水印方法里面将画布 矩阵化,然后每一个节点根据水印类型,分别绘制文本和图片

是不是很简单?

至于前文提到的水印安全问题,我只用到了:

ts 复制代码
window.addEventListener('resize',useThrottleFn(() => this.applyWatermark(this.config), 500))

来监听浏览器变化,进行重新绘制水印。至于用户如果用display或者删除节点的方法去破解,再加个定时器去重复的执行这个方法就好了。对了,还要把 initCanvas 也挪进来。

为了延长我的小破本的寿命,所以我就把这段逻辑略过啦!

🛀 - 总结

水印功能不复杂,只是做一个工具,方便日后使用 ~ ~ ~

唯一的缺陷就是,如果到了没有引入这个水印的页面,水印加不上,但是无伤大雅呀,自己解决喽!

系列文章:

  1. 脚手架开发
  2. 模板项目初始化
  3. 模板项目开发规范与设计思路
  4. layout设计与开发
  5. login 设计与开发
  6. CURD页面的设计与开发
  7. 监控页面的设计与开发
  8. 富文本编辑器的使用与页面设开发设计
  9. 主题切换的设计与开发并页面
  10. 水印切换的设计与开发
  11. 全屏与取消全屏
  12. 开发提效之一键生成模块(页面)
相关推荐
小马哥编程1 小时前
Function.prototype和Object.prototype 的区别
javascript
小白学前端6661 小时前
React Router 深入指南:从入门到进阶
前端·react.js·react
苹果醋31 小时前
React系列(八)——React进阶知识点拓展
运维·vue.js·spring boot·nginx·课程设计
web130933203981 小时前
前端下载后端文件流,文件可以下载,但是打不开,显示“文件已损坏”的问题分析与解决方案
前端
王小王和他的小伙伴1 小时前
解决 vue3 中 echarts图表在el-dialog中显示问题
javascript·vue.js·echarts
学前端的小朱1 小时前
处理字体图标、js、html及其他资源
开发语言·javascript·webpack·html·打包工具
outstanding木槿1 小时前
react+antd的Table组件编辑单元格
前端·javascript·react.js·前端框架
好名字08212 小时前
前端取Content-Disposition中的filename字段与解码(vue)
前端·javascript·vue.js·前端框架
摇光932 小时前
js高阶-async与事件循环
开发语言·javascript·事件循环·宏任务·微任务
隐形喷火龙2 小时前
element ui--下拉根据拼音首字母过滤
前端·vue.js·ui