Vue+redis全局添加水印解决方案

水印功能开发过程完整整理

以下是整个水印系统的开发过程,从安装包开始,到前端/后端实现、集成、调试。全程基于 Vue 2 + Element UI + Vuex + watermark-js-plus 前端,Spring Boot + Redisson (Redis) 后端。过程分步骤,包含关键代码和注意点。开发目标:动态模板水印 (e.g., "{showName} + {date}"),配置存 Redis,用户切换立即更新,未登录隐藏。

步骤1: 环境准备(安装依赖,如果你的环境没有安装过的话)
  • 前端 (Vue 项目)

    • 安装水印库:npm install watermark-js-plus (生成 canvas 水印,支持 gap/rotate 等)。
    • 安装 Element UI:npm install element-plus (UI 组件,如 el-switch, el-input-number)。
    • 安装 Vuex:npm install vuex@next (状态管理)。
    • 工具函数:创建 utils/sessionStorageUtil.js (你的 getObjectFromSessionStorage)。
  • 后端 (Spring Boot 项目)

    • 添加 Redisson 依赖 (pom.xml):

      xml 复制代码
      <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.25.3</version>
      </dependency>
    • 配置 application.yml:

      yaml 复制代码
      spring:
        redis:
          host: localhost
          port: 6379
    • 启动 Redis 服务 (docker run -p 6379:6379 redis)。

  • 注意:前端需 Axios 或 HTTP 工具 (你的 doUrl);后端需 ResponseResult/ErrorCodeEnum (自定义)。

步骤2: 后端开发 (Redis API)

创建 WaterMarkConfigController.java,支持 set/get/remove (永久存储,refresh 加 TTL)。DTO 内嵌。

java 复制代码
// WaterMarkConfigController.java
package com.management.webadmin.upms.controller;

import cn.hutool.core.util.StrUtil;
import com.management.common.core.constant.ErrorCodeEnum;
import com.management.common.core.object.ResponseResult;
import org.redisson.api.RBucket;
import org.redisson.api.RKeys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.TimeUnit;

class WaterMarkConfigRequest {
    private String key;
    private Object value;
    private String userId;

    // Getter 和 Setter (省略...)
}

class WaterMarkConfigResponse {
    private Object value;

    public WaterMarkConfigResponse(Object value) { this.value = value; }
    // Getter 和 Setter (省略...)
}

@RestController
@RequestMapping("/admin/upms/waterMarkConfig")
public class WaterMarkConfigController {

    @Autowired
    private RedissonClient redissonClient;

    // 保存配置
    @PostMapping("/set")
    public ResponseResult<Void> setConfig(@RequestBody WaterMarkConfigRequest request) {
        if (StrUtil.isBlank(request.getKey()) || request.getValue() == null) {
            return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
        }
        String key = "watermark:config:" + request.getUserId() + ":" + request.getKey();
        RBucket<Object> bucket = redissonClient.getBucket(key);
        bucket.set(request.getValue()); // 无过期,永久存储
        return ResponseResult.success(null);
    }

    // 刷新缓存 (覆盖 + TTL 24h)
    @PutMapping("/refresh/{key}")
    public ResponseResult<Void> refreshConfig(@PathVariable String key, @RequestBody WaterMarkConfigRequest request) {
        if (StrUtil.isBlank(key) || request.getValue() == null) {
            return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST);
        }
        String redisKey = "watermark:config:" + request.getUserId() + ":" + key;
        RBucket<Object> bucket = redissonClient.getBucket(redisKey);
        bucket.set(request.getValue(), 24, TimeUnit.HOURS); // 刷新:覆盖值 + TTL
        return ResponseResult.success(null);
    }

    // 获取配置
    @GetMapping("/get/{key}")
    public ResponseResult<WaterMarkConfigResponse> getConfig(@PathVariable String key, @RequestParam(required = false) String userId) {
        if (StrUtil.isBlank(userId)) userId = "default"; // fallback
        String redisKey = "watermark:config:" + userId + ":" + key;
        RBucket<Object> bucket = redissonClient.getBucket(redisKey);
        Object value = bucket.get();
        if (value == null) {
            return ResponseResult.success(new WaterMarkConfigResponse(null));
        }
        return ResponseResult.success(new WaterMarkConfigResponse(value));
    }

    // 移除配置
    @DeleteMapping("/remove/{key}")
    public ResponseResult<Void> removeConfig(@PathVariable String key, @RequestParam(required = false) String userId) {
        if (StrUtil.isBlank(userId)) userId = "default"; // fallback
        String redisKey = "watermark:config:" + userId + ":" + key;
        RBucket<Object> bucket = redissonClient.getBucket(redisKey);
        bucket.delete();
        return ResponseResult.success(null);
    }

    // 获取所有配置
    @GetMapping("/all")
    public ResponseResult<Map<String, Object>> getAllConfig(@RequestParam(required = false) String userId) {
        if (StrUtil.isBlank(userId)) userId = "default"; // fallback
        String pattern = "watermark:config:" + userId + ":*";
        RKeys keys = redissonClient.getKeys();
        Iterable<String> keyIterable = keys.getKeysByPattern(pattern);
        Map<String, Object> configMap = new HashMap<>();
        Iterator<String> iterator = keyIterable.iterator();
        while (iterator.hasNext()) {
            String k = iterator.next();
            String subKey = k.substring(("watermark:config:" + userId + ":").length());
            RBucket<Object> bucket = redissonClient.getBucket(k);
            configMap.put(subKey, bucket.get());
        }
        return ResponseResult.success(configMap);
    }
}
步骤3: 前端 API 封装 (WaterMarkConfigController.js)
javascript 复制代码
// api/Controller/WaterMarkConfigController.js
import { buildGetUrl } from '@/core/http/requestUrl.js';

