开篇
本文内容基于工具小站-图片模块-图片加水印功能,对于此功能,简单做一个总结。
图片加水印功能实现逻辑
图片加水印功能分为两种:文字水印和图片水印功能。其中如果水印为文字类型,则使用ctx.fillText()方; 如果水印为图片类型,则使用ctx.drawImage()方法。
代码实现
<template><div class="app-container"><header class="app-header"><h1>图片水印</h1><p class="subtitle">为你的图片添加文字或图片水印</p></header><main class="main-content"><el-tabs v-model="activeTab" class="watermark-tabs"><el-tab-pane label="单张水印" name="single"><!-- 上传区域 --><div class="upload-section" v-if="!currentImage"><el-uploadclass="upload-drop-zone"drag:auto-upload="false"accept="image/*":show-file-list="false"@change="handleFileChange"><el-icon class="upload-icon"><upload-filled /></el-icon><div class="upload-text"><h3>将图片拖到此处,或点击上传</h3><p>支持 PNG、JPG、WebP 等格式</p></div></el-upload></div><!-- 编辑区域 --><div v-else class="edit-section"><div class="preview-area"><div class="canvas-container" ref="canvasContainer"><canvas ref="cacheCanvas" style="display: none"></canvas><canvasref="canvas"@mousedown="handleDrag"@mousemove="handleDrag"@mouseup="handleDrag"></canvas></div></div><div class="control-panel"><el-form :model="watermarkSettings" label-position="top"><!-- 水印类型选择 --><el-form-item label="水印类型"><el-radio-group v-model="watermarkSettings.type"><el-radio label="text">文字水印</el-radio><el-radio label="image">图片水印</el-radio></el-radio-group></el-form-item><!-- 文字水印设置 --><template v-if="watermarkSettings.type === 'text'"><el-form-item label="水印文字"><el-inputv-model="watermarkSettings.text"placeholder="请输入水印文字"/></el-form-item><el-form-item label="字体"><el-select v-model="watermarkSettings.fontFamily"><el-option label="苹方" value="PingFang SC" /><el-option label="黑体" value="SimHei" /><el-option label="宋体" value="SimSun" /></el-select></el-form-item><el-form-item label="字号"><el-sliderv-model="watermarkSettings.fontSize":min="12":max="72"/></el-form-item><el-form-item label="颜色"><el-color-picker v-model="watermarkSettings.color" /></el-form-item></template><!-- 图片水印设置 --><template v-if="watermarkSettings.type === 'image'"><el-form-item label="水印图片"><el-uploadclass="watermark-image-upload":auto-upload="false"accept="image/*":show-file-list="false"@change="handleWatermarkImageChange"><el-button type="primary">选择水印图片</el-button></el-upload></el-form-item></template><!-- 通用设置 --><el-form-item label="透明度"><el-sliderv-model="watermarkSettings.opacity":min="0":max="100"/></el-form-item><el-form-item label="位置"><el-radio-group v-model="watermarkSettings.position"><el-radio-button label="topLeft">左上</el-radio-button><el-radio-button label="topRight">右上</el-radio-button><el-radio-button label="bottomLeft">左下</el-radio-button><el-radio-button label="bottomRight">右下</el-radio-button><el-radio-button label="center">居中</el-radio-button><el-radio-button label="custom">自定义</el-radio-button></el-radio-group></el-form-item><div class="action-buttons"><el-buttontype="primary"@click="applyWatermark":loading="processing">应用水印</el-button><el-button @click="resetImage">重新选择</el-button><el-buttontype="success"@click="downloadImage":disabled="!hasWatermark">下载图片</el-button></div></el-form></div></div></el-tab-pane><el-tab-pane label="批量水印" name="batch"><div class="batch-section"><div class="upload-section" v-if="!batchFiles.length"><el-uploadclass="upload-drop-zone"dragmultiple:auto-upload="false"accept="image/*":show-file-list="false"@change="handleBatchFilesChange"><el-icon class="upload-icon"><upload-filled /></el-icon><div class="upload-text"><h3>将多张图片拖到此处,或点击上传</h3><p>支持 PNG、JPG、WebP 等格式</p></div></el-upload></div><div v-else class="batch-content"><div class="batch-main"><div class="preview-area" v-if="batchFiles.length"><divclass="canvas-container"ref="batchCanvasContainer"style="min-height: 200px"><canvasref="batchCacheCanvas"style="display: none"></canvas><canvas ref="batchCanvas"></canvas></div></div><div class="control-panel"><el-form :model="batchWatermarkSettings" label-position="top"><el-form-item label="水印类型"><el-radio-group v-model="batchWatermarkSettings.type"><el-radio label="text">文字水印</el-radio><el-radio label="image">图片水印</el-radio></el-radio-group></el-form-item><template v-if="batchWatermarkSettings.type === 'text'"><el-form-item label="水印文字"><el-inputv-model="batchWatermarkSettings.text"placeholder="请输入水印文字"/></el-form-item><el-form-item label="字体"><el-select v-model="batchWatermarkSettings.fontFamily"><el-option label="苹方" value="PingFang SC" /><el-option label="黑体" value="SimHei" /><el-option label="宋体" value="SimSun" /></el-select></el-form-item><el-form-item label="字号"><el-sliderv-model="batchWatermarkSettings.fontSize":min="12":max="72"/></el-form-item><el-form-item label="颜色"><el-color-pickerv-model="batchWatermarkSettings.color"/></el-form-item></template><template v-if="batchWatermarkSettings.type === 'image'"><el-form-item label="水印图片"><el-uploadclass="watermark-image-upload":auto-upload="false"accept="image/*":show-file-list="false"@change="handleBatchWatermarkImageChange"><el-button type="primary">选择水印图片</el-button></el-upload></el-form-item></template><el-form-item label="透明度"><el-sliderv-model="batchWatermarkSettings.opacity":min="0":max="100"/></el-form-item><el-form-item label="位置"><el-radio-group v-model="batchWatermarkSettings.position"><el-radio-button label="topLeft">左上</el-radio-button><el-radio-button label="topRight">右上</el-radio-button><el-radio-button label="bottomLeft">左下</el-radio-button><el-radio-button label="bottomRight">右下</el-radio-button><el-radio-button label="center">居中</el-radio-button></el-radio-group></el-form-item></el-form></div></div><div class="batch-footer"><div class="files-list"><el-table :data="batchFiles" style="width: 100%"><el-table-column label="文件名" prop="name" /><el-table-column label="预览" width="100"><template #default="{ row }"><el-buttonlinktype="primary"@click="previewBatchImage(row)">预览</el-button></template></el-table-column><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag></template></el-table-column><el-table-column label="操作" width="120"><template #default="{ row }"><el-buttonlinktype="primary"@click="downloadSingleBatchImage(row)":disabled="row.status !== 'done'">下载</el-button></template></el-table-column></el-table></div><div class="batch-actions"><el-buttontype="primary"@click="applyBatchWatermark":loading="processing">批量添加水印</el-button><el-button @click="resetBatchFiles">重新选择</el-button><el-buttontype="success"@click="downloadBatchImages":disabled="!hasBatchWatermark">下载全部</el-button></div></div></div></div></el-tab-pane></el-tabs></main></div>
</template><script setup>
import { ref, reactive, onMounted, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";// 状态变量
const currentImage = ref(null);
const canvas = ref(null);
const canvasContainer = ref(null);
const processing = ref(false);
const hasWatermark = ref(false);
const activeTab = ref("single");
const cacheCanvas = ref(null);
const batchCanvas = ref(null);
const batchCanvasContainer = ref(null);
const batchFiles = ref([]);
const hasBatchWatermark = ref(false);// 水印设置
const watermarkSettings = reactive({type: "text",text: "水印文字",fontFamily: "PingFang SC",fontSize: 24,color: "#000000",opacity: 50,position: "bottomRight",watermarkImage: null,x: 0,y: 0,
});// 添加批量水印的设置
const batchWatermarkSettings = reactive({type: "text",text: "水印文字",fontFamily: "PingFang SC",fontSize: 24,color: "#000000",opacity: 50,position: "bottomRight",watermarkImage: null,
});// 处理图片上传
const handleFileChange = (file) => {const fileObj = file.raw;if (!fileObj) return;if (!fileObj.type.startsWith("image/")) {ElMessage.error("请上传图片文件");return;}currentImage.value = fileObj;loadImage();
};// 加载图片到画布
const loadImage = () => {if (!canvas.value || !cacheCanvas.value) {setTimeout(loadImage, 50);return;}const img = new Image();img.onload = () => {// 设置缓存画布尺寸并绘制原始图片cacheCanvas.value.width = img.width;cacheCanvas.value.height = img.height;const cacheCtx = cacheCanvas.value.getContext("2d");cacheCtx.drawImage(img, 0, 0);// 设置显示画布尺寸canvas.value.width = img.width;canvas.value.height = img.height;// 调整容器大小(只在首次加载时调整)if (!canvasContainer.value.style.width) {const container = canvasContainer.value;const maxWidth = container.clientWidth;const scale = maxWidth / img.width;if (scale < 1) {container.style.width = maxWidth + "px";container.style.height = img.height * scale + "px";} else {container.style.width = img.width + "px";container.style.height = img.height + "px";}}// 重新绘制图片refreshCanvas();};img.src = URL.createObjectURL(currentImage.value);
};// 添加刷新画布函数
const refreshCanvas = () => {const ctx = canvas.value.getContext("2d");ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);ctx.drawImage(cacheCanvas.value, 0, 0);
};// 应用水印
const applyWatermark = async () => {if (!currentImage.value) return;processing.value = true;const ctx = canvas.value.getContext("2d");try {// 重新绘制原始图片refreshCanvas();// 应用水印if (watermarkSettings.type === "text") {// 应用文字水印ctx.globalAlpha = watermarkSettings.opacity / 100;ctx.fillStyle = watermarkSettings.color;ctx.font = `${watermarkSettings.fontSize}px ${watermarkSettings.fontFamily}`;const position = calculatePosition(ctx.measureText(watermarkSettings.text).width,watermarkSettings.fontSize);ctx.fillText(watermarkSettings.text, position.x, position.y);} else {// 应用图片水印if (watermarkSettings.watermarkImage) {const watermarkImg = new Image();watermarkImg.src = URL.createObjectURL(watermarkSettings.watermarkImage);await new Promise((resolve) => {watermarkImg.onload = () => {ctx.globalAlpha = watermarkSettings.opacity / 100;const position = calculatePosition(watermarkImg.width,watermarkImg.height);ctx.drawImage(watermarkImg, position.x, position.y);resolve();};});}}hasWatermark.value = true;ElMessage.success("水印添加成功");} catch (error) {ElMessage.error("水印添加失败");console.error(error);} finally {processing.value = false;ctx.globalAlpha = 1;}
};// 计算水印位置
const calculatePosition = (width, height) => {const canvasWidth = canvas.value.width;const canvasHeight = canvas.value.height;const padding = 20;if (watermarkSettings.position === "custom") {// 如果是首次设置自定义位置,将水印放在中心if (watermarkSettings.x === 0 && watermarkSettings.y === 0) {watermarkSettings.x = (canvasWidth - width) / 2;watermarkSettings.y = (canvasHeight + height) / 2;}return {x: Math.min(Math.max(watermarkSettings.x, 0), canvasWidth - width),y: Math.min(Math.max(watermarkSettings.y, 0), canvasHeight - height),};}const positions = {topLeft: { x: padding, y: padding + height },topRight: { x: canvasWidth - width - padding, y: padding + height },bottomLeft: { x: padding, y: canvasHeight - padding },bottomRight: {x: canvasWidth - width - padding,y: canvasHeight - padding,},center: { x: (canvasWidth - width) / 2, y: (canvasHeight + height) / 2 },};return positions[watermarkSettings.position];
};// 处理水印图片上传
const handleWatermarkImageChange = (file) => {watermarkSettings.watermarkImage = file.raw;
};// 下载处理后的图片
const downloadImage = () => {const link = document.createElement("a");link.download = `watermarked_${currentImage.value.name}`;link.href = canvas.value.toDataURL();link.click();
};// 重置
const resetImage = () => {currentImage.value = null;hasWatermark.value = false;watermarkSettings.watermarkImage = null;
};// 修改拖拽功能实现
let isDragging = false;
let startX = 0;
let startY = 0;
let dragDebounceTimer = null;
let isFirstDrag = true;const handleDrag = (event) => {if (watermarkSettings.position !== "custom") return;const rect = canvas.value.getBoundingClientRect();const scale = canvas.value.width / rect.width;switch (event.type) {case "mousedown":isDragging = true;isFirstDrag = true;startX = event.clientX;startY = event.clientY;break;case "mousemove":if (!isDragging) return;const deltaX = (event.clientX - startX) * scale;const deltaY = (event.clientY - startY) * scale;watermarkSettings.x += deltaX;watermarkSettings.y += deltaY;startX = event.clientX;startY = event.clientY;// 使用防抖处理水印应用if (dragDebounceTimer) clearTimeout(dragDebounceTimer);dragDebounceTimer = setTimeout(() => {refreshCanvas();updateWatermark();}, 16);break;case "mouseup":isDragging = false;if (dragDebounceTimer) {clearTimeout(dragDebounceTimer);if (!isFirstDrag) {applyWatermark();}}isFirstDrag = false;break;}
};// 添加更新水印函数(不显示提示)
const updateWatermark = async () => {if (!currentImage.value) return;const ctx = canvas.value.getContext("2d");try {// 重新绘制原始图片refreshCanvas();// 应用水印if (watermarkSettings.type === "text") {ctx.globalAlpha = watermarkSettings.opacity / 100;ctx.fillStyle = watermarkSettings.color;ctx.font = `${watermarkSettings.fontSize}px ${watermarkSettings.fontFamily}`;const position = calculatePosition(ctx.measureText(watermarkSettings.text).width,watermarkSettings.fontSize);ctx.fillText(watermarkSettings.text, position.x, position.y);} else if (watermarkSettings.watermarkImage) {const watermarkImg = new Image();watermarkImg.src = URL.createObjectURL(watermarkSettings.watermarkImage);await new Promise((resolve) => {watermarkImg.onload = () => {ctx.globalAlpha = watermarkSettings.opacity / 100;const position = calculatePosition(watermarkImg.width,watermarkImg.height);ctx.drawImage(watermarkImg, position.x, position.y);resolve();};});}} finally {ctx.globalAlpha = 1;}
};// 修改水印设置监听
watch(watermarkSettings,(newVal, oldVal) => {if (currentImage.value) {// 当切换到自定义位置时,重置坐标if (newVal.position === "custom" && oldVal.position !== "custom") {watermarkSettings.x = 0;watermarkSettings.y = 0;}nextTick(() => {loadImage();if (hasWatermark.value) {updateWatermark();}});}},{ deep: true }
);onMounted(() => {// 确保组件完全挂载后再初始化nextTick(() => {if (currentImage.value) {loadImage();}});
});// 添加批量处理相关函数
const handleBatchFilesChange = (file) => {const fileObj = file.raw;if (!fileObj) return;if (!fileObj.type.startsWith("image/")) {ElMessage.error("请上传图片文件");return;}batchFiles.value.push({id: Date.now() + Math.random(),name: fileObj.name,file: fileObj,status: "pending",processedBlob: null,});// 如果是第一个文件,自动加载预览if (batchFiles.value.length === 1) {nextTick(() => {previewBatchImage(batchFiles.value[0]);});}
};const previewBatchImage = (file) => {if (!batchCanvas.value) return;const ctx = batchCanvas.value.getContext("2d");const img = new Image();img.onload = () => {batchCanvas.value.width = img.width;batchCanvas.value.height = img.height;ctx.clearRect(0, 0, img.width, img.height);ctx.drawImage(img, 0, 0);// 立即应用水印预览if (batchWatermarkSettings.type === "text") {ctx.globalAlpha = batchWatermarkSettings.opacity / 100;ctx.fillStyle = batchWatermarkSettings.color;ctx.font = `${batchWatermarkSettings.fontSize}px ${batchWatermarkSettings.fontFamily}`;const position = calculatePositionForBatch(ctx.measureText(batchWatermarkSettings.text).width,batchWatermarkSettings.fontSize,batchCanvas.value);ctx.fillText(batchWatermarkSettings.text, position.x, position.y);ctx.globalAlpha = 1;} else if (batchWatermarkSettings.watermarkImage) {const watermarkImg = new Image();watermarkImg.onload = () => {ctx.globalAlpha = batchWatermarkSettings.opacity / 100;const position = calculatePositionForBatch(watermarkImg.width,watermarkImg.height,batchCanvas.value);ctx.drawImage(watermarkImg, position.x, position.y);ctx.globalAlpha = 1;};watermarkImg.src = URL.createObjectURL(batchWatermarkSettings.watermarkImage);}// 调整容器大小const container = batchCanvasContainer.value;const maxWidth = container.clientWidth;const scale = maxWidth / img.width;if (scale < 1) {container.style.width = maxWidth + "px";container.style.height = img.height * scale + "px";} else {container.style.width = img.width + "px";container.style.height = img.height + "px";}};img.src = URL.createObjectURL(file.file);
};const applyBatchWatermark = async () => {if (!batchFiles.value.length) return;processing.value = true;try {for (const file of batchFiles.value) {file.status = "processing";await processImage(file);file.status = "done";}hasBatchWatermark.value = true;ElMessage.success("批量处理完成");} catch (error) {ElMessage.error("批量处理失败");console.error(error);} finally {processing.value = false;}
};const processImage = async (file) => {return new Promise((resolve, reject) => {const tempCanvas = document.createElement("canvas");const tempCtx = tempCanvas.getContext("2d");const img = new Image();img.onload = async () => {tempCanvas.width = img.width;tempCanvas.height = img.height;tempCtx.drawImage(img, 0, 0);try {// 应用水印if (batchWatermarkSettings.type === "text") {tempCtx.globalAlpha = batchWatermarkSettings.opacity / 100;tempCtx.fillStyle = batchWatermarkSettings.color;tempCtx.font = `${batchWatermarkSettings.fontSize}px ${batchWatermarkSettings.fontFamily}`;const position = calculatePositionForBatch(tempCtx.measureText(batchWatermarkSettings.text).width,batchWatermarkSettings.fontSize,tempCanvas);tempCtx.fillText(batchWatermarkSettings.text, position.x, position.y);} else if (batchWatermarkSettings.watermarkImage) {// 处理图片水印// ... 类似的图片水印处理逻辑 ...}// 转换为 BlobtempCanvas.toBlob((blob) => {if (blob) {file.processedBlob = blob;resolve(blob);} else {reject(new Error("转换失败"));}}, file.file.type);} catch (error) {reject(error);}};img.onerror = () => reject(new Error("图片加载失败"));img.src = URL.createObjectURL(file.file);});
};// 添加批量水印位置计算函数
const calculatePositionForBatch = (width, height, canvas) => {const canvasWidth = canvas.width;const canvasHeight = canvas.height;const padding = 20;const positions = {topLeft: { x: padding, y: padding + height },topRight: { x: canvasWidth - width - padding, y: padding + height },bottomLeft: { x: padding, y: canvasHeight - padding },bottomRight: {x: canvasWidth - width - padding,y: canvasHeight - padding,},center: { x: (canvasWidth - width) / 2, y: (canvasHeight + height) / 2 },};return positions[batchWatermarkSettings.position];
};// 修改下载批量图片函数
const downloadBatchImages = () => {batchFiles.value.forEach((file) => {if (file.status === "done") {const link = document.createElement("a");link.download = `watermarked_${file.name}`;const blob = file.processedBlob || file.file;link.href = URL.createObjectURL(blob);link.click();setTimeout(() => URL.revokeObjectURL(link.href), 100);}});
};const resetBatchFiles = () => {batchFiles.value = [];hasBatchWatermark.value = false;
};// 状态显示辅助函数
const getStatusText = (status) => {const statusMap = {pending: "待处理",processing: "处理中",done: "已完成",error: "失败",};return statusMap[status];
};const getStatusType = (status) => {const typeMap = {pending: "info",processing: "warning",done: "success",error: "danger",};return typeMap[status];
};// 添加批量水印图片处理函数
const handleBatchWatermarkImageChange = (file) => {batchWatermarkSettings.watermarkImage = file.raw;
};// 添加单个批量图片下载功能
const downloadSingleBatchImage = (file) => {if (file.status !== "done") return;const link = document.createElement("a");link.download = `watermarked_${file.name}`;const blob = file.processedBlob || file.file;link.href = URL.createObjectURL(blob);link.click();setTimeout(() => URL.revokeObjectURL(link.href), 100);
};// 添加批量水印设置变化监听
watch(batchWatermarkSettings,() => {if (batchFiles.value.length > 0) {// 重新预览当前显示的图片previewBatchImage(batchFiles.value[0]);}},{ deep: true }
);
</script><style scoped>
.app-container {max-width: 1200px;margin: 0 auto;padding: 2rem;
}.app-header {text-align: center;margin-bottom: 3rem;
}.app-header h1 {font-size: 2.5rem;font-weight: 600;color: var(--el-text-color-primary);margin-bottom: 0.5rem;
}.subtitle {color: var(--el-text-color-secondary);font-size: 1.1rem;
}.main-content {background: var(--el-bg-color);border-radius: 12px;padding: 2rem;box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}.upload-section {padding: 2rem;
}.upload-drop-zone {border: 2px dashed var(--el-border-color);border-radius: 8px;padding: 3rem 1rem;transition: all 0.3s ease;
}.upload-drop-zone:hover {border-color: var(--el-color-primary);background: rgba(var(--el-color-primary-rgb), 0.1);
}.upload-icon {font-size: 3rem;color: var(--el-text-color-secondary);margin-bottom: 1rem;
}.edit-section {display: grid;grid-template-columns: 2fr 1fr;gap: 2rem;
}.preview-area {background: var(--el-bg-color-page);border-radius: 8px;padding: 1rem;overflow: hidden;
}.canvas-container {position: relative;overflow: auto;
}.canvas-container canvas {max-width: 100%;height: auto;
}.control-panel {background: var(--el-bg-color-page);border-radius: 8px;padding: 1.5rem;
}.action-buttons {display: flex;gap: 1rem;margin-top: 2rem;
}:deep(.el-form-item) {margin-bottom: 1.5rem;
}:deep(.el-radio-group) {width: 100%;display: flex;flex-wrap: wrap;gap: 0.5rem;
}:deep(.el-radio-button__inner) {border-radius: 6px;
}.watermark-image-upload {width: 100%;
}.watermark-tabs {margin-bottom: 2rem;
}.batch-section {min-height: 400px;
}.batch-content {display: flex;flex-direction: column;gap: 2rem;
}.files-list {margin: 2rem 0;
}.batch-actions {display: flex;gap: 1rem;justify-content: center;margin-top: 2rem;
}.batch-main {display: grid;grid-template-columns: 2fr 1fr;gap: 2rem;margin-bottom: 2rem;
}.batch-footer {display: flex;flex-direction: column;gap: 1rem;
}.batch-main .preview-area {background: var(--el-bg-color-page);border-radius: 8px;padding: 1rem;overflow: hidden;min-height: 300px;
}.batch-main .canvas-container {position: relative;overflow: auto;display: flex;justify-content: center;align-items: center;
}.batch-main .canvas-container canvas {max-width: 100%;height: auto;box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
</style>
效果截图
注
其实该功能还有很大的优化空间,比如说可以随机生成指定数量的水印,按照某种角度(字体旋转45度等)随机排布在图片上。
也希望本文能对您能起到抛砖引玉的作用,愿我们在计算机之路上一路前行!