// ================================================================ // 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 totalCostElement = document.getElementById('totalCost'); const fullCommissionElement = document.getElementById('fullCommission'); const logisticsFeeRuleEl = document.getElementById('logisticsFeeRule'); const commissionRuleEl = document.getElementById('commissionRule'); const fullCommissionRuleEl = document.getElementById('fullCommissionRule'); const logisticsCard = document.getElementById('logisticsCard'); const totalCostCard = document.getElementById('totalCostCard'); 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 clearAllImagesBtn = document.getElementById('clearAllImagesBtn'); 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 WATERMARK_IMAGE_SHRINK = 0.8; // 加图片水印后,图片缩到当前尺寸的 80% const DEFAULT_WHITE_BG_TOLERANCE = 60; // 白底颜色容差,越大清除得越狠 const PREVIEW_MAX_WIDTH = 400; // 预览画布宽度 const DEFAULT_WATERMARK_OPACITY = 30; // 存储上传的图片信息(原图 + 水印状态) let imageList = []; // 自增序号,保证每张图片拥有全局唯一的 index(多批追加时 data-index 不冲突) let imageSeq = 0; 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) { if (files.length === 0) return; // 遍历文件并加载图片 Array.from(files).forEach((file) => { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { // 存储图片信息(index 使用全局自增序号,保证唯一,避免多批追加时冲突) const item = { index: imageSeq++, original: img, whiteBg: null, // 白底处理后的 Canvas(与原图同尺寸) whiteBgRatio: 0, // 被判定为背景的像素占比 fileName: file.name, watermarkType: null, // 水印类型:'image' | 'text' | null(无) pos: { x: 0, y: 0 } // 水印左上角在原图中的坐标 }; imageList.push(item); // 上传后默认加上当前选择的水印(默认图片水印) item.watermarkType = getWatermarkType(); item.pos = defaultWatermarkPos(item, item.watermarkType); // 显示原始图片预览 renderPreview(item); // 图片水印需确保水印图加载完成后再重绘一次,避免“只缩小没水印” if (item.watermarkType === 'image') { loadWatermarkImage().then(() => renderPreview(item)).catch(() => {}); } // 每张图片加载完成后刷新按钮状态,确保有图即可操作 updateImageActionsState(); }; img.src = e.target.result; }; reader.readAsDataURL(file); }); } function showEmptyPreviewPlaceholder() { previewContainer.innerHTML = '
暂无图片
'; } function updateImageActionsState() { const hasImages = imageList.length > 0; exportAllBtn.disabled = !hasImages; exportAllBtn.classList.toggle('opacity-50', !hasImages); exportAllBtn.classList.toggle('cursor-not-allowed', !hasImages); clearAllImagesBtn.disabled = !hasImages; clearAllImagesBtn.classList.toggle('opacity-50', !hasImages); clearAllImagesBtn.classList.toggle('cursor-not-allowed', !hasImages); } function deleteItem(item) { const idx = imageList.indexOf(item); if (idx === -1) return; imageList.splice(idx, 1); const previewItem = previewContainer.querySelector(`[data-index="${item.index}"]`); if (previewItem) previewItem.remove(); if (imageList.length === 0) { showEmptyPreviewPlaceholder(); } updateImageActionsState(); } function clearAllImages() { if (imageList.length === 0) return; imageList = []; fileInput.value = ''; showEmptyPreviewPlaceholder(); updateImageActionsState(); } // 预加载水印图片,避免拖动时才开始加载 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'); // 水印在原图坐标系中的尺寸(type 不传时取全局当前选择) function getWatermarkSize(item, type = getWatermarkType()) { if (type === '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, type = getWatermarkType()) { const size = getWatermarkSize(item, type); 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, type = getWatermarkType()) { const size = getWatermarkSize(item, type); 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 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 // 图片水印会让整张图缩到当前的 80%(renderScale),文字水印保持原尺寸 function drawItemToCanvas(canvas, item, targetWidth) { const renderScale = item.watermarkType === 'image' ? WATERMARK_IMAGE_SHRINK : 1; const scale = targetWidth / item.original.width; canvas.width = Math.max(1, Math.round(targetWidth * renderScale)); canvas.height = Math.max(1, Math.round(item.original.height * scale * renderScale)); 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.watermarkType && (item.watermarkType === 'text' || !!watermarkImg)) { const drawX = item.pos.x * scale * renderScale; const drawY = item.pos.y * scale * renderScale; if (item.watermarkType === 'text') { drawTextWatermark(ctx, drawX, drawY, getWatermarkFontSize(item) * scale * renderScale); } else { drawImageWatermark(ctx, drawX, drawY, getWatermarkSize(item, 'image').width * scale * renderScale); } } 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.watermarkType || (item.watermarkType === 'image' && !watermarkImg)) return; const point = toImageCoords(e); const size = getWatermarkSize(item, item.watermarkType); 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.watermarkType) { item.watermarkType = null; } else { item.watermarkType = getWatermarkType(); item.pos = defaultWatermarkPos(item, item.watermarkType); } renderPreview(item); } // 按当前状态刷新预览图右上角按钮的文案 function syncPreviewActions(previewItem, item) { const watermarkBtn = previewItem.querySelector('[data-role="toggle-watermark"]'); const deleteBtn = previewItem.querySelector('[data-role="delete-item"]'); watermarkBtn.textContent = item.watermarkType ? '清除水印' : '加水印'; watermarkBtn.title = item.watermarkType ? '这张图不加水印' : '给这张图加上水印'; deleteBtn.textContent = '删除'; deleteBtn.title = '从列表中移除这张图片'; } // 渲染(或更新)单张图片的预览 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 deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'preview-action-btn'; deleteBtn.dataset.role = 'delete-item'; deleteBtn.onclick = () => deleteItem(item); actions.appendChild(deleteBtn); 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.watermarkType); 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 => { // 不论之前是什么水印,都用当前选择的水印类型重做:清除旧水印、加入新水印 item.watermarkType = getWatermarkType(); item.pos = defaultWatermarkPos(item, item.watermarkType); renderPreview(item); }); }); // 切换水印类型:只切换对应的表单项显示,不改动已有图片的水印 // (已有图片的水印类型只有在点击「添加水印」时才会被替换) function syncWatermarkTypeUI() { const isText = getWatermarkType() === 'text'; watermarkImageOptions.classList.toggle('hidden', isText); watermarkTextOptions.classList.toggle('hidden', !isText); imageList.forEach(item => { if (item.watermarkType) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y, item.watermarkType); }); renderAllPreviews(); } watermarkTypeRadios.forEach(radio => radio.addEventListener('change', syncWatermarkTypeUI)); // 调整透明度 / 修改文字后实时刷新预览 watermarkOpacityInput.addEventListener('input', renderAllPreviews); watermarkTextInput.addEventListener('input', () => { imageList.forEach(item => { if (item.watermarkType) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y, item.watermarkType); }); renderAllPreviews(); }); // 收起 / 展开整个图片处理面板 imageToggleBtn.addEventListener('click', () => { const collapsed = imageBody.classList.toggle('hidden'); imageToggleBtn.textContent = collapsed ? '展开' : '收起'; imageToggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); }); // 导出单张图片 // 生成全分辨率导出画布 function getExportCanvas(item) { return drawItemToCanvas(document.createElement('canvas'), item, item.original.width); } // canvas 转 PNG Blob function canvasToPngBlob(canvas) { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) resolve(blob); else reject(new Error('toBlob 返回空')); }, 'image/png'); }); } // 根据商品名生成导出文件夹名;未填写则用「未命名」+4位随机数 function getExportFolderName() { const rand4 = String(Math.floor(Math.random() * 10000)).padStart(4, '0'); let raw = (productNameInput.value || '').trim(); let name = raw || ('未命名' + rand4); // 过滤文件系统非法字符,并压缩多余空格 name = name.replace(/[\/\\:*?"<>|\r\n\t]/g, '_').replace(/\s+/g, ' ').trim(); if (!name) name = '未命名' + rand4; return name; } function exportImage(index) { const targetImage = imageList.find(item => item.index === index); if (!targetImage) return; // 按原图尺寸重新合成,保证导出的是全分辨率图片 const canvas = getExportCanvas(targetImage); const baseName = targetImage.fileName.replace(/\.\w+$/, ''); const suffix = (targetImage.whiteBg ? '_white' : '') + (targetImage.watermarkType ? '_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(); } // 导出所有图片:优先直接写出文件夹(File System Access API),不支持时回退 ZIP async function exportAllImages() { if (imageList.length === 0) { alert('暂无图片可导出'); return; } const folderName = getExportFolderName(); // 方案 A:浏览器支持文件系统访问 API(Chrome / Edge 等),直接写入用户选择的文件夹 if (typeof window.showDirectoryPicker === 'function') { try { const dirHandle = await window.showDirectoryPicker(); const targetDir = await dirHandle.getDirectoryHandle(folderName, { create: true }); for (const item of imageList) { const canvas = getExportCanvas(item); const blob = await canvasToPngBlob(canvas); const baseName = item.fileName.replace(/\.\w+$/, ''); const suffix = (item.whiteBg ? '_white' : '') + (item.watermarkType ? '_watermark' : ''); const fileHandle = await targetDir.getFileHandle(baseName + suffix + '.png', { create: true }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); } alert(`已导出 ${imageList.length} 张图片到文件夹「${folderName}」`); return; } catch (err) { if (err && err.name === 'AbortError') return; // 用户取消选择目录 console.error('直接导出文件夹失败,回退到 ZIP:', err); // 其它错误再走下面的 ZIP 回退 } } // 方案 B:不支持时回退为 ZIP 打包(需联网加载 JSZip) if (typeof JSZip === 'undefined') { alert('当前浏览器不支持直接导出文件夹,请使用 Chrome / Edge 浏览器,或在联网环境下使用 ZIP 打包导出'); return; } const zip = new JSZip(); const folder = zip.folder(folderName); try { for (const item of imageList) { const canvas = getExportCanvas(item); const blob = await canvasToPngBlob(canvas); const baseName = item.fileName.replace(/\.\w+$/, ''); const suffix = (item.whiteBg ? '_white' : '') + (item.watermarkType ? '_watermark' : ''); folder.file(baseName + suffix + '.png', blob); } } catch (err) { alert('导出失败,请通过本地服务访问页面(start.command)后重试'); console.error('导出失败:', err); return; } let content; try { content = await zip.generateAsync({ type: 'blob' }); } catch (err) { alert('打包失败,请重试'); console.error('打包失败:', err); return; } const link = document.createElement('a'); link.href = URL.createObjectURL(content); link.download = folderName + '.zip'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); } exportAllBtn.addEventListener('click', exportAllImages); clearAllImagesBtn.addEventListener('click', clearAllImages); 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; let rule = ''; if (level === 'low') { if (weight <= 500) { logisticsFee = 3.12 + 0.026 * weight; rule = 'low:3.12 + 0.026×重量'; } else { logisticsFee = 23.92 + 0.01768 * usedWeight; rule = 'low:23.92 + 0.01768×重量'; } } else if (level === 'high2') { if (weight <= 5000) { logisticsFee = 22.88 + 0.026 * weight; rule = '高2:22.88 + 0.026×重量'; } else { logisticsFee = 64.48 + 0.024 * usedWeight; rule = '高2:64.48 + 0.024×重量'; } } else { if (weight <= 2000) { logisticsFee = 16.64 + 0.026 * weight; rule = '普通:16.64 + 0.026×重量'; } else { logisticsFee = 37.44 + 0.01768 * usedWeight; rule = '普通:37.44 + 0.01768×重量'; } } logisticsFee += TDPrice; if (TDPrice > 0) rule += ' + 通递价'; return { fee: logisticsFee, rule }; } 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 = '--'; totalCostElement.textContent = '--'; fullCommissionElement.textContent = '--'; logisticsFeeRuleEl.textContent = ''; commissionRuleEl.textContent = ''; fullCommissionRuleEl.textContent = ''; logisticsLevelAlert.classList.add('hidden'); priceRangeAlert.classList.add('hidden'); return; } const profitRateDecimal = profitRate / 100; const { fee: logisticsFee, rule: logisticsFeeRule } = calculateLogisticsFee( weight, length, width, height, logisticsLevel, TDPrice ); const receivedPrice = purchasePrice * (1 + profitRateDecimal); const profitPrice = purchasePrice * profitRateDecimal; let commissionPrice let sellingPrice; const netRate = (logisticsLevel === 'low') ? 0.845 : 0.785; if (logisticsLevel === 'low') { sellingPrice = (receivedPrice + logisticsFee) / 0.845; commissionPrice = sellingPrice * 0.12; } else { sellingPrice = (receivedPrice + logisticsFee) / 0.785; commissionPrice = sellingPrice * 0.18; } // 完全成本:进货价 + 物流费 + 平台真实总抽成(含佣金及约3.5%其它费) const fullCommission = sellingPrice * (1 - netRate); const totalCost = purchasePrice + logisticsFee + fullCommission; 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)}`; totalCostElement.textContent = `¥ ${totalCost.toFixed(2)}`; fullCommissionElement.textContent = `¥ ${fullCommission.toFixed(2)}`; logisticsFeeRuleEl.textContent = '(' + logisticsFeeRule + ')'; commissionRuleEl.textContent = (logisticsLevel === 'low') ? '(销售价 × 12%)' : '(销售价 × 18%)'; fullCommissionRuleEl.textContent = (logisticsLevel === 'low') ? '(销售价 × 15.5%)' : '(销售价 × 21.5%)'; [logisticsCard, receivedCard, totalCostCard, 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; } // 轻提示 Toast:type 可选 'success' | 'error' | 'info' function showToast(message, type = 'info', duration = 2500) { let container = document.getElementById('toastContainer'); if (!container) { container = document.createElement('div'); container.id = 'toastContainer'; container.className = 'toast-container'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = `toast ${type}`; const icon = document.createElement('i'); const iconClass = type === 'success' ? 'fa fa-check-circle' : type === 'error' ? 'fa fa-exclamation-circle' : 'fa fa-info-circle'; icon.className = `toast-icon ${iconClass}`; toast.appendChild(icon); const text = document.createElement('span'); text.textContent = message; toast.appendChild(text); container.appendChild(toast); // 触发进入动画 requestAnimationFrame(() => toast.classList.add('show')); const remove = () => { toast.classList.remove('show'); toast.classList.add('hide'); toast.addEventListener('transitionend', () => toast.remove(), { once: true }); }; setTimeout(remove, duration); } 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 netRate = (logisticsLevel === 'low') ? 0.845 : 0.785; // 完全成本 / 平台总抽成 直接取结果卡片(totalCostCard)显示值,保证与屏幕完全一致 const cardFullCommission = parseDisplayMoney(fullCommissionElement.textContent); const cardTotalCost = parseDisplayMoney(totalCostElement.textContent); const fullCommission = cardFullCommission !== '' ? Number(cardFullCommission) : Number(sellingPrice) * (1 - netRate); const totalCost = cardTotalCost !== '' ? Number(cardTotalCost) : Number(purchasePrice) + Number(logisticsFee) + fullCommission; const record = { sku, modelCode: (modelCodeInput.value || '').trim(), skuSuffix: (skuSuffixInput.value || '').trim(), productName, status: '在售', 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, fullCommission: fullCommission.toFixed(2), totalCost: totalCost.toFixed(2), weight, dimensions: `${length}x${width}x${height}`, logisticsLevel: logisticsLevel === 'high' ? '高' : (logisticsLevel === 'high2' ? 'Premium' : '低') }; historyData.unshift(record); localStorage.setItem('priceCalculatorHistory', JSON.stringify(historyData)); renderHistoryTable(); showToast(`商品「${productName || sku}」录入成功`, 'success'); } 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 = 17; if (historyData.length === 0) { historyTableBody.innerHTML = `