export default class WaterMarkConfigController {
  // 保存配置 (POST /set)
  static set (sender, params, axiosOption, httpOption) {
    return sender.doUrl('/admin/upms/waterMarkConfig/set', 'post', params, axiosOption, httpOption);
  }

  // 获取配置 (GET /get/{key})
  static get (sender, params, axiosOption, httpOption) {
    const { key, userId } = params || {};
    if (!key) throw new Error('key is required');
    const url = `/admin/upms/waterMarkConfig/get/${key}`; // key 作为路径变量
    const queryParams = { userId: userId || localStorage.getItem('userId') || 'default' };
    return sender.doUrl(url, 'get', queryParams, axiosOption, httpOption);
  }

  // 移除配置 (DELETE /remove/{key})
  static remove (sender, params, axiosOption, httpOption) {
    const { key, userId } = params || {};
    if (!key) throw new Error('key is required');
    const url = `/admin/upms/waterMarkConfig/remove/${key}`;
    const queryParams = { userId: userId || localStorage.getItem('userId') || 'default' };
    return sender.doUrl(url, 'post', queryParams, axiosOption, httpOption); // 你的代码用 post 模拟 delete
  }

  // 获取所有配置 (GET /all)
  static all (sender, params, axiosOption, httpOption) {
    const { userId } = params || {};
    const queryParams = { userId: userId || localStorage.getItem('userId') || 'default' };
    return sender.doUrl('/admin/upms/waterMarkConfig/all', 'get', queryParams, axiosOption, httpOption);
  }

  // 构建打印 URL (备用)
  static printUrl (params) {
    return buildGetUrl('/admin/upms/waterMarkConfig/print', params);
  }
}
步骤4: Vuex Store (平坦结构,合并 watermark)

假设你的 store 是平坦的 (index.js),以下是完整合并水印逻辑的 index.js。

javascript 复制代码
// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state.js'; // 你的主 state
import getters from './getters.js'; // 你的主 getters
import mutations from './mutations.js'; // 你的主 mutations
import actions from './actions.js'; // 你的主 actions
import WaterMarkConfigController from '@/api/Controller/WaterMarkConfigController'; // watermark API

Vue.use(Vuex);

// 水印扩展 state (合并到主 state)
const watermarkState = {
  enabled: false,
  template: '{showName} + {loginName} + {date}',
  gap: 100,
  fontSize: 16,
  color: 'rgba(0,0,0,0.3)',
  rotate: -22
};

// 水印扩展 mutations (合并)
const watermarkMutations = {
  SET_WATERMARK_CONFIG(state, config) {
    Object.assign(state.watermark, config);
  },
  RESET_WATERMARK_CONFIG(state) {
    Object.assign(state.watermark, watermarkState);
  }
};

