// ================================================================ // Ozon Seller Kit - 应用逻辑 // 水印图片:web/imgs/watermark.jpg(需通过本地服务访问,否则画布会被污染无法导出) // 作为经典脚本加载(非 module)。 // ================================================================ const calculateBtn = document.getElementById('calculateBtn'); const purchasePriceInput = document.getElementById('purchasePrice'); const profitRateInput = document.getElementById('profitRate'); const weightInput = document.getElementById('weight'); const TDPriceInput = document.getElementById('tiedanPrice'); const lengthInput = document.getElementById('length'); const widthInput = document.getElementById('width'); const heightInput = document.getElementById('height'); const recordBtn = document.getElementById('recordBtn'); const recordCurrentBtn = document.getElementById('recordCurrentBtn'); const toggleOptionalColsBtn = document.getElementById('toggleOptionalColsBtn'); const historyTable = document.getElementById('historyTable'); const modelCodeInput = document.getElementById('modelCode'); const skuSuffixInput = document.getElementById('skuSuffix'); const skuPrefixEl = document.getElementById('skuPrefix'); const skuFullPreview = document.getElementById('skuFullPreview'); const productNameInput = document.getElementById('productName'); const purchaseUrlInput = document.getElementById('purchaseUrl'); const copyPurchaseUrlBtn = document.getElementById('copyPurchaseUrlBtn'); const openPurchaseUrlBtn = document.getElementById('openPurchaseUrlBtn'); const copyModelBtn = document.getElementById('copyModelBtn'); const historyTableBody = document.getElementById('historyTableBody'); const clearHistoryBtn = document.getElementById('clearHistoryBtn'); const logisticsFeeElement = document.getElementById('logisticsFee'); const receivedPriceElement = document.getElementById('receivedPrice'); const profitElement = document.getElementById('profitElement'); const commission = document.getElementById('commission'); const sellingPriceElement = document.getElementById('sellingPrice'); const logisticsCard = document.getElementById('logisticsCard'); const receivedCard = document.getElementById('receivedCard'); const sellingCard = document.getElementById('sellingCard'); const dimensionAlert = document.getElementById('dimensionAlert'); const dimensionWarning = document.getElementById('dimensionWarning'); const dimensionError = document.getElementById('dimensionError'); const dimensionWarningMsg = document.getElementById('dimensionWarningMsg'); const dimensionErrorMsg = document.getElementById('dimensionErrorMsg'); const logisticsLevelAlert = document.getElementById('logisticsLevelAlert'); const logisticsLevelMsg = document.getElementById('logisticsLevelMsg'); const priceRangeAlert = document.getElementById('priceRangeAlert'); const priceRangeMsg = document.getElementById('priceRangeMsg'); const resultOutput = document.getElementById('resultOutput'); const copyBtn = document.getElementById('copyBtn'); const transferDataBtn = document.getElementById('transferDataBtn'); const exportHistoryBtn = document.getElementById('exportHistoryBtn'); const clearPurchasePriceBtn = document.getElementById('clearPurchasePriceBtn'); let historyData = JSON.parse(localStorage.getItem('priceCalculatorHistory')) || []; const baseUrl = 'https://www.ozon.ru/highlight/tovary-iz-kitaya-935133/'; const copySkuBtn = document.getElementById('copySkuBtn'); const copyProductNameBtn = document.getElementById('copyProductNameBtn'); function getModelPrefix() { // 型号后默认加 "-",型号为空时不显示孤立的连字符 const model = (modelCodeInput.value || '').trim(); return model ? model + '-' : ''; } function getFullSku() { return (getModelPrefix() + (skuSuffixInput.value || '')).trim(); } function syncSkuPrefix() { skuPrefixEl.textContent = getModelPrefix(); const full = getFullSku(); skuFullPreview.textContent = full || '--'; } const uploadArea = document.getElementById('uploadArea'); const fileInput = document.getElementById('fileInput'); const previewContainer = document.getElementById('previewContainer'); const addWatermarkBtn = document.getElementById('addWatermarkBtn'); const exportAllBtn = document.getElementById('exportAllBtn'); const watermarkOpacityInput = document.getElementById('watermarkOpacity'); const watermarkTextInput = document.getElementById('watermarkText'); const whiteBgToleranceInput = document.getElementById('whiteBgTolerance'); const watermarkTypeRadios = document.querySelectorAll('input[name="watermarkType"]'); const watermarkImageOptions = document.getElementById('watermarkImageOptions'); const watermarkTextOptions = document.getElementById('watermarkTextOptions'); const imageToggleBtn = document.getElementById('imageToggleBtn'); const imageBody = document.getElementById('imageBody'); // 水印图片地址 const watermarkUrl = 'imgs/watermark.jpg'; const WATERMARK_SCALE = 0.15; // 水印直径占原图宽度的比例 const WATERMARK_MARGIN = 10; // 默认位置距右下角的间距(原图像素) const WATERMARK_TEXT_SCALE = 0.06; // 文字水印字号占原图宽度的比例 const DEFAULT_WHITE_BG_TOLERANCE = 60; // 白底颜色容差,越大清除得越狠 const PREVIEW_MAX_WIDTH = 400; // 预览画布宽度 const DEFAULT_WATERMARK_OPACITY = 30; // 存储上传的图片信息(原图 + 水印状态) let imageList = []; let watermarkImg = null; let watermarkImgPromise = null; renderHistoryTable(); transferDataBtn.addEventListener('click', () => { let output = ''; historyData.forEach((record) => { const sku = (record.sku || '').trim(); // 优先导出卢布预留价(挂牌价),旧记录回退到人民币现价 const price = record.sellingPriceRubReserved || record.sellingPrice; if (sku && price !== undefined && price !== '' && !isNaN(parseFloat(price))) { output += `${sku} ${parseFloat(price).toFixed(2)}\n`; } }); resultOutput.value = output; }); function normalizeUrl(value) { // 采买链接常常是直接粘贴的,可能缺少协议头 const text = (value || '').trim(); if (!text) return ''; return /^https?:\/\//i.test(text) ? text : `https://${text}`; } async function copyText(text, btn) { const value = (text || '').trim(); if (!value) return; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(value); } else { const ta = document.createElement('textarea'); ta.value = value; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } const originalHtml = btn.innerHTML; btn.innerHTML = ''; btn.classList.add('text-secondary'); setTimeout(() => { btn.innerHTML = originalHtml; btn.classList.remove('text-secondary'); }, 1200); } catch (e) { console.error('复制失败', e); } } copyModelBtn.addEventListener('click', () => { copyText(modelCodeInput.value, copyModelBtn); }); copySkuBtn.addEventListener('click', () => { copyText(getFullSku(), copySkuBtn); }); copyProductNameBtn.addEventListener('click', () => { copyText(productNameInput.value, copyProductNameBtn); }); copyPurchaseUrlBtn.addEventListener('click', () => { copyText(purchaseUrlInput.value, copyPurchaseUrlBtn); }); openPurchaseUrlBtn.addEventListener('click', () => { const url = normalizeUrl(purchaseUrlInput.value); if (url) window.open(url, '_blank', 'noopener'); }); modelCodeInput.addEventListener('input', syncSkuPrefix); skuSuffixInput.addEventListener('input', syncSkuPrefix); clearPurchasePriceBtn.addEventListener('click', () => { purchasePriceInput.value = ''; purchasePriceInput.focus(); }); calculateBtn.addEventListener('click', calculateAndDisplay); copyBtn.addEventListener('click', copyToClipboard); recordBtn.addEventListener('click', recordData); if (recordCurrentBtn) { recordCurrentBtn.addEventListener('click', recordData); } if (toggleOptionalColsBtn && historyTable) { toggleOptionalColsBtn.addEventListener('click', () => { const shown = historyTable.classList.toggle('show-optional-cols'); toggleOptionalColsBtn.setAttribute('aria-pressed', shown ? 'true' : 'false'); toggleOptionalColsBtn.textContent = shown ? '隐藏型号/重量/尺寸' : '显示型号/重量/尺寸'; }); } clearHistoryBtn.addEventListener('click', clearHistory); document.addEventListener('DOMContentLoaded', function () { const weightInput = document.getElementById('weight'); const weightPresetBtns = document.querySelectorAll('.weight-preset-btn'); weightPresetBtns.forEach(btn => { btn.addEventListener('click', function () { const weight = this.getAttribute('data-weight'); weightInput.value = weight; weightPresetBtns.forEach(b => b.classList.remove('bg-primary', 'text-white')); this.classList.add('bg-primary', 'text-white'); }); }); }); exportHistoryBtn.addEventListener('click', exportHistory); [purchasePriceInput, profitRateInput, weightInput, lengthInput, widthInput, heightInput, TDPriceInput].forEach(input => { input.addEventListener('keyup', function (event) { if (event.key === 'Enter') { calculateAndDisplay(); } }); }); document.addEventListener('DOMContentLoaded', function () { purchasePriceInput.value = '30'; profitRateInput.value = '100'; weightInput.value = '600'; lengthInput.value = '20'; widthInput.value = '15'; heightInput.value = '10'; TDPriceInput.value = '3'; document.querySelector('input[name="logisticsLevel"][value="low"]').checked = true; syncSkuPrefix(); calculateAndDisplay(); }); uploadArea.addEventListener('click', () => { fileInput.click(); }); // 拖拽上传处理 uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('border-primary'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('border-primary'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('border-primary'); if (e.dataTransfer.files.length) { handleFiles(e.dataTransfer.files); } }); // 文件选择后处理 fileInput.addEventListener('change', (e) => { if (e.target.files.length) { handleFiles(e.target.files); } }); // 处理上传的文件 function handleFiles(files) { imageList = []; if (files.length === 0) return; // 清空预览容器 previewContainer.innerHTML = ''; // 遍历文件并加载图片 Array.from(files).forEach((file, index) => { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { // 存储图片信息 const item = { index, original: img, whiteBg: null, // 白底处理后的 Canvas(与原图同尺寸) whiteBgRatio: 0, // 被判定为背景的像素占比 fileName: file.name, hasWatermark: false, pos: { x: 0, y: 0 } // 水印左上角在原图中的坐标 }; imageList.push(item); // 显示原始图片预览 renderPreview(item); }; img.src = e.target.result; }; reader.readAsDataURL(file); }); // 启用导出按钮(先显示原始图片,添加水印后更新) exportAllBtn.disabled = false; exportAllBtn.classList.remove('opacity-50', 'cursor-not-allowed'); } // 预加载水印图片,避免拖动时才开始加载 function loadWatermarkImage() { if (!watermarkImgPromise) { watermarkImgPromise = new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { watermarkImg = img; resolve(img); }; img.onerror = reject; img.src = watermarkUrl; }); } return watermarkImgPromise; } loadWatermarkImage().catch(err => console.error('水印加载失败:', err)); // 当前水印类型:'image' | 'text' function getWatermarkType() { const checked = document.querySelector('input[name="watermarkType"]:checked'); return checked ? checked.value : 'image'; } // 透明度输入框的值(0~1),图片水印和文字水印共用 function getWatermarkOpacity() { const value = parseFloat(watermarkOpacityInput.value); if (!isFinite(value)) return DEFAULT_WATERMARK_OPACITY / 100; return Math.min(100, Math.max(0, value)) / 100; } function getWatermarkText() { return (watermarkTextInput.value || '').trim(); } // 文字水印字体(按原图宽度等比缩放) function getWatermarkFontSize(item) { return Math.max(12, Math.round(item.original.width * WATERMARK_TEXT_SCALE)); } function getWatermarkFont(fontSize) { return `bold ${fontSize}px "PingFang SC", "Microsoft YaHei", Arial, sans-serif`; } // 量文字宽度用的离屏画布 const measureCtx = document.createElement('canvas').getContext('2d'); // 水印在原图坐标系中的尺寸 function getWatermarkSize(item) { if (getWatermarkType() === 'text') { const fontSize = getWatermarkFontSize(item); measureCtx.font = getWatermarkFont(fontSize); return { width: measureCtx.measureText(getWatermarkText()).width, height: fontSize * 1.25 }; } // 图片水印固定为正方形,绘制时裁成圆形 const diameter = item.original.width * WATERMARK_SCALE; return { width: diameter, height: diameter }; } // 限制水印不被拖出图片范围 function clampWatermarkPos(item, x, y) { const size = getWatermarkSize(item); const maxX = Math.max(0, item.original.width - size.width); const maxY = Math.max(0, item.original.height - size.height); return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }; } // 默认位置:右下角 function defaultWatermarkPos(item) { const size = getWatermarkSize(item); return clampWatermarkPos( item, item.original.width - size.width - WATERMARK_MARGIN, item.original.height - size.height - WATERMARK_MARGIN ); } // 圆形图片水印:取水印图中间的正方形,裁成圆形后绘制 function drawImageWatermark(ctx, x, y, size) { const source = Math.min(watermarkImg.width, watermarkImg.height); const sx = (watermarkImg.width - source) / 2; const sy = (watermarkImg.height - source) / 2; ctx.save(); ctx.beginPath(); ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); ctx.globalAlpha = getWatermarkOpacity(); ctx.drawImage(watermarkImg, sx, sy, source, source, x, y, size, size); ctx.restore(); } // 文字水印:白字 + 半透明描边,保证深浅背景上都能看清。 // 先画在离屏画布上再整体合成,避免透明度让描边透过字面显脏。 const textLayerCanvas = document.createElement('canvas'); function drawTextWatermark(ctx, x, y, fontSize) { const text = getWatermarkText(); if (!text) return; const font = getWatermarkFont(fontSize); measureCtx.font = font; const padding = Math.ceil(fontSize / 4); textLayerCanvas.width = Math.ceil(measureCtx.measureText(text).width) + padding * 2; textLayerCanvas.height = Math.ceil(fontSize * 1.5) + padding * 2; const layerCtx = textLayerCanvas.getContext('2d'); layerCtx.font = font; layerCtx.textBaseline = 'top'; layerCtx.lineWidth = Math.max(1, fontSize / 8); layerCtx.lineJoin = 'round'; layerCtx.strokeStyle = 'rgba(0, 0, 0, 0.55)'; layerCtx.strokeText(text, padding, padding); layerCtx.fillStyle = '#ffffff'; layerCtx.fillText(text, padding, padding); ctx.save(); ctx.globalAlpha = getWatermarkOpacity(); ctx.drawImage(textLayerCanvas, x - padding, y + fontSize * 0.1 - padding); ctx.restore(); } // 水印所需资源是否就绪 function isWatermarkReady() { return getWatermarkType() === 'text' ? true : !!watermarkImg; } function getWhiteBgTolerance() { const value = parseFloat(whiteBgToleranceInput.value); if (!isFinite(value)) return DEFAULT_WHITE_BG_TOLERANCE; return Math.min(200, Math.max(5, value)); } // 取边框像素每个通道的中位数作为背景基准色。 // 用中位数而不是均值:商品压到画面边缘时,均值会被商品颜色带偏。 function pickBorderColor(data, width, height) { const histR = new Uint32Array(256); const histG = new Uint32Array(256); const histB = new Uint32Array(256); let count = 0; function sample(x, y) { const i = (y * width + x) * 4; histR[data[i]]++; histG[data[i + 1]]++; histB[data[i + 2]]++; count++; } for (let x = 0; x < width; x++) { sample(x, 0); sample(x, height - 1); } for (let y = 1; y < height - 1; y++) { sample(0, y); sample(width - 1, y); } function median(hist) { const half = count / 2; let acc = 0; for (let v = 0; v < 256; v++) { acc += hist[v]; if (acc >= half) return v; } return 255; } return { r: median(histR), g: median(histG), b: median(histB) }; } // 白底处理:从四条边做区域生长,只把与边缘连通的背景像素刷白。 // 判定同时看两个条件:与相邻背景像素的局部色差(容忍渐变和噪点), // 以及与背景基准色的整体色差(防止顺着渐变一路吃进商品)。 // 商品内部的浅色区域不与边缘连通,因此不会被误伤。 function applyWhiteBackground(source, tolerance) { const width = source.width; const height = source.height; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(source, 0, 0); const imageData = ctx.getImageData(0, 0, width, height); const data = imageData.data; const origin = new Uint8ClampedArray(data); // 判定始终基于原始像素 const ref = pickBorderColor(origin, width, height); const globalLimit = tolerance; const localLimit = Math.max(6, tolerance / 3); const visited = new Uint8Array(width * height); const stack = new Int32Array(width * height); let top = 0; let filled = 0; function distanceTo(i, r, g, b) { const dr = origin[i] - r; const dg = origin[i + 1] - g; const db = origin[i + 2] - b; return Math.sqrt(dr * dr + dg * dg + db * db); } function accept(p) { visited[p] = 1; stack[top++] = p; const i = p * 4; data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; filled++; } // 种子:四条边上颜色接近基准色的像素 function trySeed(x, y) { const p = y * width + x; if (visited[p]) return; if (distanceTo(p * 4, ref.r, ref.g, ref.b) > globalLimit) return; accept(p); } for (let x = 0; x < width; x++) { trySeed(x, 0); trySeed(x, height - 1); } for (let y = 1; y < height - 1; y++) { trySeed(0, y); trySeed(width - 1, y); } // 生长:与来源像素颜色接近,且没有整体偏离基准色太远 function tryGrow(x, y, fromIndex) { const p = y * width + x; if (visited[p]) return; const i = p * 4; if (distanceTo(i, origin[fromIndex], origin[fromIndex + 1], origin[fromIndex + 2]) > localLimit) return; if (distanceTo(i, ref.r, ref.g, ref.b) > globalLimit) return; accept(p); } while (top > 0) { const p = stack[--top]; const i = p * 4; const x = p % width; const y = (p - x) / width; if (x > 0) tryGrow(x - 1, y, i); if (x < width - 1) tryGrow(x + 1, y, i); if (y > 0) tryGrow(x, y - 1, i); if (y < height - 1) tryGrow(x, y + 1, i); } ctx.putImageData(imageData, 0, 0); return { canvas, ratio: filled / (width * height) }; } // 按指定宽度把原图和水印合成到 Canvas function drawItemToCanvas(canvas, item, targetWidth) { const scale = targetWidth / item.original.width; canvas.width = Math.max(1, Math.round(targetWidth)); canvas.height = Math.max(1, Math.round(item.original.height * scale)); const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(item.whiteBg || item.original, 0, 0, canvas.width, canvas.height); if (item.hasWatermark && isWatermarkReady()) { const drawX = item.pos.x * scale; const drawY = item.pos.y * scale; if (getWatermarkType() === 'text') { drawTextWatermark(ctx, drawX, drawY, getWatermarkFontSize(item) * scale); } else { drawImageWatermark(ctx, drawX, drawY, getWatermarkSize(item).width * scale); } } return canvas; } // 让预览图上的水印可拖动 function attachWatermarkDrag(canvas, item) { let dragging = false; let grabOffsetX = 0; let grabOffsetY = 0; // 把指针位置换算成原图坐标(预览画布可能被 CSS 再次缩放) function toImageCoords(e) { const rect = canvas.getBoundingClientRect(); const scale = item.original.width / rect.width; return { x: (e.clientX - rect.left) * scale, y: (e.clientY - rect.top) * scale }; } canvas.addEventListener('pointerdown', (e) => { if (!item.hasWatermark || !isWatermarkReady()) return; const point = toImageCoords(e); const size = getWatermarkSize(item); const inside = point.x >= item.pos.x && point.x <= item.pos.x + size.width && point.y >= item.pos.y && point.y <= item.pos.y + size.height; if (!inside) return; dragging = true; grabOffsetX = point.x - item.pos.x; grabOffsetY = point.y - item.pos.y; canvas.setPointerCapture(e.pointerId); e.preventDefault(); }); canvas.addEventListener('pointermove', (e) => { if (!dragging) return; const point = toImageCoords(e); item.pos = clampWatermarkPos(item, point.x - grabOffsetX, point.y - grabOffsetY); drawItemToCanvas(canvas, item, PREVIEW_MAX_WIDTH); e.preventDefault(); }); function stopDrag(e) { if (!dragging) return; dragging = false; if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); } canvas.addEventListener('pointerup', stopDrag); canvas.addEventListener('pointercancel', stopDrag); } // 单张图片:切换是否贴水印 function toggleItemWatermark(item) { if (item.hasWatermark) { item.hasWatermark = false; } else { item.hasWatermark = true; item.pos = defaultWatermarkPos(item); } renderPreview(item); } // 单张图片:切换白底处理(再次点击还原原始底色) function toggleItemWhiteBackground(item, btn) { if (item.whiteBg) { item.whiteBg = null; renderPreview(item); return; } // 大图区域生长是同步的,先让按钮进入处理中状态再开工 btn.disabled = true; btn.textContent = '处理中…'; setTimeout(() => { try { const result = applyWhiteBackground(item.original, getWhiteBgTolerance()); item.whiteBg = result.canvas; item.whiteBgRatio = result.ratio; if (result.ratio < 0.02) { alert('几乎没有识别到背景,这张图的背景可能不是纯色。可以把「白底容差」调大一些再试'); } } catch (err) { alert('白底处理失败,请重试或换一张图'); console.error('白底处理失败:', err); } renderPreview(item); }, 0); } // 按当前状态刷新预览图右上角按钮的文案 function syncPreviewActions(previewItem, item) { const watermarkBtn = previewItem.querySelector('[data-role="toggle-watermark"]'); const whiteBgBtn = previewItem.querySelector('[data-role="toggle-white-bg"]'); watermarkBtn.textContent = item.hasWatermark ? '清除水印' : '加水印'; watermarkBtn.title = item.hasWatermark ? '这张图不加水印' : '给这张图加上水印'; whiteBgBtn.disabled = false; whiteBgBtn.textContent = item.whiteBg ? '还原底色' : '白底'; whiteBgBtn.title = item.whiteBg ? `已把 ${(item.whiteBgRatio * 100).toFixed(0)}% 的画面刷成白底,点击还原` : '把这张图的背景刷成纯白'; } // 渲染(或更新)单张图片的预览 function renderPreview(item) { let previewItem = previewContainer.querySelector(`[data-index="${item.index}"]`); if (!previewItem) { previewItem = document.createElement('div'); previewItem.dataset.index = item.index; previewItem.className = 'flex flex-col items-center'; const frame = document.createElement('div'); frame.className = 'preview-frame mb-2'; const canvas = document.createElement('canvas'); canvas.className = 'preview-img'; attachWatermarkDrag(canvas, item); frame.appendChild(canvas); const actions = document.createElement('div'); actions.className = 'preview-actions'; const watermarkBtn = document.createElement('button'); watermarkBtn.type = 'button'; watermarkBtn.className = 'preview-action-btn'; watermarkBtn.dataset.role = 'toggle-watermark'; watermarkBtn.onclick = () => toggleItemWatermark(item); actions.appendChild(watermarkBtn); const whiteBgBtn = document.createElement('button'); whiteBgBtn.type = 'button'; whiteBgBtn.className = 'preview-action-btn'; whiteBgBtn.dataset.role = 'toggle-white-bg'; whiteBgBtn.onclick = () => toggleItemWhiteBackground(item, whiteBgBtn); actions.appendChild(whiteBgBtn); frame.appendChild(actions); previewItem.appendChild(frame); const exportBtn = document.createElement('button'); exportBtn.className = 'bg-primary/20 hover:bg-primary/30 text-primary px-2 py-1 rounded-lg text-xs'; exportBtn.innerHTML = '导出'; exportBtn.onclick = () => exportImage(item.index); previewItem.appendChild(exportBtn); previewContainer.appendChild(previewItem); } const canvas = previewItem.querySelector('canvas'); canvas.classList.toggle('preview-img-draggable', item.hasWatermark); drawItemToCanvas(canvas, item, PREVIEW_MAX_WIDTH); syncPreviewActions(previewItem, item); } function renderAllPreviews() { imageList.forEach(renderPreview); } // 添加水印到图片 addWatermarkBtn.addEventListener('click', async () => { if (imageList.length === 0) { alert('请先上传图片'); return; } if (getWatermarkType() === 'text' && !getWatermarkText()) { alert('请先填写水印文字'); watermarkTextInput.focus(); return; } if (getWatermarkType() === 'image') { try { await loadWatermarkImage(); } catch (err) { alert('水印图片加载失败,请重试'); console.error('水印加载失败:', err); return; } } imageList.forEach(item => { if (!item.hasWatermark) { item.hasWatermark = true; item.pos = defaultWatermarkPos(item); } renderPreview(item); }); }); // 切换水印类型:显示对应的表单项,并把已有水印重新约束回图片范围内 function syncWatermarkTypeUI() { const isText = getWatermarkType() === 'text'; watermarkImageOptions.classList.toggle('hidden', isText); watermarkTextOptions.classList.toggle('hidden', !isText); imageList.forEach(item => { if (item.hasWatermark) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y); }); renderAllPreviews(); } watermarkTypeRadios.forEach(radio => radio.addEventListener('change', syncWatermarkTypeUI)); // 调整透明度 / 修改文字后实时刷新预览 watermarkOpacityInput.addEventListener('input', renderAllPreviews); watermarkTextInput.addEventListener('input', () => { imageList.forEach(item => { if (item.hasWatermark) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y); }); renderAllPreviews(); }); // 收起 / 展开整个图片处理面板 imageToggleBtn.addEventListener('click', () => { const collapsed = imageBody.classList.toggle('hidden'); imageToggleBtn.textContent = collapsed ? '展开' : '收起'; imageToggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); }); // 导出单张图片 function exportImage(index) { const targetImage = imageList.find(item => item.index === index); if (!targetImage) return; // 按原图尺寸重新合成,保证导出的是全分辨率图片 const canvas = drawItemToCanvas(document.createElement('canvas'), targetImage, targetImage.original.width); const baseName = targetImage.fileName.replace(/\.\w+$/, ''); const suffix = (targetImage.whiteBg ? '_white' : '') + (targetImage.hasWatermark ? '_watermark' : ''); // 创建下载链接 let dataUrl; try { dataUrl = canvas.toDataURL('image/png'); } catch (err) { // 以 file:// 打开时水印图会污染画布,导致无法导出 alert('导出失败,请通过本地服务访问页面(start.command)后重试'); console.error('导出失败:', err); return; } const link = document.createElement('a'); link.href = dataUrl; link.download = baseName + suffix + '.png'; link.click(); } // 导出所有图片 exportAllBtn.addEventListener('click', () => { if (imageList.length === 0) { alert('暂无图片可导出'); return; } // 批量导出(逐个触发下载) imageList.forEach((item, i) => { setTimeout(() => exportImage(item.index), i * 300); // 延迟避免浏览器拦截 }); }); function validateDimensions(weight, length, width, height, level) { const dimensions = [length, width, height].sort((a, b) => b - a); const longestSide = dimensions[0]; const sumOfSides = dimensions.reduce((sum, side) => sum + side, 0); let warning = ''; let error = ''; if (level === 'low') { if (weight <= 500) { if (sumOfSides > 90 || longestSide > 60) { error = '低等级物流重量≤500g时,要求三边之和≤90厘米且最长边≤60厘米。当前商品三边之和为' + sumOfSides.toFixed(2) + '厘米,最长边为' + longestSide.toFixed(2) + '厘米,不符合要求。'; } } else { if (sumOfSides > 150) { error = '低等级物流重量>500g时,要求三边之和≤150厘米。当前商品三边之和为' + sumOfSides.toFixed(2) + '厘米,不符合要求。'; } else if (longestSide > 60) { error = '低等级物流重量>500g时,要求最长边≤60厘米。当前商品最长边为' + longestSide.toFixed(2) + '厘米,不符合要求。'; } } } else { if (weight <= 2000) { if (sumOfSides > 150) { error = '高等级物流重量≤2000g时,要求三边之和≤150厘米。当前商品三边之和为' + sumOfSides.toFixed(2) + '厘米,不符合要求。'; } else if (longestSide > 60) { error = '高等级物流重量≤2000g时,要求最长边≤60厘米。当前商品最长边为' + longestSide.toFixed(2) + '厘米,不符合要求。'; } } else { if (sumOfSides > 250) { error = '高等级物流重量>2000g时,要求三边之和≤250厘米。当前商品三边之和为' + sumOfSides.toFixed(2) + '厘米,不符合要求。'; } else if (longestSide > 150) { error = '高等级物流重量>2000g时,要求最长边≤150厘米。当前商品最长边为' + longestSide.toFixed(2) + '厘米,不符合要求。'; } } } return { warning, error }; } function validateLogisticsLevel(sellingPrice, logisticsLevel) { let message = ''; if (sellingPrice > 140 && logisticsLevel === 'low') { message = '当前销售价格为' + sellingPrice.toFixed(2) + '元,超过140元,建议选择高等级物流以提供更好的服务体验。'; } else if (sellingPrice < 135 && logisticsLevel === 'high') { message = '当前销售价格为' + sellingPrice.toFixed(2) + '元,低于135元,建议选择低等级物流以降低成本。'; } return message; } function validatePriceRange(sellingPrice) { let message = ''; if (sellingPrice >= 135 && sellingPrice <= 140) { message = '当前销售价格为' + sellingPrice.toFixed(2) + '元,处于135-140元的区间。由于汇率波动,建议尽量避免此价格区间。'; } return message; } function calculateLogisticsFee(weight, length, width, height, level, TDPrice) { let logisticsFee = 0; let usedWeight = weight; if (level === 'low') { if (weight <= 500) { logisticsFee = 3.12 + 0.026 * weight; } else { logisticsFee = 23.92 + 0.01768 * usedWeight; } } else if (level === 'high2') { if (weight <= 5000) { logisticsFee = 22.88 + 0.026 * weight; } else { logisticsFee = 64.48 + 0.024 * usedWeight; } } else { if (weight <= 2000) { logisticsFee = 16.64 + 0.026 * weight; } else { logisticsFee = 37.44 + 0.01768 * usedWeight; } } logisticsFee += TDPrice; return { fee: logisticsFee }; } function calculateAndDisplay() { const purchasePrice = parseFloat(purchasePriceInput.value) || 0; console.log('222', purchasePrice) const profitRate = parseFloat(profitRateInput.value) || 0; const TDPrice = parseFloat(TDPriceInput.value) || 0; const weight = parseFloat(weightInput.value) || 0; const length = parseFloat(lengthInput.value) || 0; const width = parseFloat(widthInput.value) || 0; const height = parseFloat(heightInput.value) || 0; const logisticsLevel = document.querySelector('input[name="logisticsLevel"]:checked').value; if (purchasePrice <= 0 || weight <= 0 || length <= 0 || width <= 0 || height <= 0) { alert('请输入有效的商品信息(所有数值必须大于0)'); return; } const { warning, error } = validateDimensions(weight, length, width, height, logisticsLevel, TDPrice); dimensionAlert.classList.remove('hidden'); if (error) { dimensionErrorMsg.textContent = error; dimensionError.classList.remove('hidden'); } else { dimensionError.classList.add('hidden'); } if (warning) { dimensionWarningMsg.textContent = warning; dimensionWarning.classList.remove('hidden'); } else { dimensionWarning.classList.add('hidden'); } if (error) { logisticsFeeElement.textContent = '--'; receivedPriceElement.textContent = '--'; profitElement.textContent = '--' commission.textContent = '--'; sellingPriceElement.textContent = '--'; logisticsLevelAlert.classList.add('hidden'); priceRangeAlert.classList.add('hidden'); return; } const profitRateDecimal = profitRate / 100; const { fee: logisticsFee } = calculateLogisticsFee( weight, length, width, height, logisticsLevel, TDPrice ); const receivedPrice = purchasePrice * (1 + profitRateDecimal); const profitPrice = purchasePrice * profitRateDecimal; let commissionPrice let sellingPrice; if (logisticsLevel === 'low') { sellingPrice = (receivedPrice + logisticsFee) / 0.845; commissionPrice = sellingPrice * 0.12; } else { sellingPrice = (receivedPrice + logisticsFee) / 0.785; commissionPrice = sellingPrice * 0.18; } const logisticsLevelMessage = validateLogisticsLevel(sellingPrice, logisticsLevel); const priceRangeMessage = validatePriceRange(sellingPrice); if (logisticsLevelMessage) { logisticsLevelMsg.textContent = logisticsLevelMessage; logisticsLevelAlert.classList.remove('hidden'); } else { logisticsLevelAlert.classList.add('hidden'); } if (priceRangeMessage) { priceRangeMsg.textContent = priceRangeMessage; priceRangeAlert.classList.remove('hidden'); } else { priceRangeAlert.classList.add('hidden'); } setTimeout(() => { dimensionAlert.classList.add('opacity-100'); }, 10); logisticsFeeElement.textContent = `¥ ${logisticsFee.toFixed(2)}`; receivedPriceElement.textContent = `¥ ${receivedPrice.toFixed(2)}`; sellingPriceElement.textContent = `¥ ${sellingPrice.toFixed(2)}`; profitElement.textContent = `¥ ${profitPrice.toFixed(2)}` commission.textContent = `¥ ${commissionPrice.toFixed(2)}`; [logisticsCard, receivedCard, sellingCard].forEach((card, index) => { setTimeout(() => { card.classList.remove('opacity-0', 'translate-y-4'); }, index * 100); }); } function copyToClipboard() { resultOutput.select(); document.execCommand('copy'); const originalText = copyBtn.innerHTML; copyBtn.innerHTML = '复制成功'; copyBtn.classList.add('bg-green-600'); setTimeout(() => { copyBtn.innerHTML = originalText; copyBtn.classList.remove('bg-green-600'); }, 2000); } function parseDisplayMoney(text) { const n = parseFloat(String(text || '').replace(/[^\d.]/g, '')); return isNaN(n) ? '' : n.toFixed(2); } function formatCny(value) { const n = parseFloat(value); return isNaN(n) ? '--' : `¥ ${n.toFixed(2)}`; } function formatRub(value) { const n = parseFloat(value); return isNaN(n) ? '--' : `₽ ${n.toFixed(2)}`; } function isFormValueEmpty(value) { return value === undefined || value === null || String(value).trim() === ''; } function validateProductInfoForm() { const fields = [ { label: '进货价', el: purchasePriceInput, value: purchasePriceInput && purchasePriceInput.value }, { label: '利润率', el: profitRateInput, value: profitRateInput && profitRateInput.value }, { label: '商品重量', el: weightInput, value: weightInput && weightInput.value }, { label: '贴单费用', el: TDPriceInput, value: TDPriceInput && TDPriceInput.value }, { label: '外包装长度', el: lengthInput, value: lengthInput && lengthInput.value }, { label: '外包装宽度', el: widthInput, value: widthInput && widthInput.value }, { label: '外包装高度', el: heightInput, value: heightInput && heightInput.value }, { label: '型号', el: modelCodeInput, value: modelCodeInput && modelCodeInput.value }, { label: '货号 (sku)', el: skuSuffixInput, value: skuSuffixInput && skuSuffixInput.value }, { label: '商品名', el: productNameInput, value: productNameInput && productNameInput.value }, { label: '采买地址', el: purchaseUrlInput, value: purchaseUrlInput && purchaseUrlInput.value } ]; const missing = fields.filter((f) => isFormValueEmpty(f.value)); if (missing.length === 0) { return true; } alert(`请完整填写「输入商品信息」,以下项不能为空:\n${missing.map((f) => f.label).join('、')}`); const first = missing[0].el; if (first && typeof first.focus === 'function') { first.focus(); } return false; } function recordData() { if (!validateProductInfoForm()) { return; } const sku = getFullSku(); if (!sku) { alert('请先填写货号 (sku)'); if (skuSuffixInput) skuSuffixInput.focus(); return; } const sellingPrice = parseDisplayMoney(sellingPriceElement.textContent); if (!sellingPrice) { alert('请先点击「计价」,再录入商品'); return; } const skuKey = sku.toLowerCase(); const duplicated = historyData.findIndex( (item) => ((item && item.sku) || '').trim().toLowerCase() === skuKey ); if (duplicated !== -1) { alert(`货号「${sku}」已存在于上品登记表(第 ${duplicated + 1} 条),请勿重复录入`); return; } const productName = productNameInput.value.trim(); const logisticsFee = parseDisplayMoney(logisticsFeeElement.textContent); const receivedPrice = parseDisplayMoney(receivedPriceElement.textContent); const purchasePrice = purchasePriceInput.value; const weight = weightInput.value; const length = lengthInput.value; const width = widthInput.value; const height = heightInput.value; const logisticsLevel = document.querySelector('input[name="logisticsLevel"]:checked').value; const discountReserveEl = document.getElementById('discountReserve'); let discountReserve = parseFloat(discountReserveEl && discountReserveEl.value); if (isNaN(discountReserve) || discountReserve < 0) discountReserve = 0; if (discountReserve > 95) discountReserve = 95; const purchaseNum = parseFloat(purchasePrice); const receivedNum = parseFloat(receivedPrice); const profit = (!isNaN(receivedNum) && !isNaN(purchaseNum)) ? (receivedNum - purchaseNum).toFixed(2) : ''; const profitRate = (!isNaN(purchaseNum) && purchaseNum > 0 && profit !== '') ? ((parseFloat(profit) / purchaseNum) * 100).toFixed(0) : ''; const record = { sku, modelCode: (modelCodeInput.value || '').trim(), skuSuffix: (skuSuffixInput.value || '').trim(), productName, purchaseUrl: (purchaseUrlInput.value || '').trim(), sellingPrice, sellingPriceReserved: parseDisplayMoney(document.getElementById('sellingPriceReserved').textContent), sellingPriceRub: parseDisplayMoney(document.getElementById('sellingPriceRub').textContent), sellingPriceRubReserved: parseDisplayMoney(document.getElementById('sellingPriceRubReserved').textContent), discountReserve: discountReserve.toFixed(0), exchangeRate: cnyToRubRate ? cnyToRubRate.toFixed(4) : '', logisticsFee, receivedPrice, purchasePrice, profit, profitRate, weight, dimensions: `${length}x${width}x${height}`, logisticsLevel: logisticsLevel === 'high' ? '高' : (logisticsLevel === 'high2' ? 'Premium' : '低') }; historyData.unshift(record); localStorage.setItem('priceCalculatorHistory', JSON.stringify(historyData)); renderHistoryTable(); } function createLinkCell(url, label) { const cell = document.createElement('td'); cell.className = 'px-4 py-3 whitespace-nowrap'; if (!url) { cell.classList.add('text-sm', 'text-gray-500'); cell.textContent = '--'; return cell; } const link = document.createElement('a'); link.href = url; link.className = 'text-sm text-primary hover:underline block max-w-[14rem] truncate'; link.textContent = label || url; link.title = label || url; link.target = '_blank'; link.rel = 'noopener'; cell.appendChild(link); return cell; } function getRecordProfit(record) { const purchaseNum = parseFloat(record.purchasePrice); const receivedNum = parseFloat(record.receivedPrice); if (record.profit !== undefined && record.profit !== '') { return String(record.profit); } if (!isNaN(receivedNum) && !isNaN(purchaseNum)) { return (receivedNum - purchaseNum).toFixed(2); } return ''; } function getRecordProfitRate(record, profit) { const purchaseNum = parseFloat(record.purchasePrice); if (record.profitRate !== undefined && record.profitRate !== '') { return `${record.profitRate}%`; } if (!isNaN(purchaseNum) && purchaseNum > 0 && profit !== '') { return `${((parseFloat(profit) / purchaseNum) * 100).toFixed(0)}%`; } return ''; } function renderHistoryTable() { const colCount = 15; if (historyData.length === 0) { historyTableBody.innerHTML = `