shuiyin / index.html
Ethscriptions's picture
Upload 5 files
53b24b2 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片水印批量生成工具</title>
<!-- PWA -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#ffffff">
<link rel="icon" type="image/svg+xml" href="icon.svg">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="icon.svg">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Libraries -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
<style>
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
/* Custom scrollbar for better look */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a8a8a8; }
.range-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
.range-slider::-moz-range-thumb { width: 16px; height: 16px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
</style>
</head>
<body class="bg-gray-50 text-gray-800 min-h-screen">
<!-- Navbar -->
<nav class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<div class="flex items-center gap-2">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<span class="font-bold text-xl tracking-tight text-gray-900">水印批量助手</span>
</div>
<div class="flex gap-3" id="top-actions" style="display: none;">
<button onclick="exportAll()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 shadow-sm transition-colors duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
批量导出图片
</button>
<button onclick="exportAllZip()" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-500 hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 shadow-sm transition-colors duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path></svg>
打包下载 (ZIP)
</button>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Sidebar: Global Settings -->
<div class="lg:col-span-4 xl:col-span-3">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 sticky top-24">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
全局设置
</h2>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded-full">自动保存</span>
</div>
<div class="space-y-5">
<!-- Text Inputs -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">影城名称</label>
<input type="text" id="global-cinema" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入影城名称" value="示例国际影城">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">影片名字</label>
<input type="text" id="global-movie" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入影片名字" value="默认影片名称">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">放映时间</label>
<input type="text" id="global-time" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="YYYY-MM-DD HH:MM" value="2026-03-16 19:30">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">共出票 (张)</label>
<input type="number" id="global-tickets" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入出票数量" value="120">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">额外文字1 (选填)</label>
<input type="text" id="global-extra1" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入额外文字1">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">额外文字2 (选填)</label>
<input type="text" id="global-extra2" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border" placeholder="输入额外文字2">
</div>
<div class="border-t border-gray-100 my-4"></div>
<!-- Style Controls -->
<div>
<div class="flex justify-between mb-1">
<label class="text-sm font-medium text-gray-700">文字大小</label>
<span class="text-xs text-gray-500" id="size-val">1.0x</span>
</div>
<input type="range" id="global-size" min="0.5" max="3.0" step="0.1" value="1.0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">文字颜色</label>
<div class="flex items-center gap-2">
<input type="color" id="global-color" value="#ffffff" class="h-8 w-12 p-0 border-0 rounded cursor-pointer">
<span class="text-xs text-gray-500">点击色块选择颜色</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">左右微调</label>
<input type="range" id="global-offsetX" min="-100" max="100" value="0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">上下微调</label>
<input type="range" id="global-offsetY" min="-100" max="100" value="0" class="range-slider w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
</div>
</div>
</div>
</div>
<!-- Right Content: Upload & Grid -->
<div class="lg:col-span-8 xl:col-span-9 space-y-6">
<!-- Upload Zone -->
<div id="drop-zone" class="relative border-2 border-dashed border-gray-300 rounded-xl p-10 text-center hover:border-blue-500 hover:bg-blue-50 transition-colors duration-200 cursor-pointer bg-white" onclick="document.getElementById('imageLoader').click()">
<input type="file" id="imageLoader" accept="image/*" multiple class="hidden">
<div class="space-y-2">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600 justify-center">
<span class="relative font-medium text-blue-600 rounded-md hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
点击上传图片
</span>
<p class="pl-1">或拖拽图片到这里</p>
</div>
<p class="text-xs text-gray-500">支持 PNG, JPG, GIF (可多选)</p>
</div>
</div>
<!-- Preview Grid -->
<div id="preview-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<!-- Cards will be injected here -->
</div>
<!-- Empty State -->
<div id="empty-state" class="text-center py-12 hidden">
<p class="text-gray-500 text-lg">暂无图片,请先上传</p>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="fixed inset-0 bg-gray-900 bg-opacity-50 z-[100] hidden items-center justify-center backdrop-blur-sm transition-opacity duration-300">
<div class="bg-white rounded-2xl shadow-xl p-8 max-w-sm w-full mx-4 transform transition-all">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-blue-600 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 id="loading-title" class="text-lg font-medium text-gray-900 mb-2">处理中</h3>
<p id="loading-text" class="text-sm text-gray-500 mb-4">请稍候...</p>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div id="loading-progress" class="bg-blue-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
</div>
</div>
</main>
<footer class="bg-white border-t border-gray-200 mt-12 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-400 text-sm">
<p>&copy; 2026 图片水印批量生成工具. All rights reserved.</p>
</div>
</footer>
<script>
// PWA Registration
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => console.log('Service Worker Registered!', reg))
.catch(err => console.log('Service Worker Failed!', err));
});
}
const imageLoader = document.getElementById('imageLoader');
const dropZone = document.getElementById('drop-zone');
const previewContainer = document.getElementById('preview-container');
const topActions = document.getElementById('top-actions');
const emptyState = document.getElementById('empty-state');
// Loading UI elements
const loadingOverlay = document.getElementById('loading-overlay');
const loadingTitle = document.getElementById('loading-title');
const loadingText = document.getElementById('loading-text');
const loadingProgress = document.getElementById('loading-progress');
let imageItems = []; // Store state
let watermarkWorker = new Worker('worker.js');
// Worker communication
watermarkWorker.onmessage = function(e) {
const { type, text, percent, blob, filename, error } = e.data;
if (type === 'PROGRESS') {
updateLoading(text, percent);
} else if (type === 'DONE') {
saveAs(blob, filename);
hideLoading();
} else if (type === 'DONE_SINGLE') {
saveAs(blob, filename);
} else if (type === 'DONE_ALL_SINGLE') {
hideLoading();
} else if (type === 'ERROR') {
alert('处理出错: ' + error);
hideLoading();
}
};
function showLoading(title, text) {
loadingTitle.innerText = title;
loadingText.innerText = text;
loadingProgress.style.width = '0%';
loadingOverlay.classList.remove('hidden');
loadingOverlay.classList.add('flex');
}
function updateLoading(text, percent) {
loadingText.innerText = text;
loadingProgress.style.width = `${percent}%`;
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
loadingOverlay.classList.remove('flex');
}
// Global Controls
const globalControls = {
cinema: document.getElementById('global-cinema'),
movie: document.getElementById('global-movie'),
time: document.getElementById('global-time'),
tickets: document.getElementById('global-tickets'),
extra1: document.getElementById('global-extra1'),
extra2: document.getElementById('global-extra2'),
size: document.getElementById('global-size'),
color: document.getElementById('global-color'),
offsetX: document.getElementById('global-offsetX'),
offsetY: document.getElementById('global-offsetY')
};
// Load Settings
function loadSettings() {
const saved = localStorage.getItem('watermarkSettings');
if (saved) {
try {
const settings = JSON.parse(saved);
Object.keys(settings).forEach(key => {
if (globalControls[key]) {
globalControls[key].value = settings[key];
if(key === 'size') document.getElementById('size-val').innerText = settings[key] + 'x';
}
});
} catch (e) { console.error('Failed to load settings', e); }
}
}
function saveSettings() {
const settings = {};
Object.keys(globalControls).forEach(key => {
settings[key] = globalControls[key].value;
});
localStorage.setItem('watermarkSettings', JSON.stringify(settings));
}
loadSettings();
// Bind Global Events
Object.keys(globalControls).forEach(key => {
const input = globalControls[key];
input.addEventListener('input', () => {
const newValue = input.value;
saveSettings();
if(key === 'size') document.getElementById('size-val').innerText = newValue + 'x';
// Use requestAnimationFrame for debouncing UI updates
requestAnimationFrame(() => {
imageItems.forEach(item => {
if (key === 'size') item.data.sizeScale = parseFloat(newValue);
else if (key === 'offsetX' || key === 'offsetY') item.data[key] = parseInt(newValue);
else item.data[key] = newValue;
const cardInputId = key === 'size' ? `size-${item.id}` : `${key}-${item.id}`;
const cardInput = document.getElementById(cardInputId);
if (cardInput) cardInput.value = newValue;
drawWatermarkPreview(item.canvas, item.thumbCanvas, item.data, item.originalWidth, item.originalHeight);
});
});
});
});
// File Upload Handling
imageLoader.addEventListener('change', handleImageUpload);
// Drag and Drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
const files = e.dataTransfer.files;
processFiles(files);
});
function handleImageUpload(e) {
processFiles(e.target.files);
e.target.value = ''; // Reset
}
function processFiles(files) {
if (files.length > 0) {
topActions.style.display = 'flex';
emptyState.style.display = 'none';
showLoading('加载图片中', '正在生成预览缩略图...');
}
const validFiles = Array.from(files).filter(file => file.type.match('image.*'));
let currentIndex = 0;
// 串行加载并生成缩略图,避免大量全尺寸图片同时进入内存
function loadNextImage() {
if (currentIndex >= validFiles.length) {
hideLoading();
return;
}
const file = validFiles[currentIndex];
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
// Generate thumbnail
const MAX_WIDTH = 800;
const scale = img.width > MAX_WIDTH ? MAX_WIDTH / img.width : 1;
const thumbWidth = img.width * scale;
const thumbHeight = img.height * scale;
const thumbCanvas = document.createElement('canvas');
thumbCanvas.width = thumbWidth;
thumbCanvas.height = thumbHeight;
const tCtx = thumbCanvas.getContext('2d');
tCtx.drawImage(img, 0, 0, thumbWidth, thumbHeight);
updateLoading(`处理 ${currentIndex + 1}/${validFiles.length}`, (currentIndex / validFiles.length) * 100);
createImageCard(file, thumbCanvas, file.name, img.width, img.height);
URL.revokeObjectURL(objectUrl);
currentIndex++;
requestAnimationFrame(() => setTimeout(loadNextImage, 10));
};
img.onerror = () => {
currentIndex++;
loadNextImage();
};
img.src = objectUrl;
}
loadNextImage();
}
function createImageCard(file, thumbCanvas, filename, originalWidth, originalHeight) {
const id = Date.now() + Math.random().toString(36).substr(2, 5);
const data = {
cinema: globalControls.cinema.value,
movie: globalControls.movie.value,
time: globalControls.time.value,
tickets: globalControls.tickets.value,
extra1: globalControls.extra1.value,
extra2: globalControls.extra2.value,
color: globalControls.color.value,
sizeScale: parseFloat(globalControls.size.value),
offsetX: parseInt(globalControls.offsetX.value),
offsetY: parseInt(globalControls.offsetY.value)
};
const card = document.createElement('div');
card.className = 'bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow duration-200 flex flex-col';
card.id = `card-${id}`;
card.innerHTML = `
<div class="relative bg-gray-100 border-b border-gray-100">
<canvas id="canvas-${id}" class="w-full h-auto block"></canvas>
<div class="absolute top-2 right-2">
<button onclick="removeCard('${id}')" class="p-1 bg-white rounded-full shadow text-gray-400 hover:text-red-500 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
</div>
<div class="p-4 space-y-3 flex-1">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">影城</label>
<input type="text" id="cinema-${id}" value="${data.cinema}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">影片</label>
<input type="text" id="movie-${id}" value="${data.movie}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">时间</label>
<input type="text" id="time-${id}" value="${data.time}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">票数</label>
<input type="number" id="tickets-${id}" value="${data.tickets}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">额外1</label>
<input type="text" id="extra1-${id}" value="${data.extra1 || ''}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">额外2</label>
<input type="text" id="extra2-${id}" value="${data.extra2 || ''}" class="w-full text-xs rounded border-gray-300 px-2 py-1 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<details class="text-xs text-gray-500 cursor-pointer">
<summary class="hover:text-blue-600 transition-colors font-medium">更多单独微调</summary>
<div class="mt-3 space-y-3 pt-2 border-t border-gray-100">
<div>
<div class="flex justify-between mb-1"><span>大小</span><span class="text-gray-400">倍数</span></div>
<input type="range" id="size-${id}" min="0.5" max="3.0" step="0.1" value="${data.sizeScale}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
<div class="flex items-center justify-between">
<span>颜色</span>
<input type="color" id="color-${id}" value="${data.color}" class="h-6 w-10 border-0 p-0 rounded">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<span class="block mb-1">X轴</span>
<input type="range" id="offsetX-${id}" min="-100" max="100" value="${data.offsetX}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
<div>
<span class="block mb-1">Y轴</span>
<input type="range" id="offsetY-${id}" min="-100" max="100" value="${data.offsetY}" class="w-full h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
</div>
</div>
</details>
</div>
<div class="p-4 bg-gray-50 border-t border-gray-100">
<button onclick="exportSingle('${id}')" class="w-full flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
导出此图
</button>
</div>
`;
previewContainer.appendChild(card);
const canvas = document.getElementById(`canvas-${id}`);
// Event Listeners
['cinema', 'movie', 'time', 'tickets', 'extra1', 'extra2'].forEach(key => {
document.getElementById(`${key}-${id}`).addEventListener('input', (e) => {
data[key] = e.target.value;
requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
});
});
document.getElementById(`size-${id}`).addEventListener('input', (e) => {
data.sizeScale = parseFloat(e.target.value);
requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
});
document.getElementById(`color-${id}`).addEventListener('input', (e) => {
data.color = e.target.value;
requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
});
document.getElementById(`offsetX-${id}`).addEventListener('input', (e) => {
data.offsetX = parseInt(e.target.value);
requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
});
document.getElementById(`offsetY-${id}`).addEventListener('input', (e) => {
data.offsetY = parseInt(e.target.value);
requestAnimationFrame(() => drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight));
});
const item = { id, filename, canvas, thumbCanvas, file, data, originalWidth, originalHeight };
imageItems.push(item);
drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight);
}
function removeCard(id) {
const card = document.getElementById(`card-${id}`);
if(card) card.remove();
imageItems = imageItems.filter(item => item.id !== id);
if(imageItems.length === 0) {
topActions.style.display = 'none';
emptyState.style.display = 'block';
}
}
// Draw Watermark on Thumbnail for Preview
function drawWatermarkPreview(canvas, thumbCanvas, data, originalWidth, originalHeight) {
const ctx = canvas.getContext('2d');
canvas.width = thumbCanvas.width;
canvas.height = thumbCanvas.height;
ctx.drawImage(thumbCanvas, 0, 0);
// Scale calculations based on original dimensions so preview matches export
const ratio = thumbCanvas.width / originalWidth;
const baseFontSize = Math.max(30, Math.floor(originalHeight * 0.03));
const originalFontSize = baseFontSize * (data.sizeScale || 1.0);
const fontSize = originalFontSize * ratio;
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = data.color || '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = Math.max(2, Math.floor(fontSize / 15));
const movieTitle = data.movie ? `《${data.movie}》` : '';
const lines = [
data.cinema,
movieTitle,
data.time,
data.tickets ? `共出票${data.tickets}张` : '',
data.extra1,
data.extra2
];
const validLines = lines.filter(line => line && line.trim() !== '');
const originalXOffset = (data.offsetX || 0) * (originalWidth / 1000);
const originalYOffset = (data.offsetY || 0) * (originalHeight / 1000);
const xOffset = originalXOffset * ratio;
const yOffset = originalYOffset * ratio;
const lineHeight = fontSize * 1.5;
const totalTextHeight = validLines.length * lineHeight;
const startY = canvas.height - totalTextHeight - (canvas.height * 0.05) + yOffset;
const x = (canvas.width / 2) + xOffset;
validLines.forEach((line, index) => {
const y = startY + (index * lineHeight) + fontSize;
ctx.strokeText(line, x, y);
ctx.fillText(line, x, y);
});
}
function exportSingle(id) {
const item = imageItems.find(i => i.id === id);
if (!item) return;
const data = item.data;
const sanitize = (str) => (str || '').replace(/[\\/:*?"<>|]/g, '_');
const cinema = sanitize(data.cinema);
const movie = sanitize(data.movie);
let formattedTime = data.time.replace(/\s+/g, '_').replace(/:/g, '-');
const tickets = sanitize(data.tickets);
const newName = `${cinema}_${movie}_${formattedTime}_共出票${tickets}张.jpg`;
// Dispatch to worker
watermarkWorker.postMessage({
type: 'EXPORT_SINGLE',
payload: { item: { file: item.file, data: item.data }, filename: newName }
});
}
function exportAll() {
if (imageItems.length === 0) return;
showLoading('批量导出中', '准备导出...');
const itemsPayload = imageItems.map(item => ({
file: item.file,
data: item.data
}));
watermarkWorker.postMessage({
type: 'EXPORT_ALL_SINGLE',
payload: { items: itemsPayload }
});
}
function exportAllZip() {
if (imageItems.length === 0) return;
const cinema = globalControls.cinema.value || '影城';
const movie = globalControls.movie.value || '影片';
const safeCinema = cinema.replace(/[\\/:*?"<>|]/g, '_');
const safeMovie = movie.replace(/[\\/:*?"<>|]/g, '_');
const zipName = `${safeCinema}_${safeMovie}出票返图.zip`;
showLoading('正在打包 ZIP', '初始化...');
const itemsPayload = imageItems.map(item => ({
file: item.file,
data: item.data
}));
watermarkWorker.postMessage({
type: 'EXPORT_ZIP',
payload: { items: itemsPayload, zipName }
});
}
</script>
</body>
</html>