在数字化时代,企业核心数据(如财务报表、客户信息、研发图纸等)的外发与共享日益频繁,数据泄露风险随之剧增。一旦发生数据泄露或篡改事件,企业往往面临"不知道是谁泄露的、不知道从哪个渠道泄露的"尴尬境地。
一条总原则:水印在页面上都是"渲染"出来的。静态的普通可见水印前端 canvas 自己画就行(第一节);需要和责任人绑定、需要防伪的水印,则由接口生成一张水印图,前端把它铺到内容容器上渲染------前端全程不上传原图、不参与任何水印算法。只有溯源环节,才需要把疑似泄露的文件交给接口去提取。
一、可见水印:纯前端实现即可的逻辑
这一类完全不需要接口:canvas 画一张平铺图 + 定位遮罩,浏览器自己就能渲染与导出。
实现逻辑
- 用 canvas 画一个只包含一行文字的「平铺单元」,导出
dataURL; - 把这个
dataURL作为background-image铺满一个position: absolute; inset: 0; pointer-events: none的遮罩层; - 用
MutationObserver监听遮罩被删除 / 被改样式,发现异常就重建(自愈); - 导出时,在 canvas 上按同样参数把水印烧进图片像素,再
toBlob下载。
前端实现
vue
<!-- components/AppWatermark.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Props {
text: string
fontSize?: number
color?: string
opacity?: number
rotate?: number
gapX?: number
gapY?: number
}
const props = withDefaults(defineProps<Props>(), {
fontSize: 14,
color: '#000000',
opacity: 0.12,
rotate: -22,
gapX: 180,
gapY: 120
})
const rootRef = ref<HTMLElement | null>(null)
let layer: HTMLDivElement | null = null
let observer: MutationObserver | null = null
/** 画一个平铺单元并转成 dataURL */
function drawTile() {
const dpr = window.devicePixelRatio || 1
const canvas = document.createElement('canvas')
canvas.width = props.gapX * dpr
canvas.height = props.gapY * dpr
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.translate(props.gapX / 2, props.gapY / 2)
ctx.rotate((props.rotate * Math.PI) / 180)
ctx.font = `${props.fontSize}px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.globalAlpha = props.opacity
ctx.fillStyle = props.color
ctx.fillText(props.text, 0, 0)
return canvas.toDataURL('image/png')
}
/** 生成(或重建)水印遮罩层 */
function paint() {
const root = rootRef.value
if (!root) return
// 容器需要定位上下文,否则绝对定位会飘到页面根节点
if (getComputedStyle(root).position === 'static') {
root.style.position = 'relative'
}
layer = document.createElement('div')
Object.assign(layer.style, {
position: 'absolute',
inset: '0',
zIndex: '9999',
pointerEvents: 'none',
userSelect: 'none',
backgroundImage: `url(${drawTile()})`,
backgroundRepeat: 'repeat',
backgroundSize: `${props.gapX}px ${props.gapY}px`
})
root.appendChild(layer)
}
/** 防删除:自己改 DOM 前先 disconnect,改完再 observe,避免回调死循环 */
function guard() {
const root = rootRef.value
if (!root) return
observer = new MutationObserver(() => {
const broken =
!layer ||
!layer.isConnected ||
layer.style.pointerEvents !== 'none' ||
!layer.style.backgroundImage
if (!broken) return
observer?.disconnect()
layer?.remove()
paint()
guard()
})
observer.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class']
})
}
function rebuild() {
observer?.disconnect()
layer?.remove()
paint()
guard()
}
onMounted(rebuild)
onBeforeUnmount(() => {
observer?.disconnect()
layer?.remove()
layer = null
observer = null
})
watch(() => [props.text, props.fontSize, props.gapX, props.gapY, props.opacity], rebuild)
</script>
<template>
<div ref="rootRef" class="app-watermark">
<slot />
</div>
</template>
用法:
<AppWatermark text="内部资料 张三 001"><router-view /></AppWatermark>如果水印文案要跟登录态实时绑定、不想落到前端源码里,就换成第二节的「接口下发水印图」。
前端导出(纯前端,同样不需要接口)
ts
// utils/exportWatermarkImage.ts
const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('图片加载失败'))
img.src = src
})
/** 把水印直接烧进图片像素,导出 PNG */
export async function exportWithWatermark(
file: File,
options: { text: string; fontSize?: number; opacity?: number; rotate?: number; gapX?: number; gapY?: number }
) {
const { text, fontSize = 20, opacity = 0.18, rotate = -22, gapX = 220, gapY = 150 } = options
const url = URL.createObjectURL(file)
try {
const img = await loadImage(url)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
// 旋转后要按对角线铺,否则四个角会露白
const half = Math.ceil(Math.hypot(canvas.width, canvas.height) / 2)
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((rotate * Math.PI) / 180)
ctx.globalAlpha = opacity
ctx.fillStyle = '#000'
ctx.font = `${fontSize}px -apple-system, "PingFang SC", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let y = -half; y < half; y += gapY) {
for (let x = -half; x < half; x += gapX) {
ctx.fillText(text, x, y)
}
}
ctx.restore()
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(b => (b ? resolve(b) : reject(new Error('导出失败'))), 'image/png')
)
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'export.png'
link.click()
URL.revokeObjectURL(link.href)
} finally {
URL.revokeObjectURL(url)
}
}
注意事项
- 水印层必须
pointer-events: none,否则整页都点不动; MutationObserver只是提高门槛,禁 JS / 直接读接口数据依然能绕过,可见水印的定位是威慑 + 追责心理成本;- 大图导出时循环别按像素走,按「对角线的 1/2 ÷ 间距」计算,否则会卡死主线程。
二、隐形水印:接口下发水印图,前端只渲染
实现逻辑
- 前端进入敏感页面时,用当前登录态(或路由参数里的 taskId)请求
GET /api/watermark/image/tile; - 后端把「责任人 / 日期 / 任务号」画成一张透明底的斜向文字平铺图,同时把任务号写进图片像素的最低有效位(防伪),以 PNG 返回;
- 前端拿到 Blob →
URL.createObjectURL→ 直接当background-image铺在内容容器上,background-size与后端画布尺寸对齐; - 页面上的表格、报表等数据同样来自接口,前端只做渲染,渲染层与水印层彻底分离;
- 导出时前端把「内容 + 水印层」一起截图成 PNG 下载(或交给后端出成品)。
全程没有任何「上传原图」的动作:水印图是接口产的,前端只是把它显示出来。
服务端(FastAPI)
python
# server/routers/watermark.py
import io
import numpy as np
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from PIL import Image, ImageDraw, ImageFont
router = APIRouter()
TILE_W, TILE_H = 300, 200 # 与前端 background-size 保持一致
FONT_PATH = "fonts/NotoSansSC-Regular.ttf" # 中文必须显式指定字体文件
def _draw_tile(text: str, font_size: int = 16, alpha: int = 24, rotate: int = -22) -> Image.Image:
"""画一张透明底的斜向文字水印单元"""
layer = Image.new("RGBA", (TILE_W, TILE_H), (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
try:
font = ImageFont.truetype(FONT_PATH, font_size)
except OSError:
font = ImageFont.load_default()
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
draw.text(
((TILE_W - (right - left)) / 2, (TILE_H - (bottom - top)) / 2),
text,
font=font,
fill=(0, 0, 0, alpha),
)
return layer.rotate(rotate, resample=Image.BICUBIC)
def _embed_task_id(img: Image.Image, secret: bytes) -> Image.Image:
"""把任务号写进 RGB 通道最低位(无损 PNG 才有效)"""
bits = np.unpackbits(np.frombuffer(secret, dtype=np.uint8))
arr = np.array(img.convert("RGBA"), dtype=np.uint8)
flat = arr.reshape(-1) # RGBA 交错展平
idx = (np.arange(bits.size) // 3) * 4 + (np.arange(bits.size) % 3) # 跳过 alpha
flat[idx] = (flat[idx] & 0xFE) | bits
return Image.fromarray(flat.reshape(arr.shape))
def _read_task_id(img: Image.Image) -> int:
arr = np.array(img.convert("RGBA"), dtype=np.uint8).reshape(-1)
idx = (np.arange(32) // 3) * 4 + (np.arange(32) % 3)
return int.from_bytes(np.packbits(arr[idx] & 0x01).tobytes(), "big")
@router.get("/image/tile", summary="下发水印背景图,前端直接渲染")
def watermark_tile(
task_id: int = Query(..., ge=1, description="水印任务号"),
text: str = Query("", max_length=40, description="水印文案,留空按任务号生成"),
font_size: int = Query(16, ge=8, le=40),
alpha: int = Query(24, ge=8, le=80, description="文字透明度 0-255"),
):
tile = _draw_tile(text or f"内部资料 T{task_id:06d}", font_size=font_size, alpha=alpha)
tile = _embed_task_id(tile, task_id.to_bytes(4, "big"))
buf = io.BytesIO()
tile.save(buf, format="PNG")
return Response(
content=buf.getvalue(),
media_type="image/png",
headers={
"Cache-Control": "no-store", # 水印图每次按人下发,别让 CDN 缓存串号
"X-Task-Id": str(task_id),
},
)
@router.post("/image/extract", summary="从疑似泄露图片中提取任务号(溯源用)")
async def extract_image(file: UploadFile = File(...)):
try:
img = Image.open(io.BytesIO(await file.read()))
except Exception as exc:
raise HTTPException(status_code=400, detail="图片无法解析") from exc
task_id = _read_task_id(img)
if task_id == 0:
raise HTTPException(status_code=404, detail="未检测到水印")
return {"task_id": task_id}
需要抗二次压缩、抗缩放的强鲁棒能力时,把
_embed_task_id换成 DWT-DCT(如invisible-watermark的dwtDct,单次上限 32 bit,正好放 4 字节任务号),接口形态不变。
前端(Vue 3):拿到图就铺,不做任何计算
vue
<!-- components/WatermarkLayer.vue -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps<{ taskId: number; text?: string }>()
const tileUrl = ref('')
onMounted(async () => {
const query = new URLSearchParams({ task_id: String(props.taskId) })
if (props.text) query.set('text', props.text)
const res = await fetch(`/api/watermark/image/tile?${query}`, { credentials: 'include' })
if (!res.ok) return
// 接口返回的就是成品水印图,直接转成可用的 URL
tileUrl.value = URL.createObjectURL(await res.blob())
})
onBeforeUnmount(() => tileUrl.value && URL.revokeObjectURL(tileUrl.value))
</script>
<template>
<div class="wm-wrap">
<slot />
<div
v-if="tileUrl"
class="wm-layer"
:style="{ backgroundImage: `url(${tileUrl})` }"
/>
</div>
</template>
<style scoped>
.wm-wrap {
position: relative;
}
.wm-layer {
position: absolute;
inset: 0;
z-index: 9999;
pointer-events: none;
user-select: none;
background-repeat: repeat;
background-size: 300px 200px; /* 必须等于后端 TILE_W/TILE_H */
}
</style>
页面里直接包住内容即可,数据和渲染都跟水印无关:
vue
<template>
<WatermarkLayer :task-id="2" text="Bob 2026-11-05">
<ReportTable :rows="rows" />
</WatermarkLayer>
</template>
导出(前端把内容和水印层一起截下来):
ts
// utils/exportPanel.ts
import html2canvas from 'html2canvas' // npm i html2canvas
export async function exportPanel(el: HTMLElement, filename = 'export.png') {
const canvas = await html2canvas(el, {
backgroundColor: '#fff',
scale: window.devicePixelRatio,
useCORS: true
})
const link = document.createElement('a')
link.href = canvas.toDataURL('image/png')
link.download = filename
link.click()
}
注意事项
- 水印图的
Cache-Control必须是no-store,否则不同用户会看到同一个任务号的水印; background-size写错会导致平铺错位、出现明显接缝,前后端尺寸要作为常量约定;- html2canvas 导出的是看得见的那层,像素级 LSB 已在合成中丢失,所以要求"导出仍可溯源"时,让后端出成品文件,前端只负责下载;
- 水印图建议按登录态生成,不要由前端传
user之类的参数,否则改个 query 就能伪造责任人。
三、数据水印:零宽字符(接口返回原文 + 水印文本)
实现逻辑
用不可见字符表示二进制:U+200B(零宽空格)= 0,U+200C(零宽非连接符)= 1。接口一次性返回两份文本------text 给前端渲染,watermarked 带给水印,前端复制或导出时用后者,页面视觉完全不变。
服务端(FastAPI)
python
# server/routers/watermark.py(续)
import random
from datetime import datetime
from typing import Any
from fastapi import Query
from pydantic import BaseModel, Field
ZERO_WIDTH_0 = "\u200b" # 0
ZERO_WIDTH_1 = "\u200c" # 1
GUARD = "\u200d" # 首尾标记,便于定位水印区间
# 演示用内存表,生产环境替换为数据库
WATERMARK_TASKS: dict[int, dict[str, Any]] = {
1: {"user": "Alice", "channel": "Email", "time": datetime(2026, 10, 1, 10, 0)},
2: {"user": "Bob", "channel": "USB", "time": datetime(2026, 10, 2, 14, 30)},
}
class TextContentOut(BaseModel):
text: str # 原文,前端直接渲染
watermarked: str # 带零宽水印的版本,复制/导出时用
payload: str
class TextExtractIn(BaseModel):
text: str
class TextExtractOut(BaseModel):
found: bool
payload: str = ""
def _encode_text(text: str, payload: str) -> str:
bits = "".join(f"{byte:08b}" for byte in payload.encode("utf-8"))
hidden = bits.replace("0", ZERO_WIDTH_0).replace("1", ZERO_WIDTH_1)
return f"{text}{GUARD}{hidden}{GUARD}"
@router.get("/text/content", response_model=TextContentOut, summary="返回原文与水印文本")
def text_content(
task_id: int = Query(..., ge=1),
text: str = Query("本季度财务数据仅供内部使用,请勿外传。", max_length=2000),
):
task = WATERMARK_TASKS.get(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
payload = f"{task['user']}/{task['time']:%Y-%m-%d}/Task_{task_id:03d}"
return TextContentOut(text=text, watermarked=_encode_text(text, payload), payload=payload)
@router.post("/text/extract", response_model=TextExtractOut, summary="从文本还原水印")
def extract_text(body: TextExtractIn):
bits = "".join(
"0" if char == ZERO_WIDTH_0 else "1"
for char in body.text
if char in (ZERO_WIDTH_0, ZERO_WIDTH_1)
)
if len(bits) < 8:
return TextExtractOut(found=False)
usable = len(bits) - len(bits) % 8
payload = bytes(int(bits[i : i + 8], 2) for i in range(0, usable, 8)).decode("utf-8", "ignore")
return TextExtractOut(found=bool(payload), payload=payload)
前端(Vue 3):渲染原文,复制/导出用水印版
vue
<!-- components/TextWatermarkPanel.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const taskId = 2
const displayText = ref('') // 页面上渲染的
const watermarkedText = ref('') // 只在复制/导出时使用
const message = ref('')
onMounted(async () => {
const res = await fetch(`/api/watermark/text/content?task_id=${taskId}`, { credentials: 'include' })
if (!res.ok) return
const data = await res.json()
displayText.value = data.text // 可视区域用原文,避免零宽字符影响排版
watermarkedText.value = data.watermarked
})
/** 复制:把肉眼相同的文本换成带水印的版本 */
async function copyWithWatermark() {
await navigator.clipboard.writeText(watermarkedText.value)
message.value = '已复制(附带隐形水印)'
}
/** 导出 txt:直接写水印版,全程不请求第二次 */
function exportTxt() {
const blob = new Blob([watermarkedText.value], { type: 'text/plain;charset=utf-8' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `report-task-${taskId}.txt`
link.click()
URL.revokeObjectURL(link.href)
message.value = '已导出'
}
</script>
<template>
<div>
<pre>{{ displayText }}</pre>
<button @click="copyWithWatermark">复制(带水印)</button>
<button @click="exportTxt">导出 txt</button>
<p v-if="message">{{ message }}</p>
</div>
</template>
注意事项
- 一定要「渲染用原文、复制用水印版」,否则零宽字符混进
contenteditable或表单里会引发校验异常; navigator.clipboard需要 HTTPS 或 localhost,否则要降级为隐藏 textarea +execCommand('copy');- 微信、部分富文本编辑器、输入框校验会清洗零宽字符,所以它适合 txt / 表格复制这类链路。
四、仿行伪列水印:查询接口直接掺入假数据
实现逻辑
后端在返回敏感数据的查询接口里,按比例混入「看起来是真的、业务上并不存在」的伪行。伪行用任务号做随机种子 生成,同一任务每次返回的伪行完全一致------数据一旦泄露,这批伪行就是指向泄露源的指纹。前端只负责渲染与导出,不能在页面上给伪行做任何标记,否则一眼就被剔除。
服务端(FastAPI)
python
# server/routers/watermark.py(续)
class TableRowsOut(BaseModel):
rows: list[dict[str, Any]]
fake_count: int
def _query_real_rows() -> list[dict[str, Any]]:
"""真实数据来源:数据库 / 数据仓库,这里用假数据代替"""
return [
{"id": "A001", "region": "华东区", "amount": 58200},
{"id": "A002", "region": "华南区", "amount": 43100},
{"id": "A003", "region": "华北区", "amount": 62750},
]
def _fake_row(template: dict[str, Any], rnd: random.Random) -> dict[str, Any]:
row: dict[str, Any] = {}
for key, value in template.items():
if isinstance(value, bool):
row[key] = value
elif isinstance(value, (int, float)):
row[key] = round(value * (1 + rnd.uniform(-0.1, 0.1)), 2) # 数值微扰,量级一致
else:
row[key] = value
return row
@router.get("/table/rows", response_model=TableRowsOut, summary="查询结果直接掺入伪行")
def table_rows(
task_id: int = Query(..., ge=1),
ratio: float = Query(0.05, gt=0, le=0.3, description="伪行比例"),
):
if task_id not in WATERMARK_TASKS:
raise HTTPException(status_code=404, detail="任务不存在")
rows = _query_real_rows()
if not rows:
return TableRowsOut(rows=[], fake_count=0)
rnd = random.Random(task_id) # 种子固定 → 伪行可复现
count = max(1, round(len(rows) * ratio))
fake_rows = [_fake_row(rows[rnd.randrange(len(rows))], rnd) for _ in range(count)]
merged = [*rows, *fake_rows]
rnd.shuffle(merged)
return TableRowsOut(rows=merged, fake_count=count)
前端(Vue 3):渲染 + 导出 CSV
vue
<!-- components/TableWatermarkPanel.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const taskId = 2
const tableData = ref<Record<string, any>[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
const res = await fetch(`/api/watermark/table/rows?task_id=${taskId}`, { credentials: 'include' })
if (res.ok) tableData.value = (await res.json()).rows // 真伪混排,前端不做区分
} finally {
loading.value = false
}
})
</script>
<template>
<div>
<button :disabled="!tableData.length" @click="exportCsv(tableData, `task-${taskId}.csv`)">
导出 CSV
</button>
<el-table :data="tableData" v-loading="loading">
<el-table-column prop="id" label="编号" />
<el-table-column prop="region" label="区域" />
<el-table-column prop="amount" label="金额" />
</el-table>
</div>
</template>
ts
// utils/exportCsv.ts
export function exportCsv(rows: Record<string, any>[], filename = 'export.csv') {
const headers = Object.keys(rows[0] ?? {})
const escape = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`
const csv = [
headers.join(','),
...rows.map(row => headers.map(key => escape(row[key])).join(','))
].join('\n')
const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }) // BOM 防 Excel 乱码
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
注意事项
- 伪行位置必须由服务端决定,种子一旦下发到前端,伪行就能被反推、被剔除;
- 全字符串的表(如姓名 + 地址)数值微扰无效,这类表要靠「插入不存在的编号」或伪列(虚构备注列)实现;
- 前端拿到的就是混过伪行的结果集,不要再回写缓存或提交回后端,否则脏数据会污染真实库。
五、一键溯源:上传疑似泄露文件 → 接口提取 → 接口匹配
这是全文唯一需要前端提供文件的场景:页面渲染阶段不需要上传任何东西,但溯源时必须把泄露样本交给服务端解析。
实现逻辑
- 用户把疑似泄露的 txt / csv / 图片拖进来;
- 图片走
POST /image/extract(读像素里的任务号),文本走POST /text/extract(读零宽字符); - 拿到
task_id后调POST /trace,返回责任人、渠道、时间; - 密钥与水印库全部留在服务端,前端只做类型判断和结果渲染。
服务端(FastAPI)
python
# server/routers/watermark.py(续)
class TraceIn(BaseModel):
task_id: int
class TraceOut(BaseModel):
task_id: int
user: str
channel: str
time: datetime
@router.post("/trace", response_model=TraceOut, summary="根据任务号溯源")
def trace(body: TraceIn):
task = WATERMARK_TASKS.get(body.task_id)
if not task:
raise HTTPException(status_code=404, detail="未匹配到水印任务")
return TraceOut(task_id=body.task_id, **task)
前端(Vue 3):拖拽 → 提取 → 查询 → 展示
vue
<!-- components/TracePanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface TraceResult {
task_id: number
user: string
channel: string
time: string
}
const loading = ref(false)
const error = ref('')
const result = ref<TraceResult | null>(null)
async function extractTaskId(file: File): Promise<number | null> {
// 图片:上传后由服务端读像素里的任务号
if (file.type.startsWith('image/')) {
const form = new FormData()
form.append('file', file)
const res = await fetch('/api/watermark/image/extract', { method: 'POST', body: form })
if (!res.ok) return null
return (await res.json()).task_id
}
// 文本 / CSV:上传文本内容,由服务端读零宽字符
const res = await fetch('/api/watermark/text/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: await file.text() })
})
if (!res.ok) return null
const data = await res.json()
const matched = /Task_(\d+)/.exec(data.payload ?? '') // payload 形如 Bob/2026-11-05/Task_002
return matched ? Number(matched[1]) : null
}
async function handleFile(file: File) {
loading.value = true
error.value = ''
result.value = null
try {
const taskId = await extractTaskId(file)
if (!taskId) {
error.value = '未检测到水印信息,可能已被二次压缩或清洗'
return
}
const res = await fetch('/api/watermark/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
})
if (!res.ok) throw new Error('未匹配到水印任务')
result.value = await res.json()
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
const file = event.dataTransfer?.files?.[0]
if (file) handleFile(file)
}
</script>
<template>
<div class="trace-panel" @dragover.prevent @drop="onDrop">
<p class="hint">把疑似泄露的文本、CSV 或图片拖到这里</p>
<p v-if="loading">正在提取水印并溯源...</p>
<p v-else-if="error" class="error">{{ error }}</p>
<dl v-else-if="result">
<dt>任务号</dt><dd>{{ result.task_id }}</dd>
<dt>责任人</dt><dd>{{ result.user }}</dd>
<dt>外发渠道</dt><dd>{{ result.channel }}</dd>
<dt>外发时间</dt><dd>{{ new Date(result.time).toLocaleString() }}</dd>
</dl>
</div>
</template>
注意事项
- 真实的图片泄露往往经过截图 / 有损压缩,LSB 会失效,生产环境建议用 DWT-DCT 或「服务端合成导出」的方式保证可提取;
- 提取失败不要直接判定「无水印」,先判断样本是否被有损处理,再提示转人工核查;
- 每次外发前都要先创建任务(发号 → 生成载体 → 记录责任人),溯源才有数据可查。