水印功能开发过程完整整理
以下是整个水印系统的开发过程,从安装包开始,到前端/后端实现、集成、调试。全程基于 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:
yamlspring: 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+。