waimai / index.html
Ethscriptions's picture
Upload 4 files
54ca146 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4c7dff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="分摊器" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<title>外卖订单收益分摊器</title>
<style>
:root {
--bg-a: #f7fbff;
--bg-b: #fff4fb;
--panel: #ffffff;
--panel-soft: #f4f8ff;
--line: #d7e3ff;
--line-strong: #b8ccff;
--text-main: #253054;
--text-sub: #6b7ba8;
--accent: #4c7dff;
--accent-2: #28d7a7;
--accent-3: #ff7ec7;
--ok: #1fa97f;
--radius: 14px;
--shadow: 0 6px 14px rgba(76, 125, 255, 0.10);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
background: linear-gradient(140deg, var(--bg-a), var(--bg-b));
color: var(--text-main);
min-height: 100vh;
overflow: hidden;
user-select: none;
}
input, textarea { user-select: text; }
.app {
height: 100vh;
display: flex;
flex-direction: column;
gap: 0;
padding: 10px;
}
.btn {
border: 1px solid var(--line-strong);
background: #f2f6ff;
color: #2d3f72;
border-radius: 12px;
padding: 10px 14px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: .14s;
}
.btn:hover { box-shadow: 0 3px 8px rgba(76, 125, 255, 0.12); }
.btn-primary { border: none; background: #4c7dff; color: #fff; }
.main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1.15fr 1fr 1.25fr;
gap: 10px;
height: 100%;
}
.panel {
min-height: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
}
.panel-title {
padding: 12px 14px;
background: linear-gradient(90deg, #edf3ff, #fff2fb);
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 800;
color: #3450a7;
}
.title-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.panel-title small { font-size: 15px; font-weight: 600; color: var(--text-sub); }
.controls {
padding: 12px;
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
background: var(--panel-soft);
}
.search-row {
grid-column: span 2;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
}
select, .text-input {
width: 100%;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: #fff;
color: var(--text-main);
padding: 10px;
font-size: 16px;
outline: none;
}
.product-grid {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
display: grid;
gap: 10px;
align-content: start;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
}
.card {
border: 1px solid #d8e3ff;
border-radius: 12px;
padding: 10px;
min-height: 102px;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(140deg, #f7faff, #fff5fb);
transition: .14s;
}
.card:hover { transform: translateY(-1px); border-color: #9db9ff; box-shadow: 0 8px 16px rgba(76, 125, 255, 0.16); }
.card .name { font-size: 16px; line-height: 1.35; color: #2f3f72; word-break: break-all; }
.card .price { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; color: #1fbe8d; font-size: 24px; }
.order-wrap {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.order-item {
border: 1px solid #d5e1ff;
border-radius: 11px;
padding: 10px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
background: #fbfdff;
}
.order-name { font-size: 17px; line-height: 1.35; color: #2c3b67; font-weight: 700; }
.order-prices {
margin-top: 6px;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
font-size: 14px;
color: var(--text-sub);
}
.order-prices .mono { font-size: 17px; color: #3656b4; font-weight: 800; }
.order-origin-input {
width: 100%;
border: 1px solid #bfd2ff;
border-radius: 9px;
background: linear-gradient(135deg, #ffffff, #f6f9ff);
color: #2f5de0;
padding: 8px 10px;
font-size: 18px;
font-weight: 800;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
cursor: text;
outline: none;
}
.order-origin-input:focus {
border-color: #7ea3ff;
box-shadow: 0 0 0 3px rgba(76, 125, 255, 0.14);
}
.qty-box { display: flex; align-items: center; gap: 8px; }
.mini-btn {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid #bad0ff;
background: #f1f6ff;
color: #3152b2;
cursor: pointer;
font-weight: 700;
font-size: 20px;
}
.qty-text {
min-width: 30px;
text-align: center;
color: #2f437d;
font-size: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.sum-line {
margin-top: auto;
border-top: 1px dashed #cedcff;
padding-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
color: #4761ad;
font-size: 24px;
font-weight: 800;
position: sticky;
bottom: 0;
z-index: 1;
background: linear-gradient(180deg, rgba(255,255,255,0.95), #fff);
padding-bottom: 6px;
}
.sum-line strong {
color: var(--ok);
font-size: 34px;
line-height: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.calc-wrap {
padding: 12px;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.calc-label { color: var(--text-sub); font-size: 15px; margin-bottom: 8px; }
.calc-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
.amount-input {
border: 1px solid #b9ccff;
border-radius: 10px;
background: #fff;
color: #2f5de0;
font-size: 34px;
text-align: right;
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
outline: none;
}
.amount-input::placeholder { color: #9fb1df; }
.result-wrap {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.result-head {
border: 1px solid #d4e1ff;
border-radius: 12px;
padding: 10px;
background: #fff;
font-size: 16px;
color: #536aa2;
line-height: 1.6;
}
.result-item,
.check-box {
border: 1px solid #d4e1ff;
border-radius: 12px;
padding: 12px;
background: #fbfdff;
}
.result-top {
font-size: 19px;
font-weight: 800;
color: #3455b4;
margin-bottom: 10px;
}
.price-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.price-box {
border: 1px solid #dce6ff;
border-radius: 10px;
background: #fff;
padding: 10px;
}
.price-box .label {
font-size: 15px;
color: #5e72a6;
margin-bottom: 6px;
display: block;
font-weight: 700;
}
.price-box .value {
font-size: 28px;
color: #2248b7;
font-weight: 800;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
line-height: 1.1;
}
.price-box .value.actual { color: var(--ok); }
.check-box { font-size: 16px; color: #536aa2; line-height: 1.75; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.empty {
border: 1px dashed #cadbff;
border-radius: 11px;
text-align: center;
padding: 22px 12px;
color: #8a9bc8;
font-size: 17px;
background: #fbfdff;
line-height: 1.6;
}
.modal {
position: fixed;
inset: 0;
display: none;
justify-content: center;
align-items: center;
z-index: 20;
padding: 16px;
background: rgba(94, 121, 190, 0.25);
backdrop-filter: blur(3px);
}
.modal.active { display: flex; }
.modal-box {
width: 700px;
max-width: 100%;
border: 1px solid #d7e2ff;
border-radius: 12px;
overflow: hidden;
background: #fff;
box-shadow: var(--shadow);
}
.modal-head {
background: linear-gradient(90deg, #edf3ff, #fff2fb);
border-bottom: 1px solid #d7e2ff;
padding: 12px 14px;
font-size: 20px;
font-weight: 800;
color: #3757b8;
}
.modal-body { padding: 14px; }
.modal-desc { color: #7587b6; font-size: 15px; line-height: 1.6; margin-bottom: 10px; }
textarea {
width: 100%;
min-height: 300px;
border: 1px solid #c9d8ff;
border-radius: 10px;
background: #fff;
color: #33457a;
padding: 12px;
font-size: 16px;
line-height: 1.6;
resize: vertical;
outline: none;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.modal-foot {
padding: 12px 14px;
border-top: 1px solid #d7e2ff;
display: flex;
justify-content: flex-end;
gap: 10px;
background: #f9fbff;
}
.numpad {
position: fixed;
right: 16px;
bottom: 16px;
width: 360px;
border: 1px solid #c5d7ff;
border-radius: 14px;
background: #fff;
box-shadow: var(--shadow);
padding: 12px;
z-index: 15;
display: none;
}
.numpad.active { display: block; }
.numpad-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.key {
border: 1px solid #c5d6ff;
border-radius: 10px;
background: #f4f8ff;
color: #3954a8;
font-weight: 700;
font-size: 24px;
padding: 12px 0;
cursor: pointer;
}
.key:active { transform: scale(0.98); }
.key-fn { font-size: 16px; background: #eef3ff; color: #5a71b0; }
.key-ac { color: #b84a6a; background: #fff3f7; border-color: #ffd2e1; }
.key-enter { border: none; color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: #cfddff; border-radius: 6px; }
@media (max-width: 1260px) {
.main { grid-template-columns: 1fr; }
body { overflow: auto; }
.app { height: auto; min-height: 100vh; }
.numpad { width: calc(100% - 24px); right: 12px; }
.price-pair { grid-template-columns: 1fr; }
.order-prices { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="app">
<main class="main">
<section class="panel">
<div class="panel-title">
<span>商品陈列</span>
<div class="title-actions">
<button class="btn btn-primary" id="openImportBtn">📥 导入商品</button>
<small id="productCountText">0 / 0 个商品</small>
</div>
</div>
<div class="controls">
<select id="sortSelect">
<option value="default">默认排序</option>
<option value="priceAsc">价格从低到高</option>
<option value="priceDesc">价格从高到低</option>
<option value="name">按名称排序</option>
</select>
<select id="priceFilterSelect">
<option value="all">全部价格分类</option>
</select>
<div class="search-row">
<input id="searchInput" class="text-input" placeholder="搜索:商品名称" />
<button class="btn" id="resetFilterBtn">重置筛选</button>
</div>
</div>
<div class="product-grid" id="productGrid"></div>
</section>
<section class="panel">
<div class="panel-title">
<span>当前订单</span>
<button class="btn" id="clearOrderBtn">清空订单</button>
</div>
<div class="order-wrap" id="orderWrap"></div>
</section>
<section class="panel">
<div class="panel-title">
<span>结算结果</span>
<button class="btn" id="clearResultBtn">清空结果</button>
</div>
<div class="calc-wrap">
<div class="calc-label">输入外卖平台订单优惠后总金额:</div>
<div class="calc-row">
<input id="payInput" class="amount-input" type="text" inputmode="decimal" placeholder="0.00" />
<button class="btn btn-primary" id="calcBtn">开始计算</button>
</div>
</div>
<div class="result-wrap" id="resultWrap"></div>
</section>
</main>
</div>
<div class="modal" id="importModal">
<div class="modal-box">
<div class="modal-head">导入商品(支持 Excel 复制粘贴)</div>
<div class="modal-body">
<p class="modal-desc">
一行一个商品,最后一列为价格,可空格或 Tab 分隔。<br />
示例:<code>爆C柠香绿 9.9</code>
</p>
<textarea id="importText"></textarea>
</div>
<div class="modal-foot">
<button class="btn" id="closeImportBtn">取消</button>
<button class="btn btn-primary" id="saveImportBtn">保存并应用</button>
</div>
</div>
</div>
<div class="numpad" id="numpad">
<div class="numpad-grid">
<button class="key" data-key="7">7</button>
<button class="key" data-key="8">8</button>
<button class="key" data-key="9">9</button>
<button class="key" data-key="4">4</button>
<button class="key" data-key="5">5</button>
<button class="key" data-key="6">6</button>
<button class="key" data-key="1">1</button>
<button class="key" data-key="2">2</button>
<button class="key" data-key="3">3</button>
<button class="key key-ac" data-key="clear">清空</button>
<button class="key" data-key="0">0</button>
<button class="key" data-key=".">.</button>
<button class="key key-fn" data-key="backspace">退格</button>
<button class="key key-fn" data-key="hide">收起</button>
<button class="key key-enter" data-key="calc" id="numpadActionBtn">计算</button>
</div>
</div>
<script>
const STORAGE_KEYS = { products: 'shop_products_v5' };
const state = {
products: [],
order: [],
result: null,
priceRanges: []
};
let activeNumpadInput = null;
const els = {
productGrid: document.getElementById('productGrid'),
productCountText: document.getElementById('productCountText'),
orderWrap: document.getElementById('orderWrap'),
resultWrap: document.getElementById('resultWrap'),
sortSelect: document.getElementById('sortSelect'),
priceFilterSelect: document.getElementById('priceFilterSelect'),
searchInput: document.getElementById('searchInput'),
payInput: document.getElementById('payInput'),
importModal: document.getElementById('importModal'),
importText: document.getElementById('importText'),
numpad: document.getElementById('numpad'),
numpadActionBtn: document.getElementById('numpadActionBtn')
};
function formatMoney(v) { return Number(v).toFixed(2); }
function escapeHtml(input) {
return String(input)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeInputValue(val) {
const normalized = String(val).replace(/[。.。]/g, '.');
const only = normalized.replace(/[^\d.]/g, '');
if (!only) return '';
const parts = only.split('.');
const intPart = parts[0] || '0';
if (parts.length === 1) return intPart;
return `${intPart}.${parts.slice(1).join('').slice(0, 2)}`;
}
function normalizeProduct(item) {
const name = String(item.name || '').trim();
const price = Number(item.price);
return { name, price };
}
function loadProducts() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.products);
if (!raw) {
state.products = [];
return;
}
const parsed = JSON.parse(raw);
const list = Array.isArray(parsed)
? parsed
.map(normalizeProduct)
.filter((it) => it.name && isFinite(it.price) && it.price > 0)
: [];
state.products = list;
} catch {
state.products = [];
}
}
function saveProducts() {
localStorage.setItem(STORAGE_KEYS.products, JSON.stringify(state.products));
}
function parseProductsText(text) {
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const list = [];
for (const line of lines) {
const m = line.match(/^(.*?)[\s\t]+(-?\d+(?:\.\d+)?)$/);
if (!m) continue;
const name = m[1].trim();
const price = Number(m[2]);
if (!name || !isFinite(price) || price <= 0) continue;
list.push({ name, price });
}
return list;
}
function getPriceRanges(products) {
if (!products.length) return [];
const prices = products.map((p) => p.price).filter((n) => isFinite(n)).sort((a, b) => a - b);
if (!prices.length) return [];
const min = prices[0];
const max = prices[prices.length - 1];
if (min === max) {
return [{ id: 'r0', min, max, label: ${formatMoney(min)}(同一价格)` }];
}
const bucketCount = 4;
const span = (max - min) / bucketCount;
const ranges = [];
for (let i = 0; i < bucketCount; i += 1) {
const start = min + span * i;
const end = i === bucketCount - 1 ? max : min + span * (i + 1);
const label = i === bucketCount - 1
? ${formatMoney(start)} ~ ¥${formatMoney(end)}`
: ${formatMoney(start)} ~ ¥${formatMoney(end)}`;
ranges.push({ id: `r${i}`, min: start, max: end, label });
}
return ranges;
}
function renderPriceFilterOptions() {
const prev = els.priceFilterSelect.value || 'all';
state.priceRanges = getPriceRanges(state.products);
const options = ['<option value="all">全部价格分类</option>'];
state.priceRanges.forEach((r) => {
options.push(`<option value="${r.id}">${r.label}</option>`);
});
els.priceFilterSelect.innerHTML = options.join('');
const canKeep = prev === 'all' || state.priceRanges.some((r) => r.id === prev);
els.priceFilterSelect.value = canKeep ? prev : 'all';
}
function openImportModal() {
els.importText.value = state.products.map((p) => `${p.name}\t${p.price}`).join('\n');
els.importModal.classList.add('active');
}
function closeImportModal() {
els.importModal.classList.remove('active');
}
function saveImport() {
const parsed = parseProductsText(els.importText.value);
if (!parsed.length) {
alert('未识别到有效商品,请检查格式:商品名 + 价格。');
return;
}
state.products = parsed;
renderPriceFilterOptions();
saveProducts();
renderProducts();
closeImportModal();
alert(`导入成功,共 ${parsed.length} 个商品。`);
}
function filteredProducts() {
const keyword = els.searchInput.value.trim().toLowerCase();
const sort = els.sortSelect.value;
const priceFilter = els.priceFilterSelect.value;
let list = state.products.filter((p) => {
if (keyword) {
const byName = p.name.toLowerCase().includes(keyword);
if (!byName) return false;
}
if (priceFilter !== 'all') {
const range = state.priceRanges.find((r) => r.id === priceFilter);
if (!range) return true;
const isLast = range.id === state.priceRanges[state.priceRanges.length - 1]?.id;
const inRange = isLast
? p.price >= range.min && p.price <= range.max
: p.price >= range.min && p.price < range.max;
if (!inRange) return false;
}
return true;
});
if (sort === 'priceAsc') list.sort((a, b) => a.price - b.price);
else if (sort === 'priceDesc') list.sort((a, b) => b.price - a.price);
else if (sort === 'name') list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
return list;
}
function resetProductFilters() {
els.sortSelect.value = 'default';
els.priceFilterSelect.value = 'all';
els.searchInput.value = '';
renderProducts();
}
function addProductToOrder(product) {
const found = state.order.find((it) => it.name === product.name && it.price === product.price);
if (found) {
found.qty += 1;
} else {
state.order.push({
name: product.name,
price: product.price,
qty: 1
});
}
state.result = null;
renderOrder();
}
function updateQty(index, delta) {
const item = state.order[index];
if (!item) return;
item.qty += delta;
if (item.qty <= 0) state.order.splice(index, 1);
state.result = null;
renderOrder();
}
function updateOrderPrice(index, value) {
const item = state.order[index];
if (!item) return;
const v = Number(value);
if (!isFinite(v) || v <= 0) return;
const nextPrice = Math.round(v * 100) / 100;
if (item.price === nextPrice) return;
item.price = nextPrice;
state.result = null;
renderOrderFooter();
renderResult();
}
function clearOrder() {
state.order = [];
state.result = null;
renderOrder();
}
function clearResult() {
state.result = null;
renderResult();
}
function getOrderSummary() {
const count = state.order.reduce((sum, item) => sum + item.qty, 0);
const total = state.order.reduce((sum, item) => sum + item.price * item.qty, 0);
return { count, total };
}
function renderOrderFooter() {
const sumEl = els.orderWrap.querySelector('.sum-line');
if (!sumEl) return;
const { count, total } = getOrderSummary();
sumEl.innerHTML = `<span>${count}件商品原价总和</span><strong>¥ ${formatMoney(total)}</strong>`;
}
function renderProducts() {
const displayList = filteredProducts();
els.productCountText.textContent = `${displayList.length} / ${state.products.length} 个商品`;
if (!state.products.length) {
els.productGrid.innerHTML = '<div class="empty">当前没有商品,请点击上方“导入商品”</div>';
return;
}
if (!displayList.length) {
els.productGrid.innerHTML = '<div class="empty">没有匹配商品,请按商品名称搜索</div>';
return;
}
const frag = document.createDocumentFragment();
displayList.forEach((p) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="name">${escapeHtml(p.name)}</div>
<div class="price">¥ ${formatMoney(p.price)}</div>
`;
card.addEventListener('click', () => addProductToOrder(p));
frag.appendChild(card);
});
els.productGrid.innerHTML = '';
els.productGrid.appendChild(frag);
}
function renderOrder() {
const frag = document.createDocumentFragment();
if (!state.order.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = '请先从左侧商品陈列中点击添加商品';
frag.appendChild(empty);
} else {
state.order.forEach((item, index) => {
const subtotal = item.price * item.qty;
const row = document.createElement('div');
row.className = 'order-item';
row.innerHTML = `
<div>
<div class="order-name">${escapeHtml(item.name)}</div>
<div class="order-prices">
<div>原价:<input class="order-origin-input" data-price-index="${index}" value="${formatMoney(item.price)}" /></div>
<div>小计:<span class="mono">¥ ${formatMoney(subtotal)}</span></div>
</div>
</div>
<div class="qty-box">
<button class="mini-btn" data-action="minus" data-index="${index}">-</button>
<span class="qty-text">${item.qty}</span>
<button class="mini-btn" data-action="plus" data-index="${index}">+</button>
</div>
`;
frag.appendChild(row);
});
}
const { count, total } = getOrderSummary();
const sum = document.createElement('div');
sum.className = 'sum-line';
sum.innerHTML = `<span>${count}件商品原价总和</span><strong>¥ ${formatMoney(total)}</strong>`;
els.orderWrap.innerHTML = '';
els.orderWrap.appendChild(frag);
els.orderWrap.appendChild(sum);
renderResult();
}
function getAllocations(orderItems, payTotal) {
const totalOriginal = orderItems.reduce((sum, item) => sum + item.price * item.qty, 0);
const expanded = [];
orderItems.forEach((item) => {
for (let i = 0; i < item.qty; i += 1) {
expanded.push({ name: item.name, originalPrice: item.price, price: item.price });
}
});
const totalCount = expanded.length;
const rawAlloc = expanded.map((it) => (it.price / totalOriginal) * payTotal);
const rounded = rawAlloc.map((v) => Math.round(v * 100) / 100);
const roundedSum = rounded.reduce((a, b) => a + b, 0);
const firstDiff = Math.round((payTotal - roundedSum) * 100) / 100;
if (rounded.length && firstDiff !== 0) {
rounded[rounded.length - 1] = Math.round((rounded[rounded.length - 1] + firstDiff) * 100) / 100;
}
const seqMap = new Map();
const rows = expanded.map((item, idx) => {
const seq = (seqMap.get(item.name) || 0) + 1;
seqMap.set(item.name, seq);
return {
name: item.name,
seq,
originalPrice: item.originalPrice,
allocatedPrice: rounded[idx]
};
});
const allocatedTotal = rows.reduce((sum, row) => sum + row.allocatedPrice, 0);
return {
rows,
totalOriginal,
totalCount,
allocatedTotal,
diff: Math.round((payTotal - allocatedTotal) * 100) / 100
};
}
function calculate() {
if (!state.order.length) {
alert('请先添加订单商品。');
return;
}
const payTotal = Number(els.payInput.value);
if (!isFinite(payTotal) || payTotal <= 0) {
alert('请输入有效的订单优惠后总金额。');
els.payInput.focus();
openNumpad();
return;
}
state.result = { payTotal, ...getAllocations(state.order, payTotal) };
renderResult();
closeNumpad();
}
function renderResult() {
if (!state.result || !state.order.length) {
if (lastResultEmptyState === true) return;
lastResultEmptyState = true;
els.resultWrap.innerHTML = '<div class="empty">计算后将完整展示每一件商品:原价 与 订单价格(优惠后)</div>';
return;
}
lastResultEmptyState = false;
const { payTotal, totalOriginal, totalCount, rows, allocatedTotal, diff } = state.result;
const frag = document.createDocumentFragment();
const head = document.createElement('div');
head.className = 'result-head';
head.innerHTML = `
<div>结果条数:<strong class="mono">${rows.length}</strong>(逐件)</div>
<div>订单优惠后总金额:<strong class="mono">¥ ${formatMoney(payTotal)}</strong> | ${totalCount}件商品原价总和:<strong class="mono">¥ ${formatMoney(totalOriginal)}</strong></div>
`;
frag.appendChild(head);
rows.forEach((row) => {
const item = document.createElement('div');
item.className = 'result-item';
item.innerHTML = `
<div class="result-top">${escapeHtml(row.name)}(第${row.seq}件)</div>
<div class="price-pair">
<div class="price-box">
<span class="label">原价</span>
<strong class="value">¥ ${formatMoney(row.originalPrice)}</strong>
</div>
<div class="price-box">
<span class="label">订单价格(优惠后)</span>
<strong class="value actual">¥ ${formatMoney(row.allocatedPrice)}</strong>
</div>
</div>
`;
frag.appendChild(item);
});
const check = document.createElement('div');
check.className = 'check-box';
check.innerHTML = `
<div>校检:分摊后总和 <strong class="mono">¥ ${formatMoney(allocatedTotal)}</strong> | 订单优惠后总金额 <strong class="mono">¥ ${formatMoney(payTotal)}</strong></div>
<div>差额:<strong class="mono">¥ ${formatMoney(Math.abs(diff))}</strong></div>
`;
frag.appendChild(check);
els.resultWrap.innerHTML = '';
els.resultWrap.appendChild(frag);
}
function updateNumpadActionLabel() {
if (!els.numpadActionBtn) return;
const isPayInput = !activeNumpadInput || activeNumpadInput === els.payInput;
els.numpadActionBtn.textContent = isPayInput ? '计算' : '确认';
}
function handleKeypadPress(key) {
const targetInput = activeNumpadInput || els.payInput;
const current = targetInput.value;
if (key === 'hide') {
closeNumpad();
return;
}
if (key === 'calc') {
if (targetInput !== els.payInput) {
targetInput.blur();
closeNumpad();
return;
}
calculate();
return;
}
if (key === 'clear') {
targetInput.value = '';
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (key === 'backspace') {
targetInput.value = current.slice(0, -1);
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (key === '.') {
if (!current) targetInput.value = '0.';
else if (!current.includes('.')) targetInput.value += '.';
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (/^\d$/.test(key)) {
if (current === '0') targetInput.value = key;
else targetInput.value = normalizeInputValue(`${current}${key}`);
targetInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
function openNumpad(targetInput = els.payInput) {
activeNumpadInput = targetInput;
updateNumpadActionLabel();
els.numpad.classList.add('active');
}
function closeNumpad() {
els.numpad.classList.remove('active');
activeNumpadInput = null;
updateNumpadActionLabel();
}
function bindEvents() {
document.getElementById('openImportBtn').addEventListener('click', openImportModal);
document.getElementById('closeImportBtn').addEventListener('click', closeImportModal);
document.getElementById('saveImportBtn').addEventListener('click', saveImport);
document.getElementById('resetFilterBtn').addEventListener('click', resetProductFilters);
document.getElementById('calcBtn').addEventListener('click', calculate);
document.getElementById('clearOrderBtn').addEventListener('click', clearOrder);
document.getElementById('clearResultBtn').addEventListener('click', clearResult);
const scheduleRenderProducts = (() => {
let rafId = 0;
return () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
rafId = 0;
renderProducts();
});
};
})();
els.sortSelect.addEventListener('change', renderProducts);
els.priceFilterSelect.addEventListener('change', renderProducts);
els.searchInput.addEventListener('input', scheduleRenderProducts);
els.productGrid.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const card = target.closest('.card');
if (!(card instanceof HTMLElement)) return;
const index = Number(card.dataset.productIndex);
if (Number.isNaN(index)) return;
const product = lastDisplayedProducts[index];
if (!product) return;
addProductToOrder(product);
});
els.orderWrap.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const action = target.dataset.action;
if (!action) return;
const index = Number(target.dataset.index);
if (Number.isNaN(index)) return;
if (action === 'minus') updateQty(index, -1);
if (action === 'plus') updateQty(index, 1);
});
els.orderWrap.addEventListener('focusin', (e) => {
const target = e.target;
if (!(target instanceof HTMLInputElement)) return;
if (!target.dataset.priceIndex) return;
openNumpad(target);
});
els.orderWrap.addEventListener('input', (e) => {
const target = e.target;
if (!(target instanceof HTMLInputElement)) return;
if (!target.dataset.priceIndex) return;
const v = normalizeInputValue(target.value);
target.value = v;
const index = Number(target.dataset.priceIndex);
if (Number.isNaN(index)) return;
updateOrderPrice(index, v);
});
els.orderWrap.addEventListener('keydown', (e) => {
const target = e.target;
if (!(target instanceof HTMLInputElement)) return;
if (!target.dataset.priceIndex) return;
if (e.key !== 'Enter') return;
e.preventDefault();
handleKeypadPress('calc');
});
els.payInput.addEventListener('input', () => {
els.payInput.value = normalizeInputValue(els.payInput.value);
});
els.payInput.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault();
handleKeypadPress('calc');
});
els.payInput.addEventListener('focus', () => {
openNumpad(els.payInput);
});
document.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const clickInsidePad = els.numpad.contains(target);
const clickInput = target === els.payInput || !!target.closest('.order-origin-input');
if (!clickInsidePad && !clickInput) closeNumpad();
if (target === els.importModal) closeImportModal();
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
if (!els.numpad.classList.contains('active')) return;
const focused = document.activeElement;
if (!(focused instanceof HTMLElement)) return;
if (!els.numpad.contains(focused)) return;
e.preventDefault();
handleKeypadPress('calc');
});
els.numpad.addEventListener('mousedown', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (!target.closest('.key')) return;
e.preventDefault();
});
els.numpad.addEventListener('click', (e) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const key = target.dataset.key;
if (!key) return;
handleKeypadPress(key);
});
}
function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js').catch(() => {});
});
}
function init() {
loadProducts();
renderPriceFilterOptions();
bindEvents();
resetProductFilters();
renderOrder();
renderResult();
registerServiceWorker();
}
init();
</script>
</body>
</html>