Watermark.vue是水印组件,用于在页面或指定区域添加水印效果,提供安全防护和版权保护功能。
文件位置: src/components/common/Watermark.vue
- 文字水印
- 图片水印
- 自定义样式
- 防删除保护
- 响应式布局
- 多种排列方式
<template>
<div class="watermark-container" ref="containerRef">
<!-- 内容插槽 -->
<slot></slot>
<!-- 水印层 -->
<div
ref="watermarkRef"
class="watermark-layer"
:style="watermarkStyle"
v-show="visible"
>
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
class="watermark-canvas"
></canvas>
</div>
</div>
</template><script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
// Props定义
const props = defineProps({
// 水印文字
text: {
type: String,
default: 'Watermark'
},
// 水印图片URL
image: {
type: String,
default: ''
},
// 水印宽度
width: {
type: Number,
default: 200
},
// 水印高度
height: {
type: Number,
default: 100
},
// 水印透明度
opacity: {
type: Number,
default: 0.1,
validator: (value) => value >= 0 && value <= 1
},
// 水印旋转角度
rotate: {
type: Number,
default: -20
},
// 字体大小
fontSize: {
type: Number,
default: 16
},
// 字体颜色
fontColor: {
type: String,
default: '#000000'
},
// 字体样式
fontStyle: {
type: String,
default: 'normal'
},
// 字体粗细
fontWeight: {
type: [String, Number],
default: 'normal'
},
// 字体族
fontFamily: {
type: String,
default: 'Arial, sans-serif'
},
// 水印间距X
gapX: {
type: Number,
default: 100
},
// 水印间距Y
gapY: {
type: Number,
default: 100
},
// 水印偏移X
offsetX: {
type: Number,
default: 0
},
// 水印偏移Y
gapY: {
type: Number,
default: 0
},
// 层级
zIndex: {
type: Number,
default: 1000
},
// 是否显示
visible: {
type: Boolean,
default: true
},
// 是否启用防删除保护
monitor: {
type: Boolean,
default: true
}
})
// 响应式数据
const containerRef = ref(null)
const watermarkRef = ref(null)
const canvasRef = ref(null)
const canvasWidth = ref(0)
const canvasHeight = ref(0)
const observer = ref(null)
// 计算属性
const watermarkStyle = computed(() => ({
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: props.zIndex,
opacity: props.opacity,
backgroundImage: `url(${getWatermarkDataUrl()})`,
backgroundRepeat: 'repeat',
backgroundPosition: `${props.offsetX}px ${props.offsetY}px`
}))
// 字体样式
const fontStyle = computed(() => {
return `${props.fontStyle} ${props.fontWeight} ${props.fontSize}px ${props.fontFamily}`
})
</script>// 生成水印DataURL
const getWatermarkDataUrl = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// 设置画布尺寸
const canvasWidth = props.width + props.gapX
const canvasHeight = props.height + props.gapY
canvas.width = canvasWidth
canvas.height = canvasHeight
// 设置画布样式
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = fontStyle.value
ctx.fillStyle = props.fontColor
// 保存当前状态
ctx.save()
// 移动到画布中心
ctx.translate(canvasWidth / 2, canvasHeight / 2)
// 旋转画布
ctx.rotate((props.rotate * Math.PI) / 180)
if (props.image) {
// 绘制图片水印
drawImageWatermark(ctx)
} else {
// 绘制文字水印
drawTextWatermark(ctx)
}
// 恢复状态
ctx.restore()
return canvas.toDataURL()
}
// 绘制文字水印
const drawTextWatermark = (ctx) => {
const lines = props.text.split('\n')
const lineHeight = props.fontSize * 1.2
const totalHeight = lines.length * lineHeight
const startY = -totalHeight / 2 + lineHeight / 2
lines.forEach((line, index) => {
const y = startY + index * lineHeight
ctx.fillText(line, 0, y)
})
}
// 绘制图片水印
const drawImageWatermark = (ctx) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const imgWidth = Math.min(img.width, props.width)
const imgHeight = Math.min(img.height, props.height)
ctx.drawImage(
img,
-imgWidth / 2,
-imgHeight / 2,
imgWidth,
imgHeight
)
// 更新水印
updateWatermark()
}
img.onerror = () => {
console.error('水印图片加载失败:', props.image)
// 降级到文字水印
drawTextWatermark(ctx)
}
img.src = props.image
}
// 更新水印
const updateWatermark = () => {
if (!watermarkRef.value) return
const dataUrl = getWatermarkDataUrl()
watermarkRef.value.style.backgroundImage = `url(${dataUrl})`
}// 更新容器尺寸
const updateContainerSize = () => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
canvasWidth.value = rect.width
canvasHeight.value = rect.height
// 更新水印
nextTick(() => {
updateWatermark()
})
}
// 监听容器尺寸变化
const observeContainer = () => {
if (!containerRef.value || !window.ResizeObserver) return
observer.value = new ResizeObserver((entries) => {
for (const entry of entries) {
updateContainerSize()
}
})
observer.value.observe(containerRef.value)
}
// 停止监听
const unobserveContainer = () => {
if (observer.value && containerRef.value) {
observer.value.unobserve(containerRef.value)
observer.value.disconnect()
observer.value = null
}
}// 防删除监听器
const mutationObserver = ref(null)
// 启用防删除保护
const enableMonitor = () => {
if (!props.monitor || !watermarkRef.value) return
mutationObserver.value = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// 检查水印元素是否被删除或修改
if (mutation.type === 'childList') {
const removedNodes = Array.from(mutation.removedNodes)
const hasWatermarkRemoved = removedNodes.some(node =>
node === watermarkRef.value ||
(node.nodeType === Node.ELEMENT_NODE && node.contains(watermarkRef.value))
)
if (hasWatermarkRemoved) {
console.warn('水印被删除,正在恢复...')
restoreWatermark()
}
}
// 检查水印样式是否被修改
if (mutation.type === 'attributes' && mutation.target === watermarkRef.value) {
if (mutation.attributeName === 'style') {
console.warn('水印样式被修改,正在恢复...')
restoreWatermark()
}
}
})
})
// 监听容器和水印元素
if (containerRef.value) {
mutationObserver.value.observe(containerRef.value, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
})
}
}
// 禁用防删除保护
const disableMonitor = () => {
if (mutationObserver.value) {
mutationObserver.value.disconnect()
mutationObserver.value = null
}
}
// 恢复水印
const restoreWatermark = () => {
nextTick(() => {
if (containerRef.value && !containerRef.value.contains(watermarkRef.value)) {
// 重新添加水印元素
containerRef.value.appendChild(watermarkRef.value)
}
// 恢复水印样式
if (watermarkRef.value) {
Object.assign(watermarkRef.value.style, watermarkStyle.value)
}
updateWatermark()
})
}// 监听props变化
watch([
() => props.text,
() => props.image,
() => props.width,
() => props.height,
() => props.opacity,
() => props.rotate,
() => props.fontSize,
() => props.fontColor,
() => props.fontStyle,
() => props.fontWeight,
() => props.fontFamily,
() => props.gapX,
() => props.gapY,
() => props.offsetX,
() => props.offsetY
], () => {
updateWatermark()
}, { deep: true })
// 监听可见性变化
watch(() => props.visible, (newValue) => {
if (watermarkRef.value) {
watermarkRef.value.style.display = newValue ? 'block' : 'none'
}
})
// 监听防删除保护开关
watch(() => props.monitor, (newValue) => {
if (newValue) {
enableMonitor()
} else {
disableMonitor()
}
})
// 组件挂载
onMounted(() => {
nextTick(() => {
updateContainerSize()
observeContainer()
if (props.monitor) {
enableMonitor()
}
})
})
// 组件卸载
onBeforeUnmount(() => {
unobserveContainer()
disableMonitor()
})
// 暴露方法
defineExpose({
updateWatermark,
updateContainerSize
}).watermark-container {
position: relative;
width: 100%;
height: 100%;
}
.watermark-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
background-repeat: repeat;
}
.watermark-canvas {
display: none;
}.watermark-layer {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-drag: none;
-moz-user-drag: none;
-ms-user-drag: none;
user-drag: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}<template>
<Watermark text="机密文档" :opacity="0.1">
<div class="content">
<h1>文档标题</h1>
<p>这里是文档内容...</p>
</div>
</Watermark>
</template>
<script setup>
import Watermark from '@/components/common/Watermark.vue'
</script><template>
<Watermark
text="公司名称\n版权所有\n2025"
:font-size="14"
font-color="#cccccc"
:rotate="-30"
:gap-x="150"
:gap-y="120"
>
<div class="document">
<!-- 文档内容 -->
</div>
</Watermark>
</template><template>
<Watermark
image="/logo.png"
:width="100"
:height="50"
:opacity="0.15"
:gap-x="200"
:gap-y="150"
>
<div class="content">
<!-- 内容区域 -->
</div>
</Watermark>
</template><template>
<div>
<div class="controls">
<el-input v-model="watermarkText" placeholder="水印文字" />
<el-slider v-model="opacity" :min="0" :max="1" :step="0.1" />
<el-slider v-model="rotate" :min="-90" :max="90" />
</div>
<Watermark
:text="watermarkText"
:opacity="opacity"
:rotate="rotate"
:monitor="true"
>
<div class="content">
<h2>受保护的内容</h2>
<p>这个内容受到水印保护</p>
</div>
</Watermark>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Watermark from '@/components/common/Watermark.vue'
const watermarkText = ref('保密文档')
const opacity = ref(0.1)
const rotate = ref(-20)
</script><template>
<Watermark
:text="userWatermark"
:font-size="12"
font-color="#999999"
:opacity="0.08"
:gap-x="180"
:gap-y="100"
:monitor="true"
>
<div class="sensitive-content">
<!-- 敏感内容 -->
</div>
</Watermark>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import Watermark from '@/components/common/Watermark.vue'
const userStore = useUserStore()
const userWatermark = computed(() => {
const user = userStore.userInfo
const now = new Date()
return `${user.username}\n${user.realName}\n${now.toLocaleString()}`
})
</script><template>
<Watermark
text="内部文档"
:visible="showWatermark"
:monitor="isProduction"
>
<div class="document">
<!-- 文档内容 -->
</div>
</Watermark>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const showWatermark = computed(() => {
// 根据用户角色决定是否显示水印
return userStore.hasRole('guest') || userStore.hasRole('viewer')
})
const isProduction = computed(() => {
return process.env.NODE_ENV === 'production'
})
</script>// 创建渐变水印
const createGradientWatermark = (ctx) => {
const gradient = ctx.createLinearGradient(0, 0, props.width, 0)
gradient.addColorStop(0, 'rgba(255, 0, 0, 0.3)')
gradient.addColorStop(0.5, 'rgba(0, 255, 0, 0.3)')
gradient.addColorStop(1, 'rgba(0, 0, 255, 0.3)')
ctx.fillStyle = gradient
ctx.fillText(props.text, 0, 0)
}
// 创建阴影水印
const createShadowWatermark = (ctx) => {
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 4
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
ctx.fillStyle = props.fontColor
ctx.fillText(props.text, 0, 0)
}// 简单的文字编码
const encodeText = (text) => {
return btoa(encodeURIComponent(text))
}
const decodeText = (encodedText) => {
try {
return decodeURIComponent(atob(encodedText))
} catch (e) {
return encodedText
}
}- 性能优化: 大量水印时注意Canvas性能
- 浏览器兼容: 某些老版本浏览器可能不支持部分API
- 安全性: 水印只是视觉保护,不能完全防止内容被复制
- 响应式: 确保水印在不同屏幕尺寸下正常显示
- 可访问性: 水印不应影响内容的可读性
最后更新时间:2025-09-19