From ff76997ac2a01815f510085b9b35f589f565c36a Mon Sep 17 00:00:00 2001 From: xuhuajiao <13476289682@163.com> Date: Tue, 22 Sep 2026 13:59:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B0=B4=E5=8D=B0=E7=AE=A1=E7=90=86=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/waterMark.js | 23 ++-- src/views/preview/index.vue | 146 ++++++++++++++++++--- src/views/system/watermarkManage/index.vue | 7 +- 3 files changed, 147 insertions(+), 29 deletions(-) diff --git a/src/utils/waterMark.js b/src/utils/waterMark.js index 30cb5cd..66f02ed 100644 --- a/src/utils/waterMark.js +++ b/src/utils/waterMark.js @@ -130,7 +130,8 @@ export const watermark = (settings, className, type) => { mask_div.style.position = defaultSettings.watermark_position mask_div.style.left = x + 'px' mask_div.style.top = y + 'px' - mask_div.style.overflow = 'hidden' + mask_div.style.overflow = 'visible' + mask_div.style.whiteSpace = 'nowrap' mask_div.style.zIndex = '9999' mask_div.style.pointerEvents = 'none' // pointer-events:none 让水印不遮挡页面的点击事件 mask_div.style.opacity = defaultSettings.watermark_alpha @@ -142,18 +143,20 @@ export const watermark = (settings, className, type) => { mask_div.style.height = defaultSettings.watermark_height + 'px' mask_div.style.display = 'block' oTemp.appendChild(mask_div) - - const parent = document.getElementsByClassName(className)[0] - const child = parent.getElementsByClassName('mask_div') - if (child.length > 0) { - for (let i = 0; i < child.length; i++) { - parent.removeChild(child[i]) - } - } - parent.appendChild(oTemp.cloneNode(true)) } } } + // 文本类型:循环结束后统一清除旧水印再渲染,避免循环内重复 clone 导致水印叠加、颜色深浅不一 + if (type !== 'img') { + const parents = document.getElementsByClassName(className) + for (let p = 0; p < parents.length; p++) { + const child = parents[p].getElementsByClassName('mask_div') + for (let k = child.length - 1; k >= 0; k--) { + parents[p].removeChild(child[k]) + } + parents[p].appendChild(oTemp.cloneNode(true)) + } + } // document.body.appendChild(oTemp) } diff --git a/src/views/preview/index.vue b/src/views/preview/index.vue index 71e9f91..ba727a2 100644 --- a/src/views/preview/index.vue +++ b/src/views/preview/index.vue @@ -118,7 +118,11 @@ export default { fileCurrent: null, fileParentInfo: null, pdfsArray: [], - documentId: null + documentId: null, + // 水印防篡改:MutationObserver + 定时兜底 + watermarkObserver: null, + watermarkTimer: null, + watermarkRerenderLock: false } }, computed: { @@ -160,6 +164,9 @@ export default { } this.getBase64ByFileId() }, + beforeDestroy() { + this.stopWatermarkGuard() + }, methods: { getFileSize(fileSize) { // 1. 先将接口返回的KB值转为数字,处理非数字/空值情况 @@ -240,7 +247,6 @@ export default { 'bucketType': 2 } FetchMinioReDocumentBase64ByFileId(params).then(res => { - console.log('res', res) var base64String = res var fileName = this.fileCurrent && this.fileCurrent.file_name var mimeType = this.fileCurrent && this.fileCurrent.file_type @@ -284,39 +290,149 @@ export default { }) }, getWatermark() { - getInitWatermark().then((res) => { + return getInitWatermark().then((res) => { this.watermarkInfo = res - // this.isEnable = this.watermarkInfo.isEnable - if (res.watermarkType === '1') { watermark({ watermark_txt: this.watermarkInfo.context, watermark_alpha: parseFloat(this.watermarkInfo.transparency) / 100 }, 'water-mask', 'img') } else if (res.watermarkType === '0') { - // if (parent.length > 0) { - // for (let i = 0; i < parent.length; i++) { - // const childImg = parent[i].getElementsByClassName('weterbox') - // if (childImg.length > 0) { - // for (let i = 0; i < childImg.length; i++) { - // parent[i].removeChild(childImg[i]) - // } - // } - // } - // } watermark({ watermark_txt: this.watermarkInfo.context, watermark_color: this.watermarkInfo.transparency, watermark_fontsize: this.watermarkInfo.watermarkFont + 'px' }, 'water-mask', 'text') + // 第二层:canvas 水印,独立于 DOM 水印,增加绕过难度 + this.drawCanvasWatermark() } if (document.getElementById('error-tip')) { document.getElementsByClassName('water-mask')[0].innerHTML = '' } + // 首次渲染后启动防篡改守卫(幂等:已存在则不重复启动) + if (!this.watermarkObserver) { + this.startWatermarkGuard() + } }).catch(err => { console.log(err) }) }, + // canvas 层水印:用 canvas API 平铺绘制,与 DOM 水印独立,用户需同时隐藏两层 + drawCanvasWatermark() { + if (!this.watermarkInfo) return + const container = document.querySelector('.water-mask') + if (!container) return + const oldCanvas = container.querySelector('canvas.wm-canvas') + if (oldCanvas) oldCanvas.remove() + const txt = this.watermarkInfo.context || '' + if (!txt) return + const canvas = document.createElement('canvas') + canvas.className = 'wm-canvas' + canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:1;' + container.appendChild(canvas) + const dpr = window.devicePixelRatio || 1 + const rect = container.getBoundingClientRect() + if (rect.width === 0 || rect.height === 0) return + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + ctx.scale(dpr, dpr) + ctx.font = `${this.watermarkInfo.watermarkFont || 15}px 微软雅黑` + ctx.fillStyle = this.watermarkInfo.watermarkType === '0' ? (this.watermarkInfo.transparency || '#000') : '#000' + ctx.globalAlpha = 0.12 + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + const stepX = 180 + const stepY = 120 + for (let y = -stepY; y < rect.height + stepY; y += stepY) { + for (let x = -stepX; x < rect.width + stepX; x += stepX) { + ctx.save() + ctx.translate(x, y) + ctx.rotate(-15 * Math.PI / 180) + ctx.fillText(txt, 0, 0) + ctx.restore() + } + } + }, + // 启动防篡改守卫:MutationObserver 监听 DOM 篡改 + 定时兜底检查 + startWatermarkGuard() { + this.stopWatermarkGuard() + const container = document.querySelector('.water-mask') + if (!container) return + this.watermarkObserver = new MutationObserver((mutations) => { + if (this.watermarkRerenderLock) return + let needRerender = false + for (const m of mutations) { + if (m.type === 'childList' && m.removedNodes.length > 0) { + needRerender = true + break + } + if (m.type === 'attributes' && m.target && m.target.style) { + const s = m.target.style + if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) { + needRerender = true + break + } + } + } + if (needRerender) this.rerenderWatermark() + }) + this.watermarkObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'] + }) + // 定时兜底:observer 监听不到容器自身被隐藏的情况,用定时器补位 + this.watermarkTimer = setInterval(() => { + this.checkWatermarkAlive() + }, 2000) + }, + stopWatermarkGuard() { + if (this.watermarkObserver) { + this.watermarkObserver.disconnect() + this.watermarkObserver = null + } + if (this.watermarkTimer) { + clearInterval(this.watermarkTimer) + this.watermarkTimer = null + } + }, + // 定时检查:容器或水印元素被隐藏/删除时重新渲染 + checkWatermarkAlive() { + if (this.watermarkRerenderLock) return + const container = document.querySelector('.water-mask') + if (!container) return + const cs = getComputedStyle(container) + if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) { + // 容器被隐藏:强制恢复显示再重渲染 + container.style.setProperty('display', 'block', 'important') + this.rerenderWatermark() + return + } + const masks = container.querySelectorAll('.mask_div, .weterbox, canvas.wm-canvas') + if (masks.length === 0) { + this.rerenderWatermark() + } + }, + // 重新渲染水印(断开 observer 避免重入,渲染完再接回) + rerenderWatermark() { + if (this.watermarkRerenderLock) return + this.watermarkRerenderLock = true + if (this.watermarkObserver) this.watermarkObserver.disconnect() + this.getWatermark().then(() => { + const container = document.querySelector('.water-mask') + if (container && this.watermarkObserver) { + this.watermarkObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class'] + }) + } + this.watermarkRerenderLock = false + }) + }, async handleChange(e) { try { clearTimeout(this.timer) diff --git a/src/views/system/watermarkManage/index.vue b/src/views/system/watermarkManage/index.vue index 3e7d01a..195ba07 100644 --- a/src/views/system/watermarkManage/index.vue +++ b/src/views/system/watermarkManage/index.vue @@ -101,10 +101,9 @@ export default { if (parent.length > 0) { for (let i = 0; i < parent.length; i++) { const childImg = parent[i].getElementsByClassName('weterbox') - if (childImg.length > 0) { - for (let i = 0; i < childImg.length; i++) { - parent[i].removeChild(childImg[i]) - } + // 倒序删除避免 live HTMLCollection 索引偏移,用 j 避免遮蔽外层 i + for (let j = childImg.length - 1; j >= 0; j--) { + parent[i].removeChild(childImg[j]) } } }