1
0
forked from zguiy/utils
Files
utils/src/components/tools/ImageWatermark.vue
2025-06-28 22:38:49 +08:00

773 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="triggerFileInput"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'upload']" class="mr-2" />
上传图片
</button>
<button
@click="addTextWatermark"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'font']" class="mr-2" />
文字水印
</button>
<button
@click="triggerWatermarkInput"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
图片水印
</button>
<button
@click="downloadImage"
:disabled="!processedImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载图片
</button>
<button
@click="resetImage"
:disabled="!originalImage"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'undo']" class="mr-2" />
重置
</button>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleImageUpload"
>
<input
ref="watermarkInput"
type="file"
accept="image/*"
class="hidden"
@change="handleWatermarkUpload"
>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 设置区域 -->
<div class="space-y-4">
<!-- 文字水印设置 -->
<div v-if="watermarkType === 'text'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">文字水印设置</h3>
<div class="space-y-4">
<!-- 水印文字 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">水印文字</label>
<input
v-model="textWatermark.text"
type="text"
placeholder="请输入水印文字"
class="input-field"
@input="updateWatermark"
>
</div>
<!-- 字体大小 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
字体大小: {{ textWatermark.fontSize }}px
</label>
<input
v-model.number="textWatermark.fontSize"
type="range"
min="12"
max="120"
step="2"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体颜色 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体颜色</label>
<div class="flex space-x-2">
<input
v-model="textWatermark.color"
type="color"
class="w-12 h-8 rounded border border-primary border-opacity-20"
@input="updateWatermark"
>
<input
v-model="textWatermark.color"
type="text"
class="input-field flex-1"
@input="updateWatermark"
>
</div>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(textWatermark.opacity * 100) }}%
</label>
<input
v-model.number="textWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 字体样式 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">字体样式</label>
<div class="flex space-x-2">
<button
@click="toggleFontStyle('bold')"
:class="['btn-sm', textWatermark.fontWeight === 'bold' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'bold']" />
</button>
<button
@click="toggleFontStyle('italic')"
:class="['btn-sm', textWatermark.fontStyle === 'italic' ? 'btn-primary' : 'btn-secondary']"
>
<FontAwesomeIcon :icon="['fas', 'italic']" />
</button>
</div>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ textWatermark.rotation }}°
</label>
<input
v-model.number="textWatermark.rotation"
type="range"
min="-45"
max="45"
step="5"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 图片水印设置 -->
<div v-if="watermarkType === 'image'" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片水印设置</h3>
<div class="space-y-4">
<!-- 水印图片预览 -->
<div v-if="watermarkImage">
<label class="block text-sm font-medium text-secondary mb-2">水印图片</label>
<div class="bg-block rounded-lg p-4">
<img :src="watermarkImage" alt="水印图片" class="max-w-full h-20 object-contain mx-auto">
</div>
</div>
<!-- 缩放比例 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
缩放比例: {{ Math.round(imageWatermark.scale * 100) }}%
</label>
<input
v-model.number="imageWatermark.scale"
type="range"
min="0.1"
max="2"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 透明度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
透明度: {{ Math.round(imageWatermark.opacity * 100) }}%
</label>
<input
v-model.number="imageWatermark.opacity"
type="range"
min="0.1"
max="1"
step="0.1"
class="w-full"
@input="updateWatermark"
>
</div>
<!-- 旋转角度 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
旋转角度: {{ imageWatermark.rotation }}°
</label>
<input
v-model.number="imageWatermark.rotation"
type="range"
min="-180"
max="180"
step="15"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 位置设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">位置设置</h3>
<div class="space-y-4">
<!-- 预设位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">快速定位</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="(pos, key) in positions"
:key="key"
@click="setPosition(key)"
:class="['btn-sm text-xs', currentPosition.key === key ? 'btn-primary' : 'btn-secondary']"
>
{{ pos.name }}
</button>
</div>
</div>
<!-- 自定义位置 -->
<div>
<label class="block text-sm font-medium text-secondary mb-2">
X坐标: {{ currentPosition.x }}px
</label>
<input
v-model.number="currentPosition.x"
type="range"
:min="0"
:max="canvasWidth"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
Y坐标: {{ currentPosition.y }}px
</label>
<input
v-model.number="currentPosition.y"
type="range"
:min="0"
:max="canvasHeight"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
<!-- 批量水印设置 -->
<div v-if="originalImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">批量水印</h3>
<div class="space-y-3">
<label class="flex items-center space-x-2">
<input
v-model="batchSettings.enabled"
type="checkbox"
class="form-checkbox"
@change="updateWatermark"
>
<span class="text-secondary">启用平铺水印</span>
</label>
<div v-if="batchSettings.enabled" class="space-y-3">
<div>
<label class="block text-sm font-medium text-secondary mb-2">
水平间距: {{ batchSettings.spacingX }}px
</label>
<input
v-model.number="batchSettings.spacingX"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">
垂直间距: {{ batchSettings.spacingY }}px
</label>
<input
v-model.number="batchSettings.spacingY"
type="range"
min="50"
max="300"
step="10"
class="w-full"
@input="updateWatermark"
>
</div>
</div>
</div>
</div>
</div>
<!-- 预览区域 -->
<div class="lg:col-span-2 space-y-4">
<!-- 原图预览 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片预览</h3>
<div v-if="!originalImage" class="bg-block rounded-lg p-8 text-center">
<FontAwesomeIcon :icon="['fas', 'cloud-upload-alt']" class="text-4xl text-tertiary mb-4" />
<p class="text-secondary mb-4">请上传图片或拖拽图片到此处</p>
<button @click="triggerFileInput" class="btn-primary">
选择图片
</button>
</div>
<div v-else class="relative">
<canvas
ref="previewCanvas"
class="max-w-full border border-primary border-opacity-20 rounded-lg cursor-crosshair"
@click="handleCanvasClick"
/>
<!-- 图片信息 -->
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>{{ imageInfo.width }} × {{ imageInfo.height }}</span>
<span>{{ imageInfo.size }}</span>
<span>{{ imageInfo.format }}</span>
</div>
</div>
</div>
<!-- 处理结果 -->
<div v-if="processedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">处理结果</h3>
<div class="bg-block rounded-lg p-4">
<img :src="processedImage" alt="处理后的图片" class="max-w-full mx-auto">
</div>
<div class="mt-4 flex justify-between items-center text-sm text-secondary">
<span>质量: {{ outputQuality }}%</span>
<span>大小: {{ outputSize }}</span>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// DOM引用
const fileInput = ref<HTMLInputElement>()
const watermarkInput = ref<HTMLInputElement>()
const previewCanvas = ref<HTMLCanvasElement>()
// 响应式状态
const originalImage = ref('')
const processedImage = ref('')
const watermarkImage = ref('')
const watermarkType = ref<'text' | 'image' | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// 图片信息
const imageInfo = reactive({
width: 0,
height: 0,
size: '',
format: ''
})
// Canvas尺寸
const canvasWidth = ref(0)
const canvasHeight = ref(0)
// 文字水印设置
const textWatermark = reactive({
text: 'Sample Watermark',
fontSize: 36,
color: '#ffffff',
opacity: 0.7,
fontWeight: 'normal',
fontStyle: 'normal',
rotation: 0
})
// 图片水印设置
const imageWatermark = reactive({
scale: 0.3,
opacity: 0.7,
rotation: 0
})
// 位置设置
const currentPosition = reactive({
x: 0,
y: 0,
key: 'center'
})
// 批量水印设置
const batchSettings = reactive({
enabled: false,
spacingX: 150,
spacingY: 150
})
// 预设位置
const positions = {
'top-left': { name: '左上', x: 0.1, y: 0.1 },
'top-center': { name: '居上', x: 0.5, y: 0.1 },
'top-right': { name: '右上', x: 0.9, y: 0.1 },
'center-left': { name: '居左', x: 0.1, y: 0.5 },
'center': { name: '居中', x: 0.5, y: 0.5 },
'center-right': { name: '居右', x: 0.9, y: 0.5 },
'bottom-left': { name: '左下', x: 0.1, y: 0.9 },
'bottom-center': { name: '居下', x: 0.5, y: 0.9 },
'bottom-right': { name: '右下', x: 0.9, y: 0.9 }
}
// 输出设置
const outputQuality = computed(() => 90)
const outputSize = computed(() => {
if (!processedImage.value) return ''
return '估算大小'
})
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click()
}
const triggerWatermarkInput = () => {
watermarkInput.value?.click()
}
// 处理图片上传
const handleImageUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
originalImage.value = e.target?.result as string
loadImageInfo(file)
nextTick(() => {
setupCanvas()
})
}
reader.readAsDataURL(file)
}
// 处理水印图片上传
const handleWatermarkUpload = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
const reader = new FileReader()
reader.onload = (e) => {
watermarkImage.value = e.target?.result as string
watermarkType.value = 'image'
updateWatermark()
}
reader.readAsDataURL(file)
}
// 加载图片信息
const loadImageInfo = (file: File) => {
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.size = formatFileSize(file.size)
imageInfo.format = file.type.split('/')[1].toUpperCase()
}
img.src = originalImage.value
}
// 设置Canvas
const setupCanvas = () => {
if (!previewCanvas.value || !originalImage.value) return
const img = new Image()
img.onload = () => {
const canvas = previewCanvas.value!
const ctx = canvas.getContext('2d')!
// 设置画布尺寸
const maxWidth = 600
const maxHeight = 400
let { width, height } = img
if (width > maxWidth) {
height = (height * maxWidth) / width
width = maxWidth
}
if (height > maxHeight) {
width = (width * maxHeight) / height
height = maxHeight
}
canvas.width = width
canvas.height = height
canvasWidth.value = width
canvasHeight.value = height
// 绘制原图
ctx.drawImage(img, 0, 0, width, height)
// 设置默认水印位置
setPosition('bottom-right')
}
img.src = originalImage.value
}
// 添加文字水印
const addTextWatermark = () => {
watermarkType.value = 'text'
updateWatermark()
}
// 更新水印
const updateWatermark = () => {
if (!previewCanvas.value || !originalImage.value || !watermarkType.value) return
const canvas = previewCanvas.value
const ctx = canvas.getContext('2d')!
// 重新绘制原图
const img = new Image()
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
// 绘制水印
if (watermarkType.value === 'text') {
drawTextWatermark(ctx)
} else if (watermarkType.value === 'image' && watermarkImage.value) {
drawImageWatermark(ctx)
}
// 生成处理后的图片
processedImage.value = canvas.toDataURL('image/jpeg', outputQuality.value / 100)
}
img.src = originalImage.value
}
// 绘制文字水印
const drawTextWatermark = (ctx: CanvasRenderingContext2D) => {
ctx.save()
// 设置字体
const fontStyle = textWatermark.fontStyle === 'italic' ? 'italic ' : ''
const fontWeight = textWatermark.fontWeight === 'bold' ? 'bold ' : ''
ctx.font = `${fontStyle}${fontWeight}${textWatermark.fontSize}px Arial`
// 设置颜色和透明度
ctx.fillStyle = textWatermark.color
ctx.globalAlpha = textWatermark.opacity
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleTextWatermark(ctx, x + 50, y + 50)
}
}
} else {
// 单个水印
drawSingleTextWatermark(ctx, currentPosition.x, currentPosition.y)
}
ctx.restore()
}
// 绘制单个文字水印
const drawSingleTextWatermark = (ctx: CanvasRenderingContext2D, x: number, y: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((textWatermark.rotation * Math.PI) / 180)
// 添加阴影效果
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 1
ctx.shadowOffsetY = 1
ctx.fillText(textWatermark.text, 0, 0)
ctx.restore()
}
// 绘制图片水印
const drawImageWatermark = (ctx: CanvasRenderingContext2D) => {
const watermarkImg = new Image()
watermarkImg.onload = () => {
ctx.save()
ctx.globalAlpha = imageWatermark.opacity
const scaledWidth = watermarkImg.width * imageWatermark.scale
const scaledHeight = watermarkImg.height * imageWatermark.scale
if (batchSettings.enabled) {
// 平铺水印
for (let x = 0; x < canvasWidth.value; x += batchSettings.spacingX) {
for (let y = 0; y < canvasHeight.value; y += batchSettings.spacingY) {
drawSingleImageWatermark(ctx, watermarkImg, x, y, scaledWidth, scaledHeight)
}
}
} else {
// 单个水印
drawSingleImageWatermark(ctx, watermarkImg, currentPosition.x, currentPosition.y, scaledWidth, scaledHeight)
}
ctx.restore()
}
watermarkImg.src = watermarkImage.value
}
// 绘制单个图片水印
const drawSingleImageWatermark = (ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, width: number, height: number) => {
ctx.save()
ctx.translate(x, y)
ctx.rotate((imageWatermark.rotation * Math.PI) / 180)
ctx.drawImage(img, -width / 2, -height / 2, width, height)
ctx.restore()
}
// 设置位置
const setPosition = (key: string) => {
const pos = positions[key as keyof typeof positions]
if (pos) {
currentPosition.x = canvasWidth.value * pos.x
currentPosition.y = canvasHeight.value * pos.y
currentPosition.key = key
updateWatermark()
}
}
// 处理Canvas点击
const handleCanvasClick = (event: MouseEvent) => {
if (!previewCanvas.value) return
const rect = previewCanvas.value.getBoundingClientRect()
const scaleX = canvasWidth.value / rect.width
const scaleY = canvasHeight.value / rect.height
currentPosition.x = (event.clientX - rect.left) * scaleX
currentPosition.y = (event.clientY - rect.top) * scaleY
currentPosition.key = 'custom'
updateWatermark()
}
// 切换字体样式
const toggleFontStyle = (style: 'bold' | 'italic') => {
if (style === 'bold') {
textWatermark.fontWeight = textWatermark.fontWeight === 'bold' ? 'normal' : 'bold'
} else {
textWatermark.fontStyle = textWatermark.fontStyle === 'italic' ? 'normal' : 'italic'
}
updateWatermark()
}
// 下载图片
const downloadImage = () => {
if (!processedImage.value) return
const link = document.createElement('a')
link.download = `watermarked-image-${Date.now()}.jpg`
link.href = processedImage.value
link.click()
showStatus('图片下载完成', 'success')
}
// 重置图片
const resetImage = () => {
originalImage.value = ''
processedImage.value = ''
watermarkImage.value = ''
watermarkType.value = null
statusMessage.value = ''
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>