// 水印扩展 actions (合并)
const watermarkActions = {
  async loadWatermarkConfig({ commit }, { sender }) {
    try {
      const params = { key: 'watermarkConfig' };
      const res = await WaterMarkConfigController.get(sender, params);
      if (res && res.data) {
        commit('SET_WATERMARK_CONFIG', res.data);
      }
    } catch (error) {
      console.warn('加载水印配置失败');
    }
  },
  async saveWatermarkConfig({ commit }, { config, sender }) {
    try {
      const params = {
        key: 'watermarkConfig',
        value: config,
        userId: localStorage.getItem('userId') || 'default'
      };
      await WaterMarkConfigController.set(sender, params);
      commit('SET_WATERMARK_CONFIG', config);
    } catch (error) {
      console.error('保存水印配置失败');
    }
  },
  async resetWatermarkConfig({ commit }, { sender }) {
    try {
      const params = { key: 'watermarkConfig' };
      await WaterMarkConfigController.remove(sender, params);
      commit('RESET_WATERMARK_CONFIG');
    } catch (error) {
      console.error('重置水印配置失败');
    }
  }
};

// 合并主 store
const mergedState = { ...state, watermark: { ...watermarkState } };
const mergedMutations = { ...mutations, ...watermarkMutations };
const mergedActions = { ...actions, ...watermarkActions };

export default new Vuex.Store({
  state: mergedState,
  getters,
  mutations: mergedMutations,
  actions: mergedActions,
  strict: process.env.NODE_ENV !== 'production'
});

步骤5. Watermark.vue

javascript 复制代码
<template>
  <div v-if="visible" class="watermark-container" ref="watermark"></div>
</template>

<script>
import { Watermark } from 'watermark-js-plus';
import { mapState, mapGetters } from 'vuex';
import { watermark } from 'vxe-table';

