Spaces:
Running
Running
| <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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| 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> | |