Files
ozon-seller-kit/web/js/app.js
T
2026-08-07 17:34:24 +08:00

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();