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