export default {
  name: 'Watermark',
  computed: {
    ...mapGetters(['getUserInfo']), // 用户信息 getter
    ...mapState(['watermark']) // 从 watermark 模块获取 config
  },
  data() {
    return {
      instance: null,
      visible: false
    };
  },
  watch: {
    getUserInfo(newInfo) {
      // 监听用户信息变化
      console.log('User info changed:', newInfo); // 调试日志
      console.log('User info changed:', this.watermark); // 调试日志

      if (this.visible && this.watermark?.template) {
        // 只有在水印可见且配置了模板时才更新
        this.$nextTick(() => this.updateWatermark(this.watermark));
      }
    },
    watermark: {
      handler(newConfig) {
        console.log('Watermark config changed:', newConfig); // 调试日志
        this.visible = newConfig?.enabled || false;
        if (this.visible && newConfig?.template) {
          this.$nextTick(() => this.updateWatermark(newConfig));
        } else {
          this.destroyWatermark();
        }
      },
      deep: true,
      immediate: true
    }
  },
  mounted() {
    window.addEventListener('resize', this.handleResize);
  },
  beforeUnmount() {
    window.removeEventListener('resize', this.handleResize);
    this.destroyWatermark();
  },
  methods: {
    // 渲染模板:替换占位符
    renderTemplate(template, userInfo) {
      if (!template) return '默认水印';
      if (!userInfo.loginName || !userInfo.showName) return '默认水印';
      let rendered = template;
      const date = new Date()
        .toLocaleString('zh-CN', {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit',
          second: '2-digit'
        })
        .replace(/\//g, '-');
      rendered = rendered.replace(/\{showName\}/g, userInfo.showName || '未知用户');
      rendered = rendered.replace(/\{loginName\}/g, userInfo.loginName || '未知登录名');
      rendered = rendered.replace(/\{date\}/g, date);
      return rendered;
    },
    updateWatermark(config) {
      this.destroyWatermark(); // 先销毁旧实例

      const container = this.$refs.watermark;
      if (!container) return;

      // 清空容器
      container.innerHTML = '';

      // 获取用户数据(fallback 空对象)
      const userInfo = this.getUserInfo || {};
      const text = this.renderTemplate(config.template, userInfo); // 动态替换
      console.log('Rendered watermark text:', text); // 调试日志

      if (!text || text === '默认水印') {
        // 空文本不渲染
        console.warn('水印文本为空,跳过渲染');
        return;
      }

      // 创建水印实例
      this.instance = new Watermark({
        container: container,
        content: text,
        width: config.gap * 2,
        height: config.gap * 2,
        fontSize: config.fontSize,
        color: config.color,
        rotate: config.rotate,
        gap: config.gap,
        onSuccess: () => {
          container.style.position = 'fixed';
          container.style.top = '0';
          container.style.left = '0';
          container.style.width = '100vw';
          container.style.height = '100vh';
          container.style.pointerEvents = 'none';
          container.style.zIndex = '9999';
        }
      });

      this.instance.create(); // 生成水印
    },
    destroyWatermark() {
      if (this.instance) {
        this.instance.destroy();
        this.instance = null;
      }
    },
    handleResize() {
      if (this.instance) {
        this.instance.create(); // 重绘
      }
    }
  }
};
</script>

<style scoped>
.watermark-container {
  position: fixed !important;
  top: 0 !important;
  left: 0 !important;
  width: 100vw !important;
  height: 100vh !important;
  z-index: 9999 !important;
  pointer-events: none !important;
}
</style>

步骤六. App.vue

javascript 复制代码
<template>
  <div id="app">
    <router-view></router-view>
    <!-- 全局水印:覆盖所有页面 -->
    <watermark />
    <!-- 无需传 config,已从 store 获取 -->
  </div>
</template>

<script>
import Watermark from '@/components/Watermark/index.vue'; // 假设路径
import projectConfig from '@/core/config'; // 假设项目配置
import { mapActions } from 'vuex';

export default {
  name: 'App',
  components: { Watermark },
  mounted() {
    // 加载水印配置(从 Redis/Store)
    this.loadWatermarkConfig({ sender: this }); // 传递 sender = this 用于 API 调用
  },
  methods: {
    ...mapActions(['loadWatermarkConfig']) // 映射 loadWatermarkConfig 方法
  },
  watch: {
    $route: {
      handler(newValue) {
        document.title = projectConfig.projectName;
        if (newValue.meta && newValue.meta.title) document.title += ' - ' + newValue.meta.title;
      },
      immediate: true
    }
  }
};
</script>

步骤7.实现水印管理(动态设置水印格式,颜色,大小,倾斜度)

javascript 复制代码
<template>
  <div class="watermark-config">
    <el-card class="box-card">
      <div slot="header" class="clearfix"></div>
      <el-form label-width="120px">
        <el-form-item label="水印开关">
          <el-switch
            v-model="localConfig.enabled"
            active-color="#13ce66"
            inactive-color="#ff4949"
            @change="handleSwitchChange"
          ></el-switch>
        </el-form-item>
        <el-form-item label="水印模板">
          <el-input
            v-model="localConfig.template"
            placeholder="输入模板,如 {showName} + {loginName} 或 {showName} + {date}"
            maxlength="100"
            @input="previewTemplate"
          />
          <small class="el-form-item__small"
            >支持占位符:{showName}(用户名)、{loginName}(登录名)、{date}(日期)</small
          >
        </el-form-item>
        <el-form-item label="水印间距">
          <el-input-number v-model="localConfig.gap" :min="50" :max="300" />
        </el-form-item>
        <el-form-item label="字体大小">
          <el-input-number v-model="localConfig.fontSize" :min="10" :max="30" />
        </el-form-item>
        <el-form-item label="水印颜色">
          <el-color-picker v-model="localConfig.color" show-alpha />
        </el-form-item>
        <el-form-item label="旋转角度">
          <el-input-number v-model="localConfig.rotate" :min="-90" :max="0" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="saveConfig">保存配置</el-button>
          <el-button @click="resetConfig">重置</el-button>
          <el-button @click="previewTemplate">预览</el-button>
        </el-form-item>
        <el-form-item v-if="previewText" label="预览文本">
          <el-tag type="info">{{ previewText }}</el-tag>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script>
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'; // 只导入需要的
import RedisStorageUtil from '@/api/Controller/WaterMarkConfigController'; // 导入类

export default {
  name: 'WatermarkConfig',
  data() {
    return {
      localConfig: {
        // 本地编辑副本
        enabled: false,
        template: '{showName} + {loginName} + {date}',
        gap: 100,
        fontSize: 16,
        color: 'rgba(0,0,0,0.3)',
        rotate: -22
      },
      previewText: '',
      userInfo: {}
    };
  },
  computed: {
    ...mapGetters(['getUserInfo']), // 从 store 获取用户信息
    ...mapState(['watermark']) // 从 store 获取当前配置
  },
  mounted() {
    this.loadUserInfo();
    this.initLocalConfig();
    this.previewTemplate();
    // 加载 store 配置
    this.loadConfig({ sender: this });
  },
  methods: {
    ...mapMutations([
      'SET_CONFIG', // 直接使用 store 的 mutation
      'RESET_CONFIG' // 直接使用 store 的 mutation
    ]),
    ...mapActions([
      'loadConfig' // 直接使用 store 的 action
    ]),
    // 加载用户信息
    loadUserInfo() {
      this.userInfo = this.getUserInfo;
    },
    // 初始化本地配置
    initLocalConfig() {
      this.localConfig = { ...this.watermark }; // 从 store 复制到本地
    },
    // 渲染模板:替换占位符
    renderTemplate(template, userInfo) {
      if (!template) return '默认水印';
      let rendered = template;
      const date = new Date()
        .toLocaleString('zh-CN', {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit',
          second: '2-digit'
        })
        .replace(/\//g, '-');
      rendered = rendered.replace(/\{showName\}/g, userInfo.showName || '未知用户');
      rendered = rendered.replace(/\{loginName\}/g, userInfo.loginName || '未知登录名');
      rendered = rendered.replace(/\{date\}/g, date);
      return rendered;
    },
    // 预览模板
    previewTemplate() {
      this.previewText = this.renderTemplate(this.localConfig.template, this.userInfo);
    },
    // 开关变化
    handleSwitchChange(enabled) {
      this.localConfig.enabled = enabled;
      this.SET_CONFIG(this.localConfig);
      this.previewTemplate();
    },
    // 保存配置
    async saveConfig() {
      try {
        const params = {
          key: 'watermarkConfig',
          value: this.localConfig,
        };
        await RedisStorageUtil.set(this, params); // sender = this
        this.SET_CONFIG(this.localConfig);
        this.previewTemplate();
        this.$message.success('配置保存成功');
        // 重新加载配置
        this.loadConfig({ sender: this });
      } catch (error) {
        this.$message.error('保存失败');
        console.error('保存错误:', error);
      }
    },
    // 重置配置
    async resetConfig() {
      try {
        const params = {
          key: 'watermarkConfig',
        };
        await RedisStorageUtil.remove(this, params); // sender = this
        this.RESET_CONFIG(); // 重置 store
        this.initLocalConfig();
        this.previewTemplate();
        this.$message.info('已重置');
      } catch (error) {
        this.$message.error('重置失败');
        console.error('重置错误:', error);
      }
    }
  }
};
</script>

<style scoped>
.box-card {
  max-width: 600px;
  margin: 20px auto;
}
.el-form-item__small {
  color: #909399;
  font-size: 12px;
  line-height: 18px;
}
</style>

其他注意

  • Vuex :Vue 2 用 Vue.use(Vuex),store = new Vuex.Store(...)。

  • 生命周期:mounted() 替换 onMounted, beforeDestroy 替换 beforeUnmount。

  • $nextTick:Vue 2 相同,用于 DOM 更新后执行。

  • main.js :Vue 2 用 new Vue({ store, router }).$mount('#app')

    javascript 复制代码
    // main.js (Vue 2)
    import Vue from 'vue';
    import App from './App.vue';
    import store from './store';
    import router from './router';
    import ElementUI from 'element-ui'; // Vue 2 Element UI
    
    Vue.use(Vuex);
    Vue.use(ElementUI);
    
    new Vue({
      store,
      router,
      render: h => h(App)
    }).$mount('#app');

测试

  • 刷新页面,console "config changed",水印显示。
  • 切换用户:更新 sessionStorage 'userInfo',watch getUserInfo 触发,重绘。

Vue 2 版本稳定!如果 "beforeDestroy" 报错,确认 Vue 2.6+。

相关推荐
lecepin3 小时前
AI Coding 资讯 2025-10-29
前端·后端·面试
余道各努力,千里自同风3 小时前
小程序中获取元素节点
前端·小程序
PineappleCoder3 小时前
大模型也栽跟头的 Promise 题!来挑战一下?
前端·面试·promise
非凡ghost3 小时前
MousePlus(鼠标增强工具) 中文绿色版
前端·windows·计算机外设·软件需求
焚 城3 小时前
EXCEL(带图)转html【uni版】
前端·html·excel
我家媳妇儿萌哒哒3 小时前
Vue2 elementUI年份区间选择组件
前端·javascript·elementui
笨笨狗吞噬者3 小时前
【uniapp】小程序体积优化,分包异步化
前端·微信小程序·uni-app
该用户已不存在3 小时前
Golang 上传文件到 MinIO?别瞎折腾了,这 5 个库拿去用
前端·后端·go
Deamon Tree3 小时前
Redis的过期策略以及内存淘汰机制
java·数据库·redis·缓存