特调饮品酒精度数计算器edit icon

Fork(复制)
下载
嵌入
BUG反馈
index.html
index.html
            
            
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>ABV Calculator | 特调酒精度计算器</title>
  <style>
    :root{
      --bg:#f5f2ec;
      --card:#ffffff;
      --ink:#1f2937;
      --muted:#6b7280;
      --line:#e5e7eb;
      --accent:#7a5c44;
      --accent-2:#b08a6a;
      --danger:#b91c1c;
      --shadow: 0 10px 30px rgba(17,24,39,.10);
      --radius: 18px;
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
      color:var(--ink);
      background: radial-gradient(1200px 800px at 10% 0%, #fff 0%, var(--bg) 55%) fixed;
    }
    header{
      position:sticky;
      top:0;
      z-index:10;
      backdrop-filter:saturate(140%) blur(10px);
      background: color-mix(in srgb, var(--bg) 85%, white 15%);
      border-bottom:1px solid var(--line);
    }
    .wrap{max-width:1100px;margin:0 auto;padding:16px 16px 28px;}
    .title{
      display:flex;align-items:center;justify-content:space-between;gap:12px;
    }
    .title h1{margin:0;font-size:18px;letter-spacing:.3px;}
    .title .sub{color:var(--muted);font-size:12px;}

    .layout{
      display:grid;
      grid-template-columns: 1.15fr .85fr;
      gap:16px;
      margin-top:16px;
    }
    @media (max-width: 980px){
      .layout{grid-template-columns:1fr;}
      header{position:static}
    }

    .card{
      background:var(--card);
      border:1px solid var(--line);
      border-radius:var(--radius);
      box-shadow:var(--shadow);
    }
    .card .hd{
      padding:14px 16px;
      border-bottom:1px solid var(--line);
      display:flex;align-items:center;justify-content:space-between;gap:12px;
    }
    .card .hd h2{margin:0;font-size:14px;}
    .card .bd{padding:14px 16px;}

    .toolbar{
      display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:flex-end;
    }

    .seg{
      display:inline-flex;
      border:1px solid var(--line);
      border-radius:999px;
      overflow:hidden;
      background:#fff;
    }
    .seg input{display:none}
    .seg label{
      padding:8px 12px;
      font-size:13px;
      color:var(--muted);
      cursor:pointer;
      user-select:none;
    }
    .seg input:checked + label{
      background: color-mix(in srgb, var(--accent) 14%, white 86%);
      color:var(--ink);
      font-weight:600;
    }

    .btn{
      appearance:none;
      border:1px solid var(--line);
      background:#fff;
      border-radius:999px;
      padding:9px 12px;
      font-size:13px;
      cursor:pointer;
      display:inline-flex;align-items:center;gap:8px;
      transition: transform .06s ease, border-color .2s ease, background .2s ease;
    }
    .btn:hover{border-color: color-mix(in srgb, var(--accent) 40%, var(--line) 60%);}
    .btn:active{transform: translateY(1px)}
    .btn.primary{
      border-color: color-mix(in srgb, var(--accent) 40%, var(--line) 60%);
      background: color-mix(in srgb, var(--accent) 14%, white 86%);
    }
    .btn.danger{border-color: color-mix(in srgb, var(--danger) 35%, var(--line) 65%); color:var(--danger)}

    .hint{color:var(--muted);font-size:12px;line-height:1.55}

    .grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
    @media (max-width: 520px){.grid2{grid-template-columns:1fr;}}

    .section{margin-top:12px;padding-top:12px;border-top:1px dashed var(--line)}

    .cat{
      border:1px solid var(--line);
      border-radius:16px;
      overflow:hidden;
      margin-top:10px;
      background: linear-gradient(180deg, #fff 0%, #fff 70%, #fafafa 100%);
    }
    .cat .ch{
      padding:10px 12px;
      display:flex;align-items:center;justify-content:space-between;gap:10px;
      background: color-mix(in srgb, var(--accent) 8%, white 92%);
      border-bottom:1px solid var(--line);
    }
    .cat .ch .name{font-weight:700;font-size:13px;}
    .cat .ch .meta{color:var(--muted);font-size:12px;}

    .rows{padding:10px 12px;display:flex;flex-direction:column;gap:10px;}

    .row{
      display:grid;
      grid-template-columns: 1.3fr .7fr .9fr auto;
      gap:10px;
      align-items:end;
    }
    @media (max-width: 760px){
      .row{grid-template-columns:1fr 1fr 1fr auto;}
    }
    @media (max-width: 520px){
      .row{grid-template-columns:1fr 1fr auto;}
      .row .abvWrap{grid-column:1 / -1}
    }

    .field{display:flex;flex-direction:column;gap:6px;}
    .field label{font-size:12px;color:var(--muted)}
    .field input{
      width:100%;
      padding:10px 10px;
      border:1px solid var(--line);
      border-radius:12px;
      font-size:14px;
      outline:none;
      background:#fff;
    }
    .field input:focus{border-color: color-mix(in srgb, var(--accent) 45%, var(--line) 55%); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent 88%);}    

    .mini{
      height:40px;
      width:40px;
      border-radius:12px;
      display:inline-flex;align-items:center;justify-content:center;
      border:1px solid var(--line);
      background:#fff;
      cursor:pointer;
    }
    .mini:hover{border-color: color-mix(in srgb, var(--accent) 40%, var(--line) 60%);}    

    .kpi{
      display:grid;
      grid-template-columns:1fr;
      gap:12px;
    }
    .k{
      padding:14px;
      border-radius:16px;
      border:1px solid var(--line);
      background: linear-gradient(180deg, #fff 0%, #fff 65%, #fafafa 100%);
    }
    .k .t{color:var(--muted);font-size:12px;}
    .k .v{font-size:34px;line-height:1.05;margin-top:6px;font-weight:800;letter-spacing:-.6px;}
    .k .u{font-size:14px;color:var(--muted);margin-left:6px;font-weight:600}

    .range{
      display:flex;flex-direction:column;gap:8px;
    }
    .range .top{display:flex;align-items:center;justify-content:space-between;gap:10px;}
    input[type="range"]{width:100%}

    .toast{
      position:fixed;left:50%;bottom:18px;transform:translateX(-50%);
      background:rgba(17,24,39,.92);color:#fff;padding:10px 12px;border-radius:12px;
      font-size:13px;opacity:0;pointer-events:none;transition:opacity .18s ease;
    }
    .toast.show{opacity:1}

    .footerNote{color:var(--muted);font-size:12px;line-height:1.55;margin-top:12px}
    code.inline{background:#f3f4f6;border:1px solid #e5e7eb;border-radius:8px;padding:2px 6px;font-size:12px}
  </style>
</head>
<body>
  <header>
    <div class="wrap title">
      <div>
        <h1>ABV Calculator <span class="sub">特调饮品酒精度计算器(单文件)</span></h1>
        <div class="sub">公式:ABV = 总纯酒精体积 ÷(总液体体积 + 稀释水)× 100%</div>
      </div>
      <div class="toolbar">
        <div class="seg" role="radiogroup" aria-label="单位">
          <input id="u_ml" type="radio" name="unit" value="mL" checked>
          <label for="u_ml">mL</label>
          <input id="u_oz" type="radio" name="unit" value="oz">
          <label for="u_oz">oz</label>
        </div>
        <button class="btn" id="btnExample" title="加载一个示例配方">示例配方</button>
        <button class="btn" id="btnReset" title="清空所有输入">重置</button>
      </div>
    </div>
  </header>

  <main class="wrap layout">
    <!-- LEFT: INPUTS -->
    <section class="card" aria-label="配方输入">
      <div class="hd">
        <h2>配方输入</h2>
        <div class="hint">每个材料可填:体积 +(可选)ABV%。不含酒精就留空或填 0。</div>
      </div>
      <div class="bd">
        <div id="cats"></div>

        <div class="section">
          <div class="range">
            <div class="top">
              <div>
                <div style="font-weight:700; font-size:13px;">稀释(加水)比例</div>
                <div class="hint">常见参考:搅拌 15–25%|摇和 30–40%|加冰直调 5–10%</div>
              </div>
              <div style="font-weight:800; font-size:16px;">
                <span id="dilutionLabel">20</span><span class="hint">%</span>
              </div>
            </div>
            <input id="dilution" type="range" min="0" max="60" step="1" value="20" />
          </div>
        </div>

        <div class="section grid2">
          <button class="btn primary" id="btnCopy">复制结果</button>
          <button class="btn" id="btnExport">导出JSON(配方)</button>
        </div>

        <div class="footerNote">
          说明:此工具按“体积”近似计算,未考虑酒精/糖浆混合后的体积收缩与密度差异;用于配方开发、比赛与菜单标注的预估足够实用。
        </div>
      </div>
    </section>

    <!-- RIGHT: RESULTS -->
    <aside class="card" aria-label="计算结果">
      <div class="hd">
        <h2>结果</h2>
        <div class="hint">自动实时计算</div>
      </div>
      <div class="bd kpi">
        <div class="k">
          <div class="t">总纯酒精(Ethanol)</div>
          <div class="v"><span id="alcoholVal">0</span><span class="u" id="unit1">mL</span></div>
        </div>
        <div class="k">
          <div class="t">稀释前总体积</div>
          <div class="v"><span id="beforeVal">0</span><span class="u" id="unit2">mL</span></div>
        </div>
        <div class="k">
          <div class="t">稀释后总体积</div>
          <div class="v"><span id="afterVal">0</span><span class="u" id="unit3">mL</span></div>
        </div>
        <div class="k" style="border-color: color-mix(in srgb, var(--accent) 28%, var(--line) 72%);">
          <div class="t">饮品 ABV</div>
          <div class="v"><span id="abvVal">0</span><span class="u">%</span></div>
        </div>

        <div class="section hint">
          <div style="font-weight:700;margin-bottom:6px;color:var(--ink)">快速小贴士</div>
          <ul style="margin:0;padding-left:18px;line-height:1.65">
            <li>如果你做的是“浓缩 + 利口酒 + 牛奶/奶油”类热饮,也可以把牛奶体积计入(ABV=0)。</li>
            <li>Bitters / Tincture 也能算:体积很小但 ABV 高时会影响最终 ABV。</li>
            <li>需要更贴近现场:用你实际的搅拌/摇和出杯稀释率替换这里的百分比。</li>
          </ul>
        </div>
      </div>
    </aside>
  </main>

  <div class="toast" id="toast" role="status" aria-live="polite"></div>

  <script>
    // ===== ABV Calculator (vanilla JS) =====
    const OZ_TO_ML = 29.5735295625;

    const CATEGORIES = [
      { key: 'base', label: 'Base Spirit(基酒)', defaultAbv: 40 },
      { key: 'modifier', label: 'Modifier(利口酒/苦艾/雪莉/味美思等)', defaultAbv: 20 },
      { key: 'bitters', label: 'Bitters & Tinctures(苦精/酊剂)', defaultAbv: 44 },
      { key: 'syrup', label: 'Syrup(糖浆/甜味剂)', defaultAbv: 0 },
      { key: 'cordial', label: 'Cordial(果露/浓缩风味液)', defaultAbv: 0 },
      { key: 'juice', label: 'Juice(果汁/茶/咖啡等非酒精液体)', defaultAbv: 0 },
      { key: 'foam', label: 'Foaming agent(蛋清/水牛/泡沫剂)', defaultAbv: 0 },
      { key: 'other', label: 'Other(苏打/苏打水/气泡/其它)', defaultAbv: 0 },
    ];

    const els = {
      cats: document.getElementById('cats'),
      dilution: document.getElementById('dilution'),
      dilutionLabel: document.getElementById('dilutionLabel'),
      alcoholVal: document.getElementById('alcoholVal'),
      beforeVal: document.getElementById('beforeVal'),
      afterVal: document.getElementById('afterVal'),
      abvVal: document.getElementById('abvVal'),
      unit1: document.getElementById('unit1'),
      unit2: document.getElementById('unit2'),
      unit3: document.getElementById('unit3'),
      toast: document.getElementById('toast'),
      btnReset: document.getElementById('btnReset'),
      btnExample: document.getElementById('btnExample'),
      btnCopy: document.getElementById('btnCopy'),
      btnExport: document.getElementById('btnExport'),
      u_ml: document.getElementById('u_ml'),
      u_oz: document.getElementById('u_oz'),
    };

    const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36);

    /** @type {{unit:'mL'|'oz', dilutionPct:number, items:Array<{id:string, cat:string, name:string, abv:number, vol:number}>}} */
    let state = {
      unit: 'mL',
      dilutionPct: 20,
      items: [],
    };

    // ---------- persistence ----------
    const LS_KEY = 'abv_calc_state_v1';
    let saveTimer = null;
    function saveSoon(){
      clearTimeout(saveTimer);
      saveTimer = setTimeout(() => {
        try{ localStorage.setItem(LS_KEY, JSON.stringify(state)); }catch(e){}
      }, 120);
    }

    function loadState(){
      try{
        const raw = localStorage.getItem(LS_KEY);
        if(!raw) return;
        const s = JSON.parse(raw);
        if(!s || !s.unit || !Array.isArray(s.items)) return;
        state = {
          unit: (s.unit === 'oz' ? 'oz' : 'mL'),
          dilutionPct: clampNum(s.dilutionPct, 0, 60, 20),
          items: s.items.map(it => ({
            id: it.id || uid(),
            cat: String(it.cat || 'other'),
            name: String(it.name || ''),
            abv: clampNum(it.abv, 0, 100, 0),
            vol: clampNum(it.vol, 0, 100000, 0),
          }))
        };
      }catch(e){}
    }

    // ---------- helpers ----------
    function clampNum(v, min, max, fallback){
      const n = Number(v);
      if(!Number.isFinite(n)) return fallback;
      return Math.min(max, Math.max(min, n));
    }

    function toML(value){
      const v = clampNum(value, 0, 1e9, 0);
      return state.unit === 'mL' ? v : v * OZ_TO_ML;
    }

    function fromML(value){
      const v = clampNum(value, 0, 1e9, 0);
      return state.unit === 'mL' ? v : v / OZ_TO_ML;
    }

    function fmt(v, digits=1){
      const n = Number(v);
      if(!Number.isFinite(n)) return '0';
      const d = digits;
      return n.toFixed(d).replace(/\.0$/, '');
    }

    function showToast(msg){
      els.toast.textContent = msg;
      els.toast.classList.add('show');
      setTimeout(() => els.toast.classList.remove('show'), 1100);
    }

    // ---------- rendering ----------
    function ensureDefaults(){
      // First run: create one row per major alcohol category
      if(state.items.length) return;
      const by = (cat, abv) => ({ id: uid(), cat, name: '', abv, vol: 0 });
      state.items.push(by('base', 40));
      state.items.push(by('modifier', 0));
      state.items.push(by('bitters', 0));
      state.items.push(by('syrup', 0));
      state.items.push(by('juice', 0));
    }

    function groupItems(){
      /** @type {Record<string, Array<any>>} */
      const g = {};
      for(const c of CATEGORIES) g[c.key] = [];
      for(const it of state.items){
        if(!g[it.cat]) g[it.cat] = [];
        g[it.cat].push(it);
      }
      return g;
    }

    function makeRow(it){
      const row = document.createElement('div');
      row.className = 'row';
      row.dataset.id = it.id;

      const fName = document.createElement('div');
      fName.className = 'field';
      fName.innerHTML = `<label>名称(可选)</label><input inputmode="text" placeholder="如:Gin / Rum / Espresso / Syrup" value="${escapeHtml(it.name)}">`;

      const fVol = document.createElement('div');
      fVol.className = 'field';
      fVol.innerHTML = `<label>体积(${state.unit})</label><input inputmode="decimal" placeholder="0" value="${it.vol || ''}">`;

      const fAbv = document.createElement('div');
      fAbv.className = 'field abvWrap';
      fAbv.innerHTML = `<label>ABV(%)</label><input inputmode="decimal" placeholder="0" value="${it.abv || ''}">`;

      const del = document.createElement('button');
      del.className = 'mini';
      del.type = 'button';
      del.title = '删除该行';
      del.ariaLabel = '删除';
      del.textContent = '✕';

      // listeners
      const [nameInput] = fName.getElementsByTagName('input');
      const [volInput] = fVol.getElementsByTagName('input');
      const [abvInput] = fAbv.getElementsByTagName('input');

      nameInput.addEventListener('input', () => {
        it.name = nameInput.value;
        saveSoon();
      });
      volInput.addEventListener('input', () => {
        it.vol = clampNum(volInput.value, 0, 100000, 0);
        recalc();
        saveSoon();
      });
      abvInput.addEventListener('input', () => {
        it.abv = clampNum(abvInput.value, 0, 100, 0);
        recalc();
        saveSoon();
      });
      del.addEventListener('click', () => {
        state.items = state.items.filter(x => x.id !== it.id);
        render();
        recalc();
        saveSoon();
      });

      row.appendChild(fName);
      row.appendChild(fVol);
      row.appendChild(fAbv);
      row.appendChild(del);
      return row;
    }

    function render(){
      els.cats.innerHTML = '';
      const grouped = groupItems();

      for(const cat of CATEGORIES){
        const box = document.createElement('div');
        box.className = 'cat';

        const header = document.createElement('div');
        header.className = 'ch';

        const left = document.createElement('div');
        left.innerHTML = `<div class="name">${cat.label}</div><div class="meta">可添加多行</div>`;

        const add = document.createElement('button');
        add.className = 'btn';
        add.type = 'button';
        add.textContent = '添加 +';
        add.addEventListener('click', () => {
          state.items.push({
            id: uid(),
            cat: cat.key,
            name: '',
            abv: cat.defaultAbv,
            vol: 0,
          });
          render();
          recalc();
          saveSoon();
        });

        header.appendChild(left);
        header.appendChild(add);

        const rows = document.createElement('div');
        rows.className = 'rows';

        const items = grouped[cat.key] || [];
        if(items.length === 0){
          const empty = document.createElement('div');
          empty.className = 'hint';
          empty.textContent = '暂无,点击“添加 +”增加一行。';
          rows.appendChild(empty);
        }else{
          for(const it of items){
            rows.appendChild(makeRow(it));
          }
        }

        box.appendChild(header);
        box.appendChild(rows);
        els.cats.appendChild(box);
      }

      // sync unit labels
      els.unit1.textContent = state.unit;
      els.unit2.textContent = state.unit;
      els.unit3.textContent = state.unit;

      // sync dilution
      els.dilution.value = String(state.dilutionPct);
      els.dilutionLabel.textContent = String(state.dilutionPct);

      // sync radios
      els.u_ml.checked = state.unit === 'mL';
      els.u_oz.checked = state.unit === 'oz';
    }

    function escapeHtml(s){
      return String(s ?? '')
        .replace(/&/g,'&amp;')
        .replace(/</g,'&lt;')
        .replace(/>/g,'&gt;')
        .replace(/"/g,'&quot;')
        .replace(/'/g,'&#039;');
    }

    // ---------- calculation ----------
    function recalc(){
      const beforeML = state.items.reduce((sum, it) => sum + toML(it.vol), 0);
      const alcoholML = state.items.reduce((sum, it) => {
        const abv = clampNum(it.abv, 0, 100, 0) / 100;
        return sum + toML(it.vol) * abv;
      }, 0);

      const dilution = clampNum(state.dilutionPct, 0, 60, 20) / 100;
      const waterML = beforeML * dilution;
      const afterML = beforeML + waterML;
      const abv = afterML > 0 ? (alcoholML / afterML) * 100 : 0;

      // display in chosen unit
      els.beforeVal.textContent = fmt(fromML(beforeML), state.unit === 'mL' ? 0 : 2);
      els.alcoholVal.textContent = fmt(fromML(alcoholML), state.unit === 'mL' ? 1 : 2);
      els.afterVal.textContent = fmt(fromML(afterML), state.unit === 'mL' ? 0 : 2);
      els.abvVal.textContent = fmt(abv, 1);
    }

    // ---------- actions ----------
    function resetAll(){
      state.items = [];
      state.dilutionPct = 20;
      ensureDefaults();
      render();
      recalc();
      saveSoon();
      showToast('已重置');
    }

    function loadExample(){
      // Example: Espresso Martini-ish
      state.items = [
        { id: uid(), cat: 'base', name: 'Vodka', abv: 40, vol: state.unit === 'mL' ? 40 : 1.35 },
        { id: uid(), cat: 'modifier', name: 'Coffee Liqueur', abv: 20, vol: state.unit === 'mL' ? 20 : 0.68 },
        { id: uid(), cat: 'syrup', name: 'Simple syrup', abv: 0, vol: state.unit === 'mL' ? 10 : 0.34 },
        { id: uid(), cat: 'juice', name: 'Espresso', abv: 0, vol: state.unit === 'mL' ? 30 : 1.01 },
      ];
      state.dilutionPct = 32;
      render();
      recalc();
      saveSoon();
      showToast('已加载示例配方');
    }

    async function copyResult(){
      const alcohol = els.alcoholVal.textContent;
      const before = els.beforeVal.textContent;
      const after = els.afterVal.textContent;
      const abv = els.abvVal.textContent;
      const text = `ABV 计算结果\n- 总纯酒精:${alcohol} ${state.unit}\n- 稀释前体积:${before} ${state.unit}\n- 稀释后体积:${after} ${state.unit}\n- 饮品 ABV:${abv}%\n- 稀释比例:${state.dilutionPct}%`;
      try{
        await navigator.clipboard.writeText(text);
        showToast('已复制到剪贴板');
      }catch(e){
        showToast('复制失败(浏览器限制)');
      }
    }

    function exportJSON(){
      const blob = new Blob([JSON.stringify(state, null, 2)], {type:'application/json'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'abv-recipe.json';
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
      showToast('已导出 JSON');
    }

    // ---------- wire up ----------
    function init(){
      loadState();
      ensureDefaults();

      els.dilution.addEventListener('input', () => {
        state.dilutionPct = clampNum(els.dilution.value, 0, 60, 20);
        els.dilutionLabel.textContent = String(state.dilutionPct);
        recalc();
        saveSoon();
      });

      els.u_ml.addEventListener('change', () => {
        if(!els.u_ml.checked) return;
        // convert existing volumes to new unit for a stable display
        if(state.unit !== 'mL'){
          state.items.forEach(it => it.vol = fromML(toML(it.vol))); // current unit -> mL (through toML) then -> mL display
        }
        state.unit = 'mL';
        render();
        recalc();
        saveSoon();
      });

      els.u_oz.addEventListener('change', () => {
        if(!els.u_oz.checked) return;
        if(state.unit !== 'oz'){
          // convert current volumes into oz for display
          // current unit -> mL -> oz
          const beforeUnit = state.unit;
          state.items.forEach(it => {
            const ml = (beforeUnit === 'mL') ? it.vol : it.vol * OZ_TO_ML;
            it.vol = ml / OZ_TO_ML;
          });
        }
        state.unit = 'oz';
        render();
        recalc();
        saveSoon();
      });

      els.btnReset.addEventListener('click', resetAll);
      els.btnExample.addEventListener('click', loadExample);
      els.btnCopy.addEventListener('click', copyResult);
      els.btnExport.addEventListener('click', exportJSON);

      render();
      recalc();
    }

    init();
  </script>
</body>
</html>

        
编辑器加载中
预览
控制台