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

206 阅读9分钟

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

🧠 - 简介

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

如下是配置相关参数: image.png

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

image.png

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

通过简单的比较,可以看到,我的这个水印呢,感觉更适合业务人员去使用,而 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. 界面安全:主要是拦截用户截图和录屏的操作了,所以前端就出现了水印这个设计。然而水印这个设计就很尴尬,如果做的很安全(防止用户所有的隐藏),必然会很考验用户的设备,还要考虑到浏览器的兼容性。

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

🫀 - 拆解

言归正传,开搞 ~~~

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

<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,在里面随心所欲的绘制水印,理论上是可以绘制各种各样的水印的。

直接上代码:

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. 在绘制水印方法里面将画布 矩阵化,然后每一个节点根据水印类型,分别绘制文本和图片

是不是很简单?

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

window.addEventListener('resize',useThrottleFn(() => this.applyWatermark(this.config), 500))

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

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

image.png

🛀 - 总结

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

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

系列文章:

  1. 脚手架开发
  2. 模板项目初始化
  3. 模板项目开发规范与设计思路
  4. layout设计与开发
  5. login 设计与开发
  6. CURD页面的设计与开发
  7. 监控页面的设计与开发
  8. 富文本编辑器的使用与页面设开发设计
  9. 主题切换的设计与开发并页面
  10. 水印切换的设计与开发
  11. 全屏与取消全屏
  12. 开发提效之一键生成模块(页面)