1570 lines
76 KiB
JavaScript
1570 lines
76 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 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 = 'low:3.12 + 0.026×重量';
|
||
} else {
|
||
logisticsFee = 23.92 + 0.01768 * usedWeight;
|
||
rule = 'low:23.92 + 0.01768×重量';
|
||
}
|
||
} else if (level === 'high2') {
|
||
if (weight <= 5000) {
|
||
logisticsFee = 22.88 + 0.026 * weight;
|
||
rule = '高2:22.88 + 0.026×重量';
|
||
} else {
|
||
logisticsFee = 64.48 + 0.024 * usedWeight;
|
||
rule = '高2:64.48 + 0.024×重量';
|
||
}
|
||
} else {
|
||
if (weight <= 2000) {
|
||
logisticsFee = 16.64 + 0.026 * weight;
|
||
rule = '普通:16.64 + 0.026×重量';
|
||
} else {
|
||
logisticsFee = 37.44 + 0.01768 * usedWeight;
|
||
rule = '普通:37.44 + 0.01768×重量';
|
||
}
|
||
}
|
||
logisticsFee += TDPrice;
|
||
if (TDPrice > 0) rule += ' + 通递价';
|
||
return { fee: logisticsFee, rule };
|
||
}
|
||
function calculateAndDisplay() {
|
||
const purchasePrice = parseFloat(purchasePriceInput.value) || 0;
|
||
console.log('222', purchasePrice)
|
||
const profitRate = parseFloat(profitRateInput.value) || 0;
|
||
const TDPrice = parseFloat(TDPriceInput.value) || 0;
|
||
const weight = parseFloat(weightInput.value) || 0;
|
||
const length = parseFloat(lengthInput.value) || 0;
|
||
const width = parseFloat(widthInput.value) || 0;
|
||
const height = parseFloat(heightInput.value) || 0;
|
||
const logisticsLevel = document.querySelector('input[name="logisticsLevel"]:checked').value;
|
||
if (purchasePrice <= 0 || weight <= 0 || length <= 0 || width <= 0 || height <= 0) {
|
||
alert('请输入有效的商品信息(所有数值必须大于0)');
|
||
return;
|
||
}
|
||
const { warning, error } = validateDimensions(weight, length, width, height, logisticsLevel, TDPrice);
|
||
dimensionAlert.classList.remove('hidden');
|
||
if (error) {
|
||
dimensionErrorMsg.textContent = error;
|
||
dimensionError.classList.remove('hidden');
|
||
} else {
|
||
dimensionError.classList.add('hidden');
|
||
}
|
||
if (warning) {
|
||
dimensionWarningMsg.textContent = warning;
|
||
dimensionWarning.classList.remove('hidden');
|
||
} else {
|
||
dimensionWarning.classList.add('hidden');
|
||
}
|
||
if (error) {
|
||
logisticsFeeElement.textContent = '--';
|
||
receivedPriceElement.textContent = '--';
|
||
profitElement.textContent = '--'
|
||
commission.textContent = '--';
|
||
sellingPriceElement.textContent = '--';
|
||
totalCostElement.textContent = '--';
|
||
fullCommissionElement.textContent = '--';
|
||
logisticsFeeRuleEl.textContent = '';
|
||
commissionRuleEl.textContent = '';
|
||
fullCommissionRuleEl.textContent = '';
|
||
logisticsLevelAlert.classList.add('hidden');
|
||
priceRangeAlert.classList.add('hidden');
|
||
return;
|
||
}
|
||
const profitRateDecimal = profitRate / 100;
|
||
const { fee: logisticsFee, rule: logisticsFeeRule } = calculateLogisticsFee(
|
||
weight, length, width, height, logisticsLevel, TDPrice
|
||
);
|
||
const receivedPrice = purchasePrice * (1 + profitRateDecimal);
|
||
const profitPrice = purchasePrice * profitRateDecimal;
|
||
let commissionPrice
|
||
let sellingPrice;
|
||
const netRate = (logisticsLevel === 'low') ? 0.845 : 0.785;
|
||
if (logisticsLevel === 'low') {
|
||
sellingPrice = (receivedPrice + logisticsFee) / 0.845;
|
||
commissionPrice = sellingPrice * 0.12;
|
||
} else {
|
||
sellingPrice = (receivedPrice + logisticsFee) / 0.785;
|
||
commissionPrice = sellingPrice * 0.18;
|
||
}
|
||
// 完全成本:进货价 + 物流费 + 平台真实总抽成(含佣金及约3.5%其它费)
|
||
const fullCommission = sellingPrice * (1 - netRate);
|
||
const totalCost = purchasePrice + logisticsFee + fullCommission;
|
||
const logisticsLevelMessage = validateLogisticsLevel(sellingPrice, logisticsLevel);
|
||
const priceRangeMessage = validatePriceRange(sellingPrice);
|
||
if (logisticsLevelMessage) {
|
||
logisticsLevelMsg.textContent = logisticsLevelMessage;
|
||
logisticsLevelAlert.classList.remove('hidden');
|
||
} else {
|
||
logisticsLevelAlert.classList.add('hidden');
|
||
}
|
||
if (priceRangeMessage) {
|
||
priceRangeMsg.textContent = priceRangeMessage;
|
||
priceRangeAlert.classList.remove('hidden');
|
||
} else {
|
||
priceRangeAlert.classList.add('hidden');
|
||
}
|
||
setTimeout(() => {
|
||
dimensionAlert.classList.add('opacity-100');
|
||
}, 10);
|
||
logisticsFeeElement.textContent = `¥ ${logisticsFee.toFixed(2)}`;
|
||
receivedPriceElement.textContent = `¥ ${receivedPrice.toFixed(2)}`;
|
||
sellingPriceElement.textContent = `¥ ${sellingPrice.toFixed(2)}`;
|
||
profitElement.textContent = `¥ ${profitPrice.toFixed(2)}`
|
||
commission.textContent = `¥ ${commissionPrice.toFixed(2)}`;
|
||
totalCostElement.textContent = `¥ ${totalCost.toFixed(2)}`;
|
||
fullCommissionElement.textContent = `¥ ${fullCommission.toFixed(2)}`;
|
||
logisticsFeeRuleEl.textContent = '(' + logisticsFeeRule + ')';
|
||
commissionRuleEl.textContent = (logisticsLevel === 'low') ? '(销售价 × 12%)' : '(销售价 × 18%)';
|
||
fullCommissionRuleEl.textContent = (logisticsLevel === 'low') ? '(销售价 × 15.5%)' : '(销售价 × 21.5%)';
|
||
[logisticsCard, totalCostCard, 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;
|
||
}
|
||
// 轻提示 Toast:type 可选 'success' | 'error' | 'info'
|
||
function showToast(message, type = 'info', duration = 2500) {
|
||
let container = document.getElementById('toastContainer');
|
||
if (!container) {
|
||
container = document.createElement('div');
|
||
container.id = 'toastContainer';
|
||
container.className = 'toast-container';
|
||
document.body.appendChild(container);
|
||
}
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icon = document.createElement('i');
|
||
const iconClass = type === 'success' ? 'fa fa-check-circle'
|
||
: type === 'error' ? 'fa fa-exclamation-circle'
|
||
: 'fa fa-info-circle';
|
||
icon.className = `toast-icon ${iconClass}`;
|
||
toast.appendChild(icon);
|
||
|
||
const text = document.createElement('span');
|
||
text.textContent = message;
|
||
toast.appendChild(text);
|
||
|
||
container.appendChild(toast);
|
||
// 触发进入动画
|
||
requestAnimationFrame(() => toast.classList.add('show'));
|
||
|
||
const remove = () => {
|
||
toast.classList.remove('show');
|
||
toast.classList.add('hide');
|
||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||
};
|
||
setTimeout(remove, duration);
|
||
}
|
||
|
||
function recordData() {
|
||
if (!validateProductInfoForm()) {
|
||
return;
|
||
}
|
||
const sku = getFullSku();
|
||
if (!sku) {
|
||
alert('请先填写货号 (sku)');
|
||
if (skuSuffixInput) skuSuffixInput.focus();
|
||
return;
|
||
}
|
||
const sellingPrice = parseDisplayMoney(sellingPriceElement.textContent);
|
||
if (!sellingPrice) {
|
||
alert('请先点击「计价」,再录入商品');
|
||
return;
|
||
}
|
||
const skuKey = sku.toLowerCase();
|
||
const duplicated = historyData.findIndex(
|
||
(item) => ((item && item.sku) || '').trim().toLowerCase() === skuKey
|
||
);
|
||
if (duplicated !== -1) {
|
||
alert(`货号「${sku}」已存在于上品登记表(第 ${duplicated + 1} 条),请勿重复录入`);
|
||
return;
|
||
}
|
||
const productName = productNameInput.value.trim();
|
||
const logisticsFee = parseDisplayMoney(logisticsFeeElement.textContent);
|
||
const receivedPrice = parseDisplayMoney(receivedPriceElement.textContent);
|
||
const purchasePrice = purchasePriceInput.value;
|
||
const weight = weightInput.value;
|
||
const length = lengthInput.value;
|
||
const width = widthInput.value;
|
||
const height = heightInput.value;
|
||
const logisticsLevel = document.querySelector('input[name="logisticsLevel"]:checked').value;
|
||
const discountReserveEl = document.getElementById('discountReserve');
|
||
let discountReserve = parseFloat(discountReserveEl && discountReserveEl.value);
|
||
if (isNaN(discountReserve) || discountReserve < 0) discountReserve = 0;
|
||
if (discountReserve > 95) discountReserve = 95;
|
||
const purchaseNum = parseFloat(purchasePrice);
|
||
const receivedNum = parseFloat(receivedPrice);
|
||
const profit = (!isNaN(receivedNum) && !isNaN(purchaseNum))
|
||
? (receivedNum - purchaseNum).toFixed(2)
|
||
: '';
|
||
const profitRate = (!isNaN(purchaseNum) && purchaseNum > 0 && profit !== '')
|
||
? ((parseFloat(profit) / purchaseNum) * 100).toFixed(0)
|
||
: '';
|
||
const netRate = (logisticsLevel === 'low') ? 0.845 : 0.785;
|
||
// 完全成本 / 平台总抽成 直接取结果卡片(totalCostCard)显示值,保证与屏幕完全一致
|
||
const cardFullCommission = parseDisplayMoney(fullCommissionElement.textContent);
|
||
const cardTotalCost = parseDisplayMoney(totalCostElement.textContent);
|
||
const fullCommission = cardFullCommission !== ''
|
||
? Number(cardFullCommission)
|
||
: Number(sellingPrice) * (1 - netRate);
|
||
const totalCost = cardTotalCost !== ''
|
||
? Number(cardTotalCost)
|
||
: Number(purchasePrice) + Number(logisticsFee) + fullCommission;
|
||
|
||
const record = {
|
||
sku,
|
||
modelCode: (modelCodeInput.value || '').trim(),
|
||
skuSuffix: (skuSuffixInput.value || '').trim(),
|
||
productName,
|
||
status: '在售',
|
||
purchaseUrl: (purchaseUrlInput.value || '').trim(),
|
||
sellingPrice,
|
||
sellingPriceReserved: parseDisplayMoney(document.getElementById('sellingPriceReserved').textContent),
|
||
sellingPriceRub: parseDisplayMoney(document.getElementById('sellingPriceRub').textContent),
|
||
sellingPriceRubReserved: parseDisplayMoney(document.getElementById('sellingPriceRubReserved').textContent),
|
||
discountReserve: discountReserve.toFixed(0),
|
||
exchangeRate: cnyToRubRate ? cnyToRubRate.toFixed(4) : '',
|
||
logisticsFee,
|
||
receivedPrice,
|
||
purchasePrice,
|
||
profit,
|
||
profitRate,
|
||
fullCommission: fullCommission.toFixed(2),
|
||
totalCost: totalCost.toFixed(2),
|
||
weight,
|
||
dimensions: `${length}x${width}x${height}`,
|
||
logisticsLevel: logisticsLevel === 'high' ? '高' : (logisticsLevel === 'high2' ? 'Premium' : '低')
|
||
};
|
||
historyData.unshift(record);
|
||
localStorage.setItem('priceCalculatorHistory', JSON.stringify(historyData));
|
||
renderHistoryTable();
|
||
showToast(`商品「${productName || sku}」录入成功`, 'success');
|
||
}
|
||
function createLinkCell(url, label) {
|
||
const cell = document.createElement('td');
|
||
cell.className = 'px-4 py-3 whitespace-nowrap';
|
||
if (!url) {
|
||
cell.classList.add('text-sm', 'text-gray-500');
|
||
cell.textContent = '--';
|
||
return cell;
|
||
}
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.className = 'text-sm text-primary hover:underline block max-w-[14rem] truncate';
|
||
link.textContent = label || url;
|
||
link.title = label || url;
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
cell.appendChild(link);
|
||
return cell;
|
||
}
|
||
function getRecordProfit(record) {
|
||
const purchaseNum = parseFloat(record.purchasePrice);
|
||
const receivedNum = parseFloat(record.receivedPrice);
|
||
if (record.profit !== undefined && record.profit !== '') {
|
||
return String(record.profit);
|
||
}
|
||
if (!isNaN(receivedNum) && !isNaN(purchaseNum)) {
|
||
return (receivedNum - purchaseNum).toFixed(2);
|
||
}
|
||
return '';
|
||
}
|
||
function getRecordProfitRate(record, profit) {
|
||
const purchaseNum = parseFloat(record.purchasePrice);
|
||
if (record.profitRate !== undefined && record.profitRate !== '') {
|
||
return `${record.profitRate}%`;
|
||
}
|
||
if (!isNaN(purchaseNum) && purchaseNum > 0 && profit !== '') {
|
||
return `${((parseFloat(profit) / purchaseNum) * 100).toFixed(0)}%`;
|
||
}
|
||
return '';
|
||
}
|
||
function renderHistoryTable() {
|
||
const colCount = 17;
|
||
if (historyData.length === 0) {
|
||
historyTableBody.innerHTML = `<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();
|
||
|