From 134a89a8de83a56b1007cf4039beb77d30a1d003 Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 10 Aug 2026 14:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E7=9A=84=E7=BB=86?= =?UTF-8?q?=E8=8A=82=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODOList.md | 1 + web/css/styles.css | 78 +++++++++ web/js/ai-copy.js | 58 +++++-- web/js/app.js | 410 ++++++++++++++++++++++++++++++++------------ web/ozonSeller.html | 118 ++++++------- 5 files changed, 473 insertions(+), 192 deletions(-) create mode 100644 TODOList.md diff --git a/TODOList.md b/TODOList.md new file mode 100644 index 0000000..6f9d192 --- /dev/null +++ b/TODOList.md @@ -0,0 +1 @@ +- [ ] 把多种图片合并成一个视频 \ No newline at end of file diff --git a/web/css/styles.css b/web/css/styles.css index 895d556..d187808 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -218,8 +218,86 @@ .ai-tag-chip-zh { color: #9ca3af; } +.ai-copy-btn.is-copied, +.ai-copy-trigger.is-copied, +.ai-copy-all-tags-btn.is-copied { + background: rgba(16, 185, 129, 0.25) !important; + color: #34d399 !important; +} +.ai-tag-chip.is-copied { + border-color: #34d399; + background: rgba(16, 185, 129, 0.15); + transform: none; +} /* 上品登记表:型号 / 重量 / 尺寸 默认隐藏 */ #historyTable:not(.show-optional-cols) .history-col-optional { display: none; } + +/* ================================================================ + Toast 轻提示 + ================================================================ */ +.toast-container { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + pointer-events: none; +} +.toast { + pointer-events: auto; + min-width: 12rem; + max-width: 22rem; + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 1.125rem; + border-radius: 0.625rem; + background: var(--color-dark-light); + border: 1px solid var(--color-gray-700); + border-left: 4px solid var(--color-secondary); + color: #f3f4f6; + font-size: 0.9375rem; + line-height: 1.4; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.45); + opacity: 0; + transform: translateY(-12px); + transition: opacity 0.25s ease, transform 0.25s ease; +} +.toast.show { + opacity: 1; + transform: translateY(0); +} +.toast.hide { + opacity: 0; + transform: translateY(-12px); +} +.toast .toast-icon { + flex: 0 0 auto; + font-size: 1.125rem; + line-height: 1; +} +.toast.success { + border-left-color: var(--color-secondary); +} +.toast.success .toast-icon { + color: #34d399; +} +.toast.error { + border-left-color: var(--color-danger); +} +.toast.error .toast-icon { + color: #f87171; +} +.toast.info { + border-left-color: var(--color-primary); +} +.toast.info .toast-icon { + color: #60a5fa; +} diff --git a/web/js/ai-copy.js b/web/js/ai-copy.js index a253df6..c662d0a 100644 --- a/web/js/ai-copy.js +++ b/web/js/ai-copy.js @@ -11,7 +11,6 @@ const bodyEl = document.getElementById('aiCopyBody'); const statusEl = document.getElementById('aiCopyStatus'); const modelSelectEl = document.getElementById('aiModelSelect'); - const contextProductNameEl = document.getElementById('aiContextProductName'); const contextModelCodeEl = document.getElementById('aiContextModelCode'); const titlesContainerEl = document.getElementById('aiTitlesContainer'); const tagsContainerEl = document.getElementById('aiTagsContainer'); @@ -38,30 +37,64 @@ } function syncContextHints() { - if (contextProductNameEl) { - const name = (productNameInput && productNameInput.value || '').trim(); - contextProductNameEl.textContent = name || '--'; - } + // 注:生成文案不再带入「商品名」,故此处只同步型号提示 if (contextModelCodeEl) { const model = (modelCodeInput && modelCodeInput.value || '').trim(); contextModelCodeEl.textContent = model || '--'; } } - async function copyText(text) { + async function copyText(text, btn) { const value = (text || '').trim(); if (!value) { setStatus('没有可复制的内容', true); return; } try { - await navigator.clipboard.writeText(value); + 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); + } setStatus('已复制'); + showCopyFeedback(btn); } catch (err) { setStatus('复制失败,请手动选择文本', true); } } + function showCopyFeedback(btn) { + if (!btn || btn.dataset.copyFeedbackActive) return; + + if (btn.classList.contains('ai-tag-chip')) { + btn.classList.add('is-copied'); + setTimeout(function () { + btn.classList.remove('is-copied'); + }, 1200); + return; + } + + const originalText = btn.textContent; + btn.dataset.copyFeedbackActive = '1'; + btn.disabled = true; + btn.textContent = '已复制'; + btn.classList.add('is-copied'); + + setTimeout(function () { + btn.textContent = originalText; + btn.disabled = false; + btn.classList.remove('is-copied'); + delete btn.dataset.copyFeedbackActive; + }, 1200); + } + function toStringList(value) { return Array.isArray(value) ? value.map(function (item) { return String(item).trim(); }) @@ -98,9 +131,9 @@ const copyBtn = document.createElement('button'); copyBtn.type = 'button'; - copyBtn.className = 'px-3 py-1.5 text-sm rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors duration-200'; + copyBtn.className = 'ai-copy-trigger px-3 py-1.5 text-sm rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors duration-200'; copyBtn.textContent = '复制俄文'; - copyBtn.addEventListener('click', function () { copyText(ru); }); + copyBtn.addEventListener('click', function () { copyText(ru, copyBtn); }); const fillBtn = document.createElement('button'); fillBtn.type = 'button'; @@ -253,7 +286,6 @@ const payload = { source_text: sourceText, - product_name: (productNameInput && productNameInput.value || '').trim(), model_code: (modelCodeInput && modelCodeInput.value || '').trim(), model: (modelSelectEl && modelSelectEl.value || '').trim(), }; @@ -307,7 +339,7 @@ btn.addEventListener('click', function () { const sourceId = btn.getAttribute('data-copy-source'); const el = sourceId ? document.getElementById(sourceId) : null; - copyText(el ? el.textContent : ''); + copyText(el ? el.textContent : '', btn); }); }); @@ -315,13 +347,13 @@ tagsContainerEl.addEventListener('click', function (event) { const chip = event.target.closest('.ai-tag-chip'); if (!chip || !tagsContainerEl.contains(chip)) return; - copyText(chip.getAttribute('data-tag-ru') || ''); + copyText(chip.getAttribute('data-tag-ru') || '', chip); }); } if (copyAllTagsBtn) { copyAllTagsBtn.addEventListener('click', function () { - copyText(lastTagsRu.join(', ')); + copyText(lastTagsRu.join(', '), copyAllTagsBtn); }); } diff --git a/web/js/app.js b/web/js/app.js index 60fe5de..8fed3d7 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -31,7 +31,13 @@ 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'); @@ -71,6 +77,7 @@ 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'); @@ -84,11 +91,14 @@ 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(); @@ -169,7 +179,7 @@ toggleOptionalColsBtn.addEventListener('click', () => { const shown = historyTable.classList.toggle('show-optional-cols'); toggleOptionalColsBtn.setAttribute('aria-pressed', shown ? 'true' : 'false'); - toggleOptionalColsBtn.textContent = shown ? '隐藏型号/重量/尺寸' : '显示型号/重量/尺寸'; + toggleOptionalColsBtn.textContent = shown ? '隐藏物流费/平台总抽成/重量/尺寸/状态' : '显示物流费/平台总抽成/重量/尺寸/状态'; }); } clearHistoryBtn.addEventListener('click', clearHistory); @@ -235,44 +245,79 @@ } }); - // 处理上传的文件 + // 处理上传的文件(追加模式:不清空已有图片) function handleFiles(files) { - imageList = []; if (files.length === 0) return; - // 清空预览容器 - previewContainer.innerHTML = ''; - // 遍历文件并加载图片 - Array.from(files).forEach((file, index) => { + 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, + index: imageSeq++, original: img, whiteBg: null, // 白底处理后的 Canvas(与原图同尺寸) whiteBgRatio: 0, // 被判定为背景的像素占比 fileName: file.name, - hasWatermark: false, + 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); }); + } - // 启用导出按钮(先显示原始图片,添加水印后更新) - exportAllBtn.disabled = false; - exportAllBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + 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(); } // 预加载水印图片,避免拖动时才开始加载 @@ -320,9 +365,9 @@ // 量文字宽度用的离屏画布 const measureCtx = document.createElement('canvas').getContext('2d'); - // 水印在原图坐标系中的尺寸 - function getWatermarkSize(item) { - if (getWatermarkType() === 'text') { + // 水印在原图坐标系中的尺寸(type 不传时取全局当前选择) + function getWatermarkSize(item, type = getWatermarkType()) { + if (type === 'text') { const fontSize = getWatermarkFontSize(item); measureCtx.font = getWatermarkFont(fontSize); return { @@ -336,8 +381,8 @@ } // 限制水印不被拖出图片范围 - function clampWatermarkPos(item, x, y) { - const size = getWatermarkSize(item); + 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 { @@ -347,8 +392,8 @@ } // 默认位置:右下角 - function defaultWatermarkPos(item) { - const size = getWatermarkSize(item); + function defaultWatermarkPos(item, type = getWatermarkType()) { + const size = getWatermarkSize(item, type); return clampWatermarkPos( item, item.original.width - size.width - WATERMARK_MARGIN, @@ -399,11 +444,6 @@ ctx.restore(); } - // 水印所需资源是否就绪 - function isWatermarkReady() { - return getWatermarkType() === 'text' ? true : !!watermarkImg; - } - function getWhiteBgTolerance() { const value = parseFloat(whiteBgToleranceInput.value); if (!isFinite(value)) return DEFAULT_WHITE_BG_TOLERANCE; @@ -527,20 +567,22 @@ } // 按指定宽度把原图和水印合成到 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)); - canvas.height = Math.max(1, Math.round(item.original.height * scale)); + 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.hasWatermark && isWatermarkReady()) { - const drawX = item.pos.x * scale; - const drawY = item.pos.y * scale; - if (getWatermarkType() === 'text') { - drawTextWatermark(ctx, drawX, drawY, getWatermarkFontSize(item) * scale); + 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).width * scale); + drawImageWatermark(ctx, drawX, drawY, getWatermarkSize(item, 'image').width * scale * renderScale); } } return canvas; @@ -560,9 +602,9 @@ } canvas.addEventListener('pointerdown', (e) => { - if (!item.hasWatermark || !isWatermarkReady()) return; + if (!item.watermarkType || (item.watermarkType === 'image' && !watermarkImg)) return; const point = toImageCoords(e); - const size = getWatermarkSize(item); + 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; @@ -590,54 +632,25 @@ canvas.addEventListener('pointercancel', stopDrag); } - // 单张图片:切换是否贴水印 + // 单张图片:切换是否贴水印(开启时用当前选择的水印类型) function toggleItemWatermark(item) { - if (item.hasWatermark) { - item.hasWatermark = false; + if (item.watermarkType) { + item.watermarkType = null; } else { - item.hasWatermark = true; - item.pos = defaultWatermarkPos(item); + item.watermarkType = getWatermarkType(); + item.pos = defaultWatermarkPos(item, item.watermarkType); } 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)}% 的画面刷成白底,点击还原` - : '把这张图的背景刷成纯白'; + const deleteBtn = previewItem.querySelector('[data-role="delete-item"]'); + watermarkBtn.textContent = item.watermarkType ? '清除水印' : '加水印'; + watermarkBtn.title = item.watermarkType ? '这张图不加水印' : '给这张图加上水印'; + deleteBtn.textContent = '删除'; + deleteBtn.title = '从列表中移除这张图片'; } // 渲染(或更新)单张图片的预览 @@ -666,12 +679,12 @@ 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); + 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); @@ -686,7 +699,7 @@ } const canvas = previewItem.querySelector('canvas'); - canvas.classList.toggle('preview-img-draggable', item.hasWatermark); + canvas.classList.toggle('preview-img-draggable', item.watermarkType); drawItemToCanvas(canvas, item, PREVIEW_MAX_WIDTH); syncPreviewActions(previewItem, item); } @@ -719,21 +732,21 @@ } imageList.forEach(item => { - if (!item.hasWatermark) { - item.hasWatermark = true; - item.pos = defaultWatermarkPos(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.hasWatermark) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y); + if (item.watermarkType) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y, item.watermarkType); }); renderAllPreviews(); } @@ -743,7 +756,7 @@ watermarkOpacityInput.addEventListener('input', renderAllPreviews); watermarkTextInput.addEventListener('input', () => { imageList.forEach(item => { - if (item.hasWatermark) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y); + if (item.watermarkType) item.pos = clampWatermarkPos(item, item.pos.x, item.pos.y, item.watermarkType); }); renderAllPreviews(); }); @@ -756,14 +769,40 @@ }); // 导出单张图片 + // 生成全分辨率导出画布 + 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 = drawItemToCanvas(document.createElement('canvas'), targetImage, targetImage.original.width); + const canvas = getExportCanvas(targetImage); const baseName = targetImage.fileName.replace(/\.\w+$/, ''); - const suffix = (targetImage.whiteBg ? '_white' : '') + (targetImage.hasWatermark ? '_watermark' : ''); + const suffix = (targetImage.whiteBg ? '_white' : '') + (targetImage.watermarkType ? '_watermark' : ''); // 创建下载链接 let dataUrl; @@ -781,17 +820,83 @@ link.click(); } - // 导出所有图片 - exportAllBtn.addEventListener('click', () => { + // 导出所有图片:优先直接写出文件夹(File System Access API),不支持时回退 ZIP + async function exportAllImages() { if (imageList.length === 0) { alert('暂无图片可导出'); return; } - // 批量导出(逐个触发下载) - imageList.forEach((item, i) => { - setTimeout(() => exportImage(item.index), i * 300); // 延迟避免浏览器拦截 - }); - }); + + 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); @@ -854,27 +959,35 @@ 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; - return { fee: logisticsFee }; + if (TDPrice > 0) rule += ' + 通递价'; + return { fee: logisticsFee, rule }; } function calculateAndDisplay() { const purchasePrice = parseFloat(purchasePriceInput.value) || 0; @@ -910,18 +1023,24 @@ 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 } = calculateLogisticsFee( + 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; @@ -929,6 +1048,9 @@ 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) { @@ -951,7 +1073,12 @@ sellingPriceElement.textContent = `¥ ${sellingPrice.toFixed(2)}`; profitElement.textContent = `¥ ${profitPrice.toFixed(2)}` commission.textContent = `¥ ${commissionPrice.toFixed(2)}`; - [logisticsCard, receivedCard, sellingCard].forEach((card, index) => { + 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, totalCostCard, receivedCard, sellingCard].forEach((card, index) => { setTimeout(() => { card.classList.remove('opacity-0', 'translate-y-4'); }, index * 100); @@ -986,7 +1113,7 @@ function validateProductInfoForm() { const fields = [ { label: '进货价', el: purchasePriceInput, value: purchasePriceInput && purchasePriceInput.value }, - { label: '利润率', el: profitRateInput, value: profitRateInput && profitRateInput.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 }, @@ -1008,6 +1135,42 @@ } 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; @@ -1052,11 +1215,23 @@ 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), @@ -1069,6 +1244,8 @@ purchasePrice, profit, profitRate, + fullCommission: fullCommission.toFixed(2), + totalCost: totalCost.toFixed(2), weight, dimensions: `${length}x${width}x${height}`, logisticsLevel: logisticsLevel === 'high' ? '高' : (logisticsLevel === 'high2' ? 'Premium' : '低') @@ -1076,6 +1253,7 @@ historyData.unshift(record); localStorage.setItem('priceCalculatorHistory', JSON.stringify(historyData)); renderHistoryTable(); + showToast(`商品「${productName || sku}」录入成功`, 'success'); } function createLinkCell(url, label) { const cell = document.createElement('td'); @@ -1117,7 +1295,7 @@ return ''; } function renderHistoryTable() { - const colCount = 15; + const colCount = 17; if (historyData.length === 0) { historyTableBody.innerHTML = ``; return; @@ -1148,17 +1326,19 @@ row.append( deleteCell, createCell(record.sku || '--'), - createCell(record.modelCode || '--', true), createCell(record.productName || '--'), createCell(formatCny(record.purchasePrice)), - createCell(formatCny(record.logisticsFee)), - createCell(formatCny(record.sellingPrice)), + createCell(formatCny(record.logisticsFee), true), + createCell(formatCny(record.fullCommission), true), createCell(formatCny(record.receivedPrice)), + createCell(formatCny(record.totalCost)), + createCell(formatCny(record.sellingPrice)), createCell(profit !== '' ? formatCny(profit) : '--'), createCell(profitRate), createCell(formatRub(record.sellingPriceRub)), createCell(weightText, true), createCell(dimensionsText, true), + createCell(record.status || '在售', true), createLinkCell(ozonUrl, ozonUrl), createLinkCell(purchaseUrl, record.purchaseUrl || purchaseUrl) ); @@ -1198,8 +1378,8 @@ return; } const headers = [ - '货号(sku)', '型号', '商品名', '进货价', '物流费', '销售价', '实收价', - '利润', '利润率', '卢布销价', '重量', '尺寸', 'Ozon地址', '采买地址' + '货号(sku)', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价', + '净利润', '净利率', '卢布销价', '重量', '尺寸', '状态', 'Ozon地址', '采买地址' ]; let csvContent = '\uFEFF' + headers.join(',') + '\n'; historyData.forEach(record => { @@ -1207,17 +1387,19 @@ const profitRate = getRecordProfitRate(record, profit); const row = [ record.sku || '', - record.modelCode || '', record.productName || '', record.purchasePrice || '', record.logisticsFee || '', - record.sellingPrice || '', + record.fullCommission || '', record.receivedPrice || '', + record.totalCost || '', + record.sellingPrice || '', profit, profitRate, record.sellingPriceRub || '', record.weight || '', record.dimensions || '', + record.status || '在售', record.sku ? `https://www.ozon.ru/product/${record.sku}` : '', record.purchaseUrl || '' ].map(csvEscape); @@ -1353,28 +1535,28 @@ const cnyPrice = parseFloat(sellingPriceElement.textContent.replace(/[^\d.]/g, '')); if (isNaN(cnyPrice) || cnyPrice <= 0) { sellingPriceReserved.textContent = '--'; - cnyReserveGap.textContent = '--'; + cnyReserveGap.textContent = ''; sellingPriceRub.textContent = '--'; sellingPriceRubReserved.textContent = '--'; - rubReserveGap.textContent = '--'; + rubReserveGap.textContent = ''; return; } const cnyReserved = cnyPrice / (1 - percent / 100); sellingPriceReserved.textContent = `¥ ${cnyReserved.toFixed(2)}`; - cnyReserveGap.textContent = `¥ ${(cnyReserved - cnyPrice).toFixed(2)}`; + cnyReserveGap.textContent = `(折扣空间 ¥${(cnyReserved - cnyPrice).toFixed(2)})`; if (!cnyToRubRate) { sellingPriceRub.textContent = '--'; sellingPriceRubReserved.textContent = '--'; - rubReserveGap.textContent = '--'; + rubReserveGap.textContent = ''; return; } const rubPrice = cnyPrice * cnyToRubRate; const rubReserved = cnyReserved * cnyToRubRate; sellingPriceRub.textContent = `₽ ${rubPrice.toFixed(2)}`; sellingPriceRubReserved.textContent = `₽ ${rubReserved.toFixed(2)}`; - rubReserveGap.textContent = `₽ ${(rubReserved - rubPrice).toFixed(2)}`; + rubReserveGap.textContent = `(折扣空间 ₽${(rubReserved - rubPrice).toFixed(2)})`; sellingCardRub.classList.remove('opacity-0', 'translate-y-4'); } diff --git a/web/ozonSeller.html b/web/ozonSeller.html index d5bbc51..c4a1b01 100644 --- a/web/ozonSeller.html +++ b/web/ozonSeller.html @@ -80,7 +80,7 @@
- +
@@ -202,17 +202,31 @@

- 物流费 + 物流费

--

- 平台佣金 + 平台佣金

--

+
+
+

+ 完全成本(进货价+物流费+平台总抽成) +

+

--

+
+
+

+ 平台总抽成 +

+

--

+
+

@@ -222,7 +236,7 @@

- 利润 + 净利润

--

@@ -242,12 +256,11 @@

现价

-

--

+

--

-

预留 50%

-

--

-

折扣空间 --

+

预留 50%

+

--

@@ -262,13 +275,11 @@

现价

-

--

+

--

-

预留 50%

-

--

-

折扣空间 --

+

预留 50%

+

--

@@ -324,8 +335,8 @@ class="w-full px-3 py-2 border border-gray-700 bg-dark-light rounded-lg input-focus text-gray-100">

- 将带入当前:商品名 -- - / 型号 -- + 将带入当前:型号 -- + (生成文案不读取商品名)

@@ -350,7 +361,7 @@
+ class="ai-copy-btn ai-copy-trigger shrink-0 px-3 py-1.5 text-sm rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors duration-200">复制俄文
@@ -361,7 +372,7 @@
+ class="ai-copy-all-tags-btn ai-copy-trigger shrink-0 px-3 py-1.5 text-sm rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors duration-200">复制全部俄文标签

生成后将在此展示标签,点击可复制俄文

@@ -455,10 +466,14 @@ class="flex-1 bg-primary hover:bg-primary/90 text-white py-3 rounded-lg flex items-center justify-center transition-all duration-200 transform hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/20"> 添加水印 - +
@@ -478,7 +493,7 @@