Spaces:
Running
Running
| <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>© 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> |