<!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,'&')
.replace(/</g,'<')
.replace(/>/g,'>')
.replace(/"/g,'"')
.replace(/'/g,''');
}
// ---------- 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>
index.html
index.html