Files
2026-08-10 22:57:13 +08:00

1570 lines
76 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ================================================================
// 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 = '<i class="fa fa-check"></i>';
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 = '<p class="col-span-full text-gray-500 text-center py-4">暂无图片</p>';
}
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 = '<i class="fa fa-download mr-1"></i>导出';
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 = 'low3.12 + 0.026×重量';
} else {
logisticsFee = 23.92 + 0.01768 * usedWeight;
rule = 'low23.92 + 0.01768×重量';
}
} else if (level === 'high2') {
if (weight <= 5000) {
logisticsFee = 22.88 + 0.026 * weight;
rule = '高222.88 + 0.026×重量';
} else {
logisticsFee = 64.48 + 0.024 * usedWeight;
rule = '高264.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;
}
// 轻提示 Toasttype 可选 '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 = `<tr class="text-center"><td colspan="${colCount}" class="px-6 py-10 text-gray-500"></td></tr>`;
return;
}
historyTableBody.innerHTML = '';
historyData.forEach((record, index) => {
const profit = getRecordProfit(record);
const profitRate = getRecordProfitRate(record, profit) || '--';
const weightNum = parseFloat(record.weight);
const weightText = isNaN(weightNum) ? '--' : `${weightNum.toFixed(0)} g`;
const dimensionsText = record.dimensions ? `${record.dimensions} cm` : '--';
const row = document.createElement('tr');
const deleteCell = document.createElement('td');
deleteCell.className = 'px-4 py-3 whitespace-nowrap';
const deleteButton = document.createElement('button');
deleteButton.type = 'button';
deleteButton.classList.add('bg-danger/10', 'hover:bg-danger/20', 'text-danger', 'px-3', 'py-1', 'rounded-lg', 'text-sm', 'flex', 'items-center', 'transition-colors', 'duration-200');
deleteButton.textContent = '删除';
deleteButton.addEventListener('click', () => {
deleteRecord(index);
});
deleteCell.appendChild(deleteButton);
const ozonUrl = record.sku ? `https://www.ozon.ru/product/${record.sku}` : '';
const purchaseUrl = normalizeUrl(record.purchaseUrl);
row.append(
deleteCell,
createCell(record.sku || '--'),
createCell(record.productName || '--'),
createCell(formatCny(record.purchasePrice)),
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)
);
historyTableBody.appendChild(row);
});
}
function createCell(content, optional) {
const cell = document.createElement('td');
cell.className = (optional ? 'history-col-optional ' : '') + 'px-4 py-3 whitespace-nowrap';
const div = document.createElement('div');
div.className = 'text-sm text-gray-100';
div.textContent = content;
cell.appendChild(div);
return cell;
}
function deleteRecord(index) {
historyData.splice(index, 1);
localStorage.setItem('priceCalculatorHistory', JSON.stringify(historyData));
renderHistoryTable();
}
function clearHistory() {
if (confirm('确认表格已经导出!此操作将清空已有数据。')) {
historyData = [];
localStorage.removeItem('priceCalculatorHistory');
renderHistoryTable();
}
}
function csvEscape(value) {
const text = value === undefined || value === null ? '' : String(value);
if (/[",\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function exportHistory() {
if (historyData.length === 0) {
return;
}
const headers = [
'货号(sku)', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
'净利润', '净利率', '卢布销价', '重量', '尺寸', '状态', 'Ozon地址', '采买地址'
];
let csvContent = '\uFEFF' + headers.join(',') + '\n';
historyData.forEach(record => {
const profit = getRecordProfit(record);
const profitRate = getRecordProfitRate(record, profit);
const row = [
record.sku || '',
record.productName || '',
record.purchasePrice || '',
record.logisticsFee || '',
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);
csvContent += row.join(',') + '\n';
});
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
const currentDate = new Date();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(currentDate.getDate()).padStart(2, '0');
const hours = String(currentDate.getHours()).padStart(2, '0');
const minutes = String(currentDate.getMinutes()).padStart(2, '0');
const formattedDate = `${month}-${day} ${hours}:${minutes}`;
link.setAttribute('download', `${formattedDate}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// ===== 汇兑与折扣空间 =====
// 按优先级排列:FloatRates 每小时更新,最接近 Wise 中间价;
// 俄央行是 Ozon 结算参考的官方牌价;er-api 每日 00:00 UTC 才更新一次,仅作兜底。
const FX_SOURCES = [
{
name: 'FloatRates',
url: 'https://www.floatrates.com/daily/cny.json',
parse: (data) => data && data.rub && { rate: data.rub.rate, time: data.rub.date }
},
{
name: '俄央行',
url: 'https://www.cbr-xml-daily.ru/daily_json.js',
parse: (data) => {
const cny = data && data.Valute && data.Valute.CNY;
return cny && { rate: cny.Value / cny.Nominal, time: data.Date };
}
},
{
name: 'ExchangeRate-API',
url: 'https://open.er-api.com/v6/latest/CNY',
parse: (data) => data && data.rates && { rate: data.rates.RUB, time: data.time_last_update_utc }
}
];
const FX_FALLBACK_RATE = 11.5;
// 汇率明显越界时视为脏数据,换下一个源
const FX_MIN_RATE = 5;
const FX_MAX_RATE = 25;
const fxCnyInput = document.getElementById('fxCny');
const fxRubInput = document.getElementById('fxRub');
const fxRateText = document.getElementById('fxRateText');
const fxRateSource = document.getElementById('fxRateSource');
const fxRefreshBtn = document.getElementById('fxRefreshBtn');
const discountReserveInput = document.getElementById('discountReserve');
const sellingPriceReserved = document.getElementById('sellingPriceReserved');
const cnyReserveLabel = document.getElementById('cnyReserveLabel');
const cnyReserveGap = document.getElementById('cnyReserveGap');
const sellingPriceRub = document.getElementById('sellingPriceRub');
const sellingPriceRubReserved = document.getElementById('sellingPriceRubReserved');
const rubReserveLabel = document.getElementById('rubReserveLabel');
const rubReserveGap = document.getElementById('rubReserveGap');
const rubCardRate = document.getElementById('rubCardRate');
const sellingCardRub = document.getElementById('sellingCardRub');
let cnyToRubRate = null;
async function loadFxRate() {
fxRateText.textContent = '加载中…';
fxRateSource.textContent = '';
for (const source of FX_SOURCES) {
try {
const res = await fetch(source.url, { cache: 'no-store' });
if (!res.ok) continue;
const result = source.parse(await res.json());
const rate = result && Number(result.rate);
if (rate >= FX_MIN_RATE && rate <= FX_MAX_RATE) {
applyFxRate(rate, source.name, result.time);
return;
}
console.warn('汇率数据异常:', source.name, result);
} catch (err) {
console.warn('汇率获取失败:', source.name, err);
}
}
applyFxRate(FX_FALLBACK_RATE, '默认(获取失败)');
}
function formatFxTime(time) {
const date = new Date(time);
if (!time || isNaN(date)) return '';
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function applyFxRate(rate, source, time) {
cnyToRubRate = rate;
fxRateText.textContent = rate.toFixed(4);
rubCardRate.textContent = rate.toFixed(4);
const updatedAt = formatFxTime(time);
fxRateSource.textContent = updatedAt ? `${source} · ${updatedAt}` : source;
fxRateSource.title = updatedAt ? `数据源:${source},更新于 ${updatedAt}` : `数据源:${source}`;
if (fxCnyInput.value !== '') {
syncFxFrom('cny');
} else if (fxRubInput.value !== '') {
syncFxFrom('rub');
}
updateDerivedPrices();
}
function syncFxFrom(origin) {
if (!cnyToRubRate) return;
if (origin === 'cny') {
const cny = parseFloat(fxCnyInput.value);
fxRubInput.value = isNaN(cny) ? '' : (cny * cnyToRubRate).toFixed(2);
} else {
const rub = parseFloat(fxRubInput.value);
fxCnyInput.value = isNaN(rub) ? '' : (rub / cnyToRubRate).toFixed(2);
}
}
function getReserveRate() {
let percent = parseFloat(discountReserveInput.value);
if (isNaN(percent) || percent < 0) percent = 0;
if (percent > 95) percent = 95;
return percent;
}
function updateDerivedPrices() {
const percent = getReserveRate();
cnyReserveLabel.textContent = percent + '%';
rubReserveLabel.textContent = percent + '%';
const cnyPrice = parseFloat(sellingPriceElement.textContent.replace(/[^\d.]/g, ''));
if (isNaN(cnyPrice) || cnyPrice <= 0) {
sellingPriceReserved.textContent = '--';
cnyReserveGap.textContent = '';
sellingPriceRub.textContent = '--';
sellingPriceRubReserved.textContent = '--';
rubReserveGap.textContent = '';
return;
}
const cnyReserved = cnyPrice / (1 - percent / 100);
sellingPriceReserved.textContent = ${cnyReserved.toFixed(2)}`;
cnyReserveGap.textContent = `(折扣空间 ¥${(cnyReserved - cnyPrice).toFixed(2)}`;
if (!cnyToRubRate) {
sellingPriceRub.textContent = '--';
sellingPriceRubReserved.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)}`;
sellingCardRub.classList.remove('opacity-0', 'translate-y-4');
}
fxCnyInput.addEventListener('input', () => syncFxFrom('cny'));
fxRubInput.addEventListener('input', () => syncFxFrom('rub'));
fxRefreshBtn.addEventListener('click', loadFxRate);
discountReserveInput.addEventListener('input', updateDerivedPrices);
new MutationObserver(updateDerivedPrices)
.observe(sellingPriceElement, { childList: true, characterData: true, subtree: true });
loadFxRate();