特调咖啡设计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>Creative Coffee Designer | 特调咖啡饮品设计器</title>
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="default">
  <style>
    :root{
      --bg:#f5f2ec;
      --card:#ffffff;
      --ink:#1f2937;
      --muted:#6b7280;
      --line:#e5e7eb;
      --accent:#7a5c44;
      --accent-2:#b08a6a;
      --good:#0f766e;
      --warn:#b45309;
      --danger:#b91c1c;
      --shadow: 0 10px 30px rgba(17,24,39,.10);
      --radius: 18px;
      --radius-2: 24px;
      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft Yahei", "Apple Color Emoji","Segoe UI Emoji";
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      font-family: var(--sans);
      background: radial-gradient(1200px 700px at 20% -10%, rgba(176,138,106,.25), transparent 55%),
                  radial-gradient(900px 600px at 90% 0%, rgba(122,92,68,.18), transparent 50%),
                  var(--bg);
      color:var(--ink);
    }
    a{color:inherit}
    .wrap{max-width:1100px; margin:0 auto; padding:18px 14px 40px;}
    header{
      display:flex; align-items:flex-start; justify-content:space-between;
      gap:14px; padding:16px; border:1px solid var(--line);
      background: rgba(255,255,255,.7); backdrop-filter: blur(10px);
      border-radius: var(--radius-2); box-shadow: var(--shadow);
    }
    .brand{display:flex; flex-direction:column; gap:6px}
    .brand h1{margin:0; font-size:18px; letter-spacing:.2px}
    .brand p{margin:0; color:var(--muted); font-size:12.5px; line-height:1.35}
    .topActions{display:flex; flex-wrap:wrap; gap:8px; align-items:center; justify-content:flex-end}
    button, .btn{
      appearance:none; border:1px solid var(--line); background:#fff; color:var(--ink);
      padding:10px 12px; border-radius: 999px; cursor:pointer;
      box-shadow: 0 6px 18px rgba(17,24,39,.06);
      font-weight:650; font-size:13px;
      transition: transform .05s ease, border-color .2s ease, box-shadow .2s ease;
      user-select:none;
    }
    button:hover{border-color: rgba(122,92,68,.45)}
    button:active{transform: translateY(1px)}
    .btnPrimary{
      background: linear-gradient(180deg, rgba(122,92,68,1), rgba(122,92,68,.92));
      color:#fff; border-color: rgba(122,92,68,.2);
    }
    .btnGhost{background: rgba(255,255,255,.65)}
    .btnDanger{border-color: rgba(185,28,28,.35); color: var(--danger)}
    .pill{
      display:inline-flex; align-items:center; gap:8px;
      padding:8px 10px; border-radius:999px;
      background: rgba(255,255,255,.7);
      border:1px solid var(--line); color: var(--muted); font-size:12px;
    }
    main{margin-top:14px; display:grid; grid-template-columns: 1.1fr .9fr; gap:14px}
    @media (max-width: 980px){ main{grid-template-columns:1fr; } }

    .card{
      background: rgba(255,255,255,.75);
      border:1px solid var(--line);
      border-radius: var(--radius-2);
      box-shadow: var(--shadow);
      overflow:hidden;
    }
    .cardHead{
      padding:14px 14px 10px;
      display:flex; align-items:flex-start; justify-content:space-between; gap:10px;
      border-bottom:1px solid rgba(229,231,235,.7);
      background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(255,255,255,.65));
    }
    .cardTitle{display:flex; flex-direction:column; gap:6px}
    .cardTitle h2{margin:0; font-size:15px}
    .cardTitle p{margin:0; color:var(--muted); font-size:12px; line-height:1.35}
    .cardBody{padding:14px}
    .tabs{display:flex; gap:8px; flex-wrap:wrap}
    .tab{
      padding:8px 10px; border-radius: 999px;
      border:1px solid var(--line); background: rgba(255,255,255,.65);
      cursor:pointer; font-weight:700; font-size:12.5px; color: var(--muted);
    }
    .tab[aria-selected="true"]{
      color: var(--ink);
      border-color: rgba(122,92,68,.45);
      background: rgba(176,138,106,.16);
    }
    .grid2{display:grid; grid-template-columns: 1fr 1fr; gap:10px}
    @media (max-width: 520px){ .grid2{grid-template-columns:1fr} }
    label{display:block; font-size:12px; color: var(--muted); margin-bottom:6px}
    input[type="text"], input[type="number"], textarea, select{
      width:100%;
      padding:10px 11px;
      border-radius: 14px;
      border:1px solid var(--line);
      background: rgba(255,255,255,.9);
      outline:none;
      font-size:13.5px;
    }
    textarea{min-height:86px; resize: vertical; line-height:1.4}
    .row{display:flex; gap:10px; align-items:center; flex-wrap:wrap}
    .row > *{flex:1}
    .hint{font-size:12px; color:var(--muted); line-height:1.35}
    .sep{height:1px; background: rgba(229,231,235,.8); margin:12px 0}
    .mini{
      font-size:11.5px; color: var(--muted);
      font-family: var(--mono);
      background: rgba(255,255,255,.6);
      border:1px dashed rgba(229,231,235,.9);
      padding:10px; border-radius: 14px;
      overflow:auto;
      white-space: pre-wrap;
    }
    .chips{display:flex; flex-wrap:wrap; gap:8px}
    .chip{
      display:inline-flex; align-items:center; gap:8px;
      border:1px solid rgba(229,231,235,.9);
      padding:8px 10px; border-radius: 999px;
      background: rgba(255,255,255,.75);
      font-size:12.5px;
    }
    .chip small{color: var(--muted); font-weight:650}
    .chip .x{border:none; background:transparent; box-shadow:none; padding:0; cursor:pointer; color: var(--muted)}
    .badge{
      display:inline-flex; align-items:center; gap:6px;
      padding:6px 8px; border-radius: 999px;
      font-size:12px; font-weight:800;
      border:1px solid rgba(229,231,235,.9);
      background: rgba(255,255,255,.7);
    }
    .badge.good{color: var(--good); border-color: rgba(15,118,110,.25); background: rgba(15,118,110,.08)}
    .badge.warn{color: var(--warn); border-color: rgba(180,83,9,.25); background: rgba(180,83,9,.08)}
    .badge.danger{color: var(--danger); border-color: rgba(185,28,28,.25); background: rgba(185,28,28,.08)}
    .list{display:flex; flex-direction:column; gap:10px}
    .item{
      border:1px solid rgba(229,231,235,.9);
      background: rgba(255,255,255,.75);
      border-radius: 16px;
      padding:10px;
      display:grid;
      grid-template-columns: 1fr 140px;
      gap:10px;
      align-items:center;
    }
    @media (max-width:560px){
      .item{grid-template-columns:1fr}
    }
    .item h3{margin:0; font-size:13.5px}
    .item p{margin:6px 0 0; color: var(--muted); font-size:12px; line-height:1.35}
    .itemActions{display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap}
    .kbd{
      font-family: var(--mono);
      font-size:11px;
      padding:2px 6px;
      border-radius: 999px;
      border:1px solid rgba(229,231,235,.9);
      background: rgba(255,255,255,.85);
      color: var(--muted);
      margin-left:6px;
    }
    .meter{
      display:flex; gap:8px; align-items:center; flex-wrap:wrap;
      padding:10px; border-radius: 16px;
      border:1px solid rgba(229,231,235,.9);
      background: rgba(255,255,255,.65);
    }
    .meter b{font-size:12.5px}
    input[type="range"]{width:100%}
    .toast{
      position:fixed; left:50%; bottom:18px; transform:translateX(-50%);
      background: rgba(31,41,55,.92);
      color:#fff;
      padding:10px 12px;
      border-radius: 999px;
      box-shadow: 0 12px 30px rgba(0,0,0,.25);
      font-size:12.5px;
      opacity:0; pointer-events:none;
      transition: opacity .18s ease, transform .18s ease;
    }
    .toast.show{opacity:1; transform:translateX(-50%) translateY(-2px)}
    details{
      border:1px solid rgba(229,231,235,.9);
      background: rgba(255,255,255,.6);
      border-radius: 16px;
      padding:10px;
    }
    details summary{cursor:pointer; font-weight:800; color:var(--ink)}
    .muted{color:var(--muted)}
    .rightColSticky{
      position: sticky;
      top: 12px;
      align-self: start;
    }
    @media (max-width: 980px){ .rightColSticky{position: static} }
  </style>
</head>
<body>
  <div class="wrap">
    <header>
      <div class="brand">
        <h1>特调咖啡饮品设计器 <span class="kbd">本机离线</span></h1>
        <p>用「五要素」把创意落到配方:主体元素 / 风味强化 / 质地 / 装饰 / 概念。支持保存、复制、导出 CSV/JSON、导入。</p>
      </div>
      <div class="topActions">
        <span class="pill" id="autosavePill">已启用自动保存</span>
        <button class="btnGhost" id="btnNew">新建</button>
        <button class="btnGhost" id="btnRandom">来点灵感</button>
        <button class="btnPrimary" id="btnSave">保存到配方库</button>
      </div>
    </header>

    <main>
      <!-- Left: Editor -->
      <section class="card">
        <div class="cardHead">
          <div class="cardTitle">
            <h2>配方编辑</h2>
            <p>选择结构 → 添加原料 → 调整平衡 → 写下做法与故事。右侧会实时生成「配方卡」。</p>
          </div>
          <div class="tabs" role="tablist" aria-label="编辑区标签">
            <button class="tab" role="tab" aria-selected="true" data-tab="core">结构</button>
            <button class="tab" role="tab" aria-selected="false" data-tab="ingredients">原料</button>
            <button class="tab" role="tab" aria-selected="false" data-tab="method">做法</button>
            <button class="tab" role="tab" aria-selected="false" data-tab="concept">概念</button>
          </div>
        </div>

        <div class="cardBody" id="tab_core">
          <div class="grid2">
            <div>
              <label>饮品名称</label>
              <input id="name" type="text" placeholder="例如:Dream Bath / 梦境泡浴" />
              <div class="hint" style="margin-top:6px">
                也可以点 <b>来点灵感</b> 自动起名 + 组合方向。
              </div>
            </div>
            <div>
              <label>类型</label>
              <select id="type">
                <option value="cold">冷饮</option>
                <option value="hot">热饮</option>
                <option value="nitro">氮气/气泡</option>
                <option value="dessert">甜品向</option>
                <option value="lowabv">低酒精/无酒精</option>
              </select>
            </div>
          </div>

          <div class="sep"></div>

          <div class="grid2">
            <div>
              <label>主体(咖啡基底)</label>
              <select id="coffeeBase"></select>
            </div>
            <div>
              <label>主体(酒/无酒精基底,可选)</label>
              <select id="spiritBase"></select>
            </div>
          </div>

          <div style="margin-top:10px" class="grid2">
            <div>
              <label>杯型 / 容器</label>
              <select id="glass">
                <option value="rocks">Rocks / Old Fashioned</option>
                <option value="coupe">Coupe / 香槟碟</option>
                <option value="martini">Martini / V形杯</option>
                <option value="highball">Highball / 长杯</option>
                <option value="mug">Mug / 马克杯</option>
                <option value="tulip">Tulip / 郁金香杯</option>
                <option value="stemless">Stemless / 无脚杯</option>
                <option value="bottle">Bottle / 瓶装</option>
              </select>
            </div>
            <div>
              <label>目标氛围(用于灵感推荐)</label>
              <select id="mood">
                <option value="cozy">温暖治愈</option>
                <option value="dreamy">梦境奇幻</option>
                <option value="clean">清爽干净</option>
                <option value="luxury">高端精致</option>
                <option value="playful">俏皮有趣</option>
                <option value="dark">暗黑深邃</option>
              </select>
            </div>
          </div>

          <div class="sep"></div>

          <div class="meter">
            <div style="flex:1; min-width:220px">
              <b>平衡预期</b>
              <div class="hint">不是绝对数值,更多是「设计意图」。右侧会提示风险点。</div>
            </div>
            <div style="flex:1; min-width:220px">
              <label>甜度 <span class="muted" id="sweetVal">50</span></label>
              <input id="sweet" type="range" min="0" max="100" value="50" />
            </div>
            <div style="flex:1; min-width:220px">
              <label>酸度 <span class="muted" id="acidVal">35</span></label>
              <input id="acid" type="range" min="0" max="100" value="35" />
            </div>
            <div style="flex:1; min-width:220px">
              <label>苦度 <span class="muted" id="bitterVal">45</span></label>
              <input id="bitter" type="range" min="0" max="100" value="45" />
            </div>
            <div style="flex:1; min-width:220px">
              <label>酒精感 <span class="muted" id="boozyVal">25</span></label>
              <input id="boozy" type="range" min="0" max="100" value="25" />
            </div>
            <div style="flex:1; min-width:220px">
              <label>醇厚度 <span class="muted" id="bodyVal">55</span></label>
              <input id="body" type="range" min="0" max="100" value="55" />
            </div>
          </div>

          <div style="margin-top:12px" class="row">
            <button class="btnGhost" id="btnAutoName">自动起名</button>
            <button class="btnGhost" id="btnAutoSuggest">给我 6 个「可能的风味强化」</button>
          </div>

          <div class="hint" style="margin-top:10px">
            提示:你可以把这页当作“导演分镜”——先定类型与平衡,再进入原料堆叠。
          </div>
        </div>

        <div class="cardBody" id="tab_ingredients" style="display:none">
          <div class="grid2">
            <div>
              <label>原料库(按类别)</label>
              <select id="libCategory"></select>
            </div>
            <div>
              <label>搜索原料</label>
              <input id="libSearch" type="text" placeholder="例如:佛手柑 / 榛子 / 岩兰草 / 椰子…" />
            </div>
          </div>

          <div style="margin-top:10px" class="row">
            <div>
              <label>选择一个原料</label>
              <select id="libItem"></select>
            </div>
            <div style="max-width:160px">
              <label>用量</label>
              <input id="amount" type="number" step="0.1" min="0" placeholder="例如 15" />
            </div>
            <div style="max-width:160px">
              <label>单位</label>
              <select id="unit">
                <option value="ml">ml</option>
                <option value="g">g</option>
                <option value="dash">dash</option>
                <option value="drop">drop</option>
                <option value="piece">piece</option>
                <option value="spray">spray</option>
              </select>
            </div>
            <div style="max-width:200px">
              <label>五要素归类</label>
              <select id="role">
                <option value="main">主体元素</option>
                <option value="boost">风味强化</option>
                <option value="texture">质地</option>
                <option value="garnish">装饰</option>
              </select>
            </div>
          </div>

          <div style="margin-top:10px" class="row">
            <button class="btnPrimary" id="btnAddIng">添加到配方</button>
            <button class="btnGhost" id="btnQuickAddEsp">+ 浓缩 30ml</button>
            <button class="btnGhost" id="btnQuickAddIce">+ 冰 120g</button>
            <button class="btnGhost" id="btnClearIngs">清空原料</button>
          </div>

          <div class="sep"></div>

          <div class="row" style="align-items:flex-start">
            <div style="flex:1.2">
              <h3 style="margin:0 0 8px">当前配方原料</h3>
              <div class="hint" style="margin:0 0 10px">点击条目可编辑;支持上移/下移;自动估算总量与 ABV。</div>
              <div class="list" id="ingList"></div>
            </div>
            <div style="flex:.8; min-width:260px">
              <details open>
                <summary>智能提示(实时)</summary>
                <div style="margin-top:10px" id="smartHints" class="hint"></div>
              </details>

              <div style="margin-top:10px">
                <details>
                  <summary>灵感推荐:与当前风味更“搭”的原料</summary>
                  <div style="margin-top:10px" id="recommendations" class="chips"></div>
                </details>
              </div>

              <div style="margin-top:10px">
                <details>
                  <summary>可选:ABV 估算说明</summary>
                  <div class="hint" style="margin-top:10px">
                    ABV 估算基于:<b>含酒精原料的体积(ml) × 酒精度</b> / <b>总液体估算体积</b>。<br/>
                    若你用 g / dash / spray,系统会做保守换算(并在提示里标注“估算”)。
                  </div>
                </details>
              </div>
            </div>
          </div>
        </div>

        <div class="cardBody" id="tab_method" style="display:none">
          <div class="grid2">
            <div>
              <label>制作方式(流程)</label>
              <select id="technique">
                <option value="shake">Shake / 摇和</option>
                <option value="stir">Stir / 搅拌</option>
                <option value="build">Build / 直调</option>
                <option value="blend">Blend / 搅打</option>
                <option value="hotbuild">Hot Build / 热调</option>
                <option value="clarify">Clarify / 澄清(奶洗/果胶酶等)</option>
              </select>
            </div>
            <div>
              <label>关键控制点</label>
              <input id="controlPoints" type="text" placeholder="例如:稀释 18% / 出杯温度 62°C / 先酒后咖…" />
            </div>
          </div>

          <div style="margin-top:10px">
            <label>步骤(建议写成可复现 SOP)</label>
            <textarea id="steps" placeholder="1) 预冷杯…&#10;2) 加入…&#10;3) …"></textarea>
          </div>

          <div style="margin-top:10px" class="grid2">
            <div>
              <label>装饰与呈现(视觉/触感)</label>
              <textarea id="presentation" placeholder="例如:南瓜薄片 + 白砂糖火焰 / 喷雾 / 热蜡封口 / 湿润边…"></textarea>
            </div>
            <div>
              <label>闻香入口节奏(体验脚本)</label>
              <textarea id="experience" placeholder="例如:先闻喷雾→入口甜→中段咖啡→尾段木质…"></textarea>
            </div>
          </div>
        </div>

        <div class="cardBody" id="tab_concept" style="display:none">
          <div class="grid2">
            <div>
              <label>概念关键词(用逗号分隔)</label>
              <input id="keywords" type="text" placeholder="例如:梦 / 浴室蒸汽 / 夜礼服 / 低语 / 冬天…" />
            </div>
            <div>
              <label>目标香气家族(用于推荐)</label>
              <select id="aromaFamily" multiple size="6" style="height:auto; padding:10px;">
                <option value="citrus">柑橘</option>
                <option value="floral">花香</option>
                <option value="fruity">果香</option>
                <option value="nutty">坚果</option>
                <option value="chocolate">巧克力/可可</option>
                <option value="caramel">焦糖/烘焙</option>
                <option value="spice">香料</option>
                <option value="herbal">草本</option>
                <option value="woody">木质</option>
                <option value="smoky">烟熏</option>
                <option value="savory">咸鲜/旨味</option>
                <option value="dairy">乳脂/奶香</option>
              </select>
              <div class="hint" style="margin-top:6px">手机上:可用长按/多选(不同机型表现不同)。</div>
            </div>
          </div>

          <div style="margin-top:10px">
            <label>故事/一句话 pitch(给评委/客人)</label>
            <textarea id="story" placeholder="例如:这杯像把城市的夜拧成一团蒸汽…"></textarea>
          </div>

          <div style="margin-top:10px" class="row">
            <button class="btnGhost" id="btnIdea1">生成一句 Pitch</button>
            <button class="btnGhost" id="btnIdea2">生成「命名 + 剧场」</button>
            <button class="btnGhost" id="btnIdea3">检查五要素完整度</button>
          </div>

          <div style="margin-top:12px" class="mini" id="conceptOutput">这里会出现你的一句话 pitch / 命名建议 / 五要素检查结果。</div>
        </div>
      </section>

      <!-- Right: Preview & Library -->
      <section class="card rightColSticky">
        <div class="cardHead">
          <div class="cardTitle">
            <h2>配方卡(可复制/导出)</h2>
            <p>实时生成。建议你在出品前,把这里当作“最终对外版本”。</p>
          </div>
          <div class="tabs" role="tablist" aria-label="右侧标签">
            <button class="tab" role="tab" aria-selected="true" data-rtab="preview">预览</button>
            <button class="tab" role="tab" aria-selected="false" data-rtab="library">配方库</button>
            <button class="tab" role="tab" aria-selected="false" data-rtab="io">导入/导出</button>
          </div>
        </div>

        <div class="cardBody" id="rtab_preview">
          <div class="row" style="gap:8px; margin-bottom:10px">
            <button class="btnGhost" id="btnCopy">复制配方文本</button>
            <button class="btnGhost" id="btnExportCSV">导出 CSV</button>
            <button class="btnGhost" id="btnExportJSON">导出 JSON</button>
          </div>

          <div class="row" style="gap:8px; margin-bottom:10px">
            <span class="badge" id="badgeABV">ABV:—</span>
            <span class="badge" id="badgeVol">总量:—</span>
            <span class="badge" id="badgeRisk">提示:—</span>
          </div>

          <div class="mini" id="preview"></div>
        </div>

        <div class="cardBody" id="rtab_library" style="display:none">
          <div class="row" style="gap:8px">
            <input id="libFilter" type="text" placeholder="搜索已保存配方(名称/关键词)" />
            <button class="btnGhost" id="btnExportAll">导出全部 JSON</button>
          </div>
          <div class="sep"></div>
          <div class="list" id="savedList"></div>
          <div class="hint" style="margin-top:10px">
            所有内容只保存在本机浏览器(localStorage)。换手机/清缓存会丢失,建议定期导出 JSON 备份。
          </div>
        </div>

        <div class="cardBody" id="rtab_io" style="display:none">
          <div>
            <label>导入 JSON(粘贴后导入)</label>
            <textarea id="importBox" placeholder='粘贴导出的 JSON(单条或数组)'></textarea>
            <div class="row" style="margin-top:10px">
              <button class="btnPrimary" id="btnImport">导入到配方库</button>
              <button class="btnGhost" id="btnCopyJSON">复制当前配方 JSON</button>
            </div>
          </div>
          <div class="sep"></div>
          <details>
            <summary>分享给别人:最简单的本地方式</summary>
            <div class="hint" style="margin-top:10px">
              1) 把 <b>index.html</b> 发给对方(微信/网盘/邮件均可)。<br/>
              2) 对方用手机浏览器打开即可使用。<br/>
              3) 若想“像 App 一样”添加到桌面:iOS Safari 里点分享 → 添加到主屏幕;安卓 Chrome 里点菜单 → 添加到主屏幕。
            </div>
          </details>
        </div>
      </section>
    </main>

    <div class="toast" id="toast">已复制</div>
  </div>

<script>
(function(){
  const LS_KEY_DRAFT = "coffee_designer_draft_v1";
  const LS_KEY_SAVED = "coffee_designer_saved_v1";

  // ---- Ingredient Library (轻量内置,可自行扩展) ----
  // tags: aroma family + vibe hints
  const LIB = [
    // Coffee
    {name:"浓缩 Espresso", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["caramel","chocolate","roasty"], defaultAmt:30},
    {name:"美式/热水 Hot Water", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:60},
    {name:"冷萃 Cold Brew", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean","fruity"], defaultAmt:60},
    {name:"氮气冷萃 Nitro Cold Brew", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["clean","body"], defaultAmt:120},
    {name:"浓缩浓缩液 Ristretto", cat:"咖啡基底", abv:0, unitHint:"ml", tags:["caramel","chocolate"], defaultAmt:22},

    // Spirits
    {name:"威士忌 Whiskey", cat:"酒基底", abv:40, unitHint:"ml", tags:["woody","caramel","smoky"], defaultAmt:30},
    {name:"朗姆 Rum", cat:"酒基底", abv:40, unitHint:"ml", tags:["caramel","spice"], defaultAmt:30},
    {name:"白朗姆 White Rum", cat:"酒基底", abv:40, unitHint:"ml", tags:["clean","citrus"], defaultAmt:30},
    {name:"金酒 Gin", cat:"酒基底", abv:40, unitHint:"ml", tags:["herbal","citrus"], defaultAmt:25},
    {name:"白兰地 Brandy/Cognac", cat:"酒基底", abv:40, unitHint:"ml", tags:["fruity","woody"], defaultAmt:25},
    {name:"利口酒 Coffee Liqueur", cat:"酒基底", abv:20, unitHint:"ml", tags:["chocolate","caramel"], defaultAmt:20},
    {name:"阿玛雷托 Amaretto", cat:"酒基底", abv:28, unitHint:"ml", tags:["nutty","caramel"], defaultAmt:20},
    {name:"君度/橙味利口酒 Orange Liqueur", cat:"酒基底", abv:40, unitHint:"ml", tags:["citrus"], defaultAmt:15},
    {name:"苦艾/茴香烈酒 Absinthe/Pastis", cat:"酒基底", abv:45, unitHint:"drop", tags:["herbal","spice"], defaultAmt:0.6},

    // Sweeteners
    {name:"糖浆 Simple Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:10},
    {name:"红糖糖浆 Demerara Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["caramel","roasty"], defaultAmt:10},
    {name:"蜂蜜 Honey", cat:"甜味", abv:0, unitHint:"g", tags:["floral"], defaultAmt:8},
    {name:"枫糖 Maple", cat:"甜味", abv:0, unitHint:"ml", tags:["caramel","woody"], defaultAmt:10},
    {name:"香草糖浆 Vanilla Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["dairy","caramel","floral"], defaultAmt:8},
    {name:"可可糖浆 Cacao Syrup", cat:"甜味", abv:0, unitHint:"ml", tags:["chocolate"], defaultAmt:10},

    // Acids / Citrus
    {name:"柠檬汁 Lemon", cat:"酸度", abv:0, unitHint:"ml", tags:["citrus","clean"], defaultAmt:10},
    {name:"青柠汁 Lime", cat:"酸度", abv:0, unitHint:"ml", tags:["citrus","clean"], defaultAmt:10},
    {name:"佛手柑(皮油/喷雾)Bergamot", cat:"酸度", abv:0, unitHint:"spray", tags:["citrus","floral","luxury"], defaultAmt:2},
    {name:"苹果酸 Malic", cat:"酸度", abv:0, unitHint:"g", tags:["fruity","clean"], defaultAmt:0.6},
    {name:"柠檬酸 Citric", cat:"酸度", abv:0, unitHint:"g", tags:["citrus","clean"], defaultAmt:0.5},
    {name:"乳酸 Lactic", cat:"酸度", abv:0, unitHint:"g", tags:["dairy","smooth"], defaultAmt:0.4},

    // Aromatics / Botanicals
    {name:"玫瑰 Rose", cat:"香气", abv:0, unitHint:"spray", tags:["floral","dreamy","luxury"], defaultAmt:2},
    {name:"茉莉 Jasmine", cat:"香气", abv:0, unitHint:"spray", tags:["floral","clean"], defaultAmt:2},
    {name:"桂花 Osmanthus", cat:"香气", abv:0, unitHint:"ml", tags:["floral","fruity"], defaultAmt:6},
    {name:"迷迭香 Rosemary", cat:"香气", abv:0, unitHint:"piece", tags:["herbal","woody"], defaultAmt:1},
    {name:"百里香 Thyme", cat:"香气", abv:0, unitHint:"piece", tags:["herbal"], defaultAmt:1},
    {name:"岩兰草 Vetiver", cat:"香气", abv:0, unitHint:"drop", tags:["woody","smoky","dark"], defaultAmt:2},
    {name:"柚子 Yuzu", cat:"香气", abv:0, unitHint:"ml", tags:["citrus","fruity","clean"], defaultAmt:8},
    {name:"乌龙 Oolong", cat:"香气", abv:0, unitHint:"ml", tags:["floral","woody"], defaultAmt:20},

    // Texture / Dairy
    {name:"牛奶 Milk", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","body"], defaultAmt:60},
    {name:"淡奶油 Cream", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","luxury","body"], defaultAmt:30},
    {name:"燕麦奶 Oat", cat:"质地", abv:0, unitHint:"ml", tags:["dairy","body","cozy"], defaultAmt:60},
    {name:"椰奶 Coconut", cat:"质地", abv:0, unitHint:"ml", tags:["nutty","dairy","tropical"], defaultAmt:50},
    {name:"黄油澄清 Butter (clarified)", cat:"质地", abv:0, unitHint:"g", tags:["dairy","caramel","luxury"], defaultAmt:6},
    {name:"蛋白 Egg White", cat:"质地", abv:0, unitHint:"ml", tags:["body","foam"], defaultAmt:25},
    {name:"海盐 Salt", cat:"质地", abv:0, unitHint:"pinch", tags:["savory"], defaultAmt:1},

    // Spices / Bitters
    {name:"安哥仕苦精 Angostura Bitters", cat:"香料/苦味", abv:44.7, unitHint:"dash", tags:["spice","woody"], defaultAmt:2},
    {name:"橙味苦精 Orange Bitters", cat:"香料/苦味", abv:28, unitHint:"dash", tags:["citrus","spice"], defaultAmt:2},
    {name:"可可苦精 Cacao Bitters", cat:"香料/苦味", abv:35, unitHint:"dash", tags:["chocolate","spice"], defaultAmt:2},
    {name:"豆蔻 Cardamom", cat:"香料/苦味", abv:0, unitHint:"drop", tags:["spice","floral"], defaultAmt:2},
    {name:"黑胡椒 Black Pepper", cat:"香料/苦味", abv:0, unitHint:"pinch", tags:["spice","savory"], defaultAmt:1},

    // Garnish
    {name:"柑橘皮 Orange Peel", cat:"装饰", abv:0, unitHint:"piece", tags:["citrus"], defaultAmt:1},
    {name:"南瓜薄片 Pumpkin Slice", cat:"装饰", abv:0, unitHint:"piece", tags:["cozy","dessert"], defaultAmt:1},
    {name:"可可粉 Cocoa Dust", cat:"装饰", abv:0, unitHint:"pinch", tags:["chocolate"], defaultAmt:1},
    {name:"白砂糖火焰 Sugar Flame", cat:"装饰", abv:0, unitHint:"piece", tags:["dramatic","luxury"], defaultAmt:1},
    {name:"喷雾(香水式)Aroma Spray", cat:"装饰", abv:0, unitHint:"spray", tags:["luxury","dreamy"], defaultAmt:2},

    // Others / Dilution
    {name:"冰 Ice", cat:"稀释/温控", abv:0, unitHint:"g", tags:["cold"], defaultAmt:120},
    {name:"苏打水 Soda", cat:"稀释/温控", abv:0, unitHint:"ml", tags:["clean"], defaultAmt:60},
    {name:"热水 Hot Water", cat:"稀释/温控", abv:0, unitHint:"ml", tags:["hot"], defaultAmt:60},
  ];

  const COFFEE_BASES = [
    "浓缩 Espresso","Ristretto","冷萃 Cold Brew","氮气冷萃 Nitro Cold Brew","美式/热水 Hot Water"
  ];
  const SPIRIT_BASES = [
    "无","威士忌 Whiskey","朗姆 Rum","白朗姆 White Rum","金酒 Gin","白兰地 Brandy/Cognac","利口酒 Coffee Liqueur","阿玛雷托 Amaretto"
  ];

  // ---- State ----
  const defaultDraft = () => ({
    id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()),
    name: "",
    type: "cold",
    coffeeBase: "浓缩 Espresso",
    spiritBase: "无",
    glass: "rocks",
    mood: "dreamy",
    technique: "build",
    controlPoints: "",
    sweet: 50, acid: 35, bitter: 45, boozy: 25, body: 55,
    keywords: "",
    aromaFamily: [],
    story: "",
    steps: "",
    presentation: "",
    experience: "",
    ingredients: [
      // Start empty; user can quick-add espresso/ice
    ],
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });

  let draft = loadDraft() || defaultDraft();
  let saved = loadSaved();

  // ---- DOM ----
  const $ = (id) => document.getElementById(id);

  const el = {
    // tabs
    tabs: document.querySelectorAll('.tab[role="tab"][data-tab]'),
    tabBodies: {
      core: $('tab_core'),
      ingredients: $('tab_ingredients'),
      method: $('tab_method'),
      concept: $('tab_concept')
    },
    rTabs: document.querySelectorAll('.tab[role="tab"][data-rtab]'),
    rBodies: {
      preview: $('rtab_preview'),
      library: $('rtab_library'),
      io: $('rtab_io')
    },

    // inputs
    name: $('name'),
    type: $('type'),
    coffeeBase: $('coffeeBase'),
    spiritBase: $('spiritBase'),
    glass: $('glass'),
    mood: $('mood'),

    sweet: $('sweet'), acid: $('acid'), bitter: $('bitter'), boozy: $('boozy'), body: $('body'),
    sweetVal: $('sweetVal'), acidVal: $('acidVal'), bitterVal: $('bitterVal'), boozyVal: $('boozyVal'), bodyVal: $('bodyVal'),

    // ingredients lib
    libCategory: $('libCategory'),
    libSearch: $('libSearch'),
    libItem: $('libItem'),
    amount: $('amount'),
    unit: $('unit'),
    role: $('role'),

    // method
    technique: $('technique'),
    controlPoints: $('controlPoints'),
    steps: $('steps'),
    presentation: $('presentation'),
    experience: $('experience'),

    // concept
    keywords: $('keywords'),
    aromaFamily: $('aromaFamily'),
    story: $('story'),
    conceptOutput: $('conceptOutput'),

    // list & preview
    ingList: $('ingList'),
    smartHints: $('smartHints'),
    recommendations: $('recommendations'),
    preview: $('preview'),

    badgeABV: $('badgeABV'),
    badgeVol: $('badgeVol'),
    badgeRisk: $('badgeRisk'),

    // library
    libFilter: $('libFilter'),
    savedList: $('savedList'),

    // IO
    importBox: $('importBox'),

    // buttons
    btnNew: $('btnNew'),
    btnRandom: $('btnRandom'),
    btnSave: $('btnSave'),
    btnAutoName: $('btnAutoName'),
    btnAutoSuggest: $('btnAutoSuggest'),
    btnAddIng: $('btnAddIng'),
    btnQuickAddEsp: $('btnQuickAddEsp'),
    btnQuickAddIce: $('btnQuickAddIce'),
    btnClearIngs: $('btnClearIngs'),
    btnCopy: $('btnCopy'),
    btnExportCSV: $('btnExportCSV'),
    btnExportJSON: $('btnExportJSON'),
    btnExportAll: $('btnExportAll'),
    btnImport: $('btnImport'),
    btnCopyJSON: $('btnCopyJSON'),
    btnIdea1: $('btnIdea1'),
    btnIdea2: $('btnIdea2'),
    btnIdea3: $('btnIdea3'),

    toast: $('toast')
  };

  // ---- Init selects ----
  function initSelects(){
    // coffeeBase select
    el.coffeeBase.innerHTML = COFFEE_BASES.map(v=>`<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
    el.spiritBase.innerHTML = SPIRIT_BASES.map(v=>`<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');

    // ingredient library categories
    const cats = Array.from(new Set(LIB.map(x=>x.cat))).sort((a,b)=>a.localeCompare(b,'zh-CN'));
    el.libCategory.innerHTML = ['全部类别', ...cats].map(c=>`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
    el.libCategory.value = "全部类别";

    refreshLibItems();
  }

  function refreshLibItems(){
    const cat = el.libCategory.value;
    const q = (el.libSearch.value || "").trim().toLowerCase();
    const list = LIB.filter(x=>{
      const inCat = (cat==="全部类别") ? true : x.cat===cat;
      const inQ = !q ? true : (x.name.toLowerCase().includes(q) || x.cat.toLowerCase().includes(q) || (x.tags||[]).join(',').toLowerCase().includes(q));
      return inCat && inQ;
    });

    el.libItem.innerHTML = list.map((x,i)=>{
      const hint = x.tags?.length ? ` · ${x.tags.slice(0,3).join('/')}` : '';
      return `<option value="${escapeHtml(x.name)}">${escapeHtml(x.name)}${escapeHtml(hint)}</option>`;
    }).join('');

    // auto fill amount/unit from library item
    const picked = list[0];
    if(picked){
      el.amount.value = picked.defaultAmt ?? "";
      el.unit.value = normalizeUnit(picked.unitHint || "ml");
    }
  }

  function normalizeUnit(u){
    const allowed = new Set(["ml","g","dash","drop","piece","spray"]);
    return allowed.has(u) ? u : "ml";
  }

  // ---- Tabs ----
  function setTab(key){
    el.tabs.forEach(t=>{
      const on = t.dataset.tab===key;
      t.setAttribute('aria-selected', on ? 'true':'false');
    });
    Object.entries(el.tabBodies).forEach(([k,node])=>{
      node.style.display = (k===key) ? 'block':'none';
    });
  }
  function setRTab(key){
    el.rTabs.forEach(t=>{
      const on = t.dataset.rtab===key;
      t.setAttribute('aria-selected', on ? 'true':'false');
    });
    Object.entries(el.rBodies).forEach(([k,node])=>{
      node.style.display = (k===key) ? 'block':'none';
    });
  }

  // ---- Bind inputs to state ----
  function bind(){
    // left tabs
    el.tabs.forEach(t=>t.addEventListener('click', ()=>setTab(t.dataset.tab)));
    el.rTabs.forEach(t=>t.addEventListener('click', ()=>setRTab(t.dataset.rtab)));

    // fields -> draft
    const map = [
      ['name','name'], ['type','type'], ['glass','glass'], ['mood','mood'],
      ['technique','technique'], ['controlPoints','controlPoints'],
      ['steps','steps'], ['presentation','presentation'], ['experience','experience'],
      ['keywords','keywords'], ['story','story']
    ];
    map.forEach(([domKey, stateKey])=>{
      el[domKey].addEventListener('input', ()=>{
        draft[stateKey] = el[domKey].value;
        touch();
      });
    });

    el.coffeeBase.addEventListener('change', ()=>{ draft.coffeeBase = el.coffeeBase.value; touch(); });
    el.spiritBase.addEventListener('change', ()=>{ draft.spiritBase = el.spiritBase.value; touch(); });

    // sliders
    const sliders = [
      ['sweet','sweet','sweetVal'],
      ['acid','acid','acidVal'],
      ['bitter','bitter','bitterVal'],
      ['boozy','boozy','boozyVal'],
      ['body','body','bodyVal']
    ];
    sliders.forEach(([id, key, lab])=>{
      el[id].addEventListener('input', ()=>{
        draft[key] = +el[id].value;
        el[lab].textContent = String(draft[key]);
        touch();
      });
    });

    // aroma multi-select
    el.aromaFamily.addEventListener('change', ()=>{
      draft.aromaFamily = Array.from(el.aromaFamily.selectedOptions).map(o=>o.value);
      touch();
    });

    // library filters
    el.libCategory.addEventListener('change', refreshLibItems);
    el.libSearch.addEventListener('input', refreshLibItems);
    el.libItem.addEventListener('change', ()=>{
      const item = LIB.find(x=>x.name===el.libItem.value);
      if(item){
        el.amount.value = item.defaultAmt ?? "";
        el.unit.value = normalizeUnit(item.unitHint || "ml");
      }
    });

    // buttons
    el.btnNew.addEventListener('click', ()=>{
      draft = defaultDraft();
      applyDraftToUI();
      toast("新建完成");
    });

    el.btnRandom.addEventListener('click', ()=>{
      applyRandomIdea();
      toast("已生成灵感组合");
    });

    el.btnSave.addEventListener('click', saveToLibrary);

    el.btnAutoName.addEventListener('click', ()=>{
      if(!draft.name.trim()){
        draft.name = autoName();
      }else{
        // append alt name suggestion
        draft.name = draft.name.trim() + " / " + autoName();
      }
      el.name.value = draft.name;
      touch();
      toast("已生成名称");
    });

    el.btnAutoSuggest.addEventListener('click', ()=>{
      const picks = suggestBoosters(6);
      setTab('ingredients');
      // show recommended chips in the recommendation panel
      renderRecommendations(picks, true);
      toast("已生成强化建议");
    });

    el.btnAddIng.addEventListener('click', ()=>{
      const itemName = el.libItem.value;
      if(!itemName){ toast("请先选择原料"); return; }
      const amt = parseFloat(el.amount.value);
      const unit = el.unit.value;
      const role = el.role.value;

      if(!(amt>0) && !["dash","drop","piece","spray"].includes(unit)){
        toast("请输入用量");
        return;
      }
      addIngredient({
        name: itemName,
        amount: isFinite(amt) ? amt : null,
        unit,
        role
      });
      toast("已添加原料");
    });

    el.btnQuickAddEsp.addEventListener('click', ()=>{
      addIngredient({name:"浓缩 Espresso", amount:30, unit:"ml", role:"main"});
      toast("已添加:浓缩 30ml");
    });

    el.btnQuickAddIce.addEventListener('click', ()=>{
      addIngredient({name:"冰 Ice", amount:120, unit:"g", role:"texture"});
      toast("已添加:冰 120g");
    });

    el.btnClearIngs.addEventListener('click', ()=>{
      draft.ingredients = [];
      touch();
      toast("已清空原料");
    });

    el.btnCopy.addEventListener('click', async ()=>{
      const text = buildRecipeText(draft);
      await copyToClipboard(text);
      toast("已复制配方文本");
    });

    el.btnExportCSV.addEventListener('click', ()=>exportCSV(draft));
    el.btnExportJSON.addEventListener('click', ()=>exportJSON(draft));

    el.btnExportAll.addEventListener('click', ()=>{
      exportAllJSON(saved);
    });

    el.btnImport.addEventListener('click', ()=>{
      const raw = el.importBox.value.trim();
      if(!raw){ toast("请粘贴 JSON"); return; }
      try{
        const data = JSON.parse(raw);
        const arr = Array.isArray(data) ? data : [data];
        const normalized = arr
          .filter(Boolean)
          .map(x=>normalizeRecipe(x))
          .filter(x=>x && x.id && x.name!=null);

        if(!normalized.length){ toast("没有可导入的配方"); return; }

        // merge by id (upsert)
        const map = new Map(saved.map(r=>[r.id,r]));
        normalized.forEach(r=>map.set(r.id, r));
        saved = Array.from(map.values()).sort((a,b)=>(b.updatedAt||"").localeCompare(a.updatedAt||""));
        saveSaved(saved);
        renderSavedList();
        toast("导入完成");
        setRTab('library');
      }catch(e){
        toast("JSON 解析失败");
      }
    });

    el.btnCopyJSON.addEventListener('click', async ()=>{
      const json = JSON.stringify(draft, null, 2);
      await copyToClipboard(json);
      toast("已复制当前配方 JSON");
    });

    el.libFilter.addEventListener('input', renderSavedList);

    el.btnIdea1.addEventListener('click', ()=>{
      const out = genPitch();
      el.conceptOutput.textContent = out;
      toast("已生成 Pitch");
    });
    el.btnIdea2.addEventListener('click', ()=>{
      const out = genStageName();
      el.conceptOutput.textContent = out;
      toast("已生成命名/剧场");
    });
    el.btnIdea3.addEventListener('click', ()=>{
      const out = checkFiveElements();
      el.conceptOutput.textContent = out;
      toast("已检查五要素");
    });

    // keyboard shortcut (desktop)
    window.addEventListener('keydown', (e)=>{
      if((e.ctrlKey || e.metaKey) && e.key.toLowerCase()==='s'){
        e.preventDefault();
        saveToLibrary();
      }
    });
  }

  // ---- Ingredient operations ----
  function addIngredient(ing){
    const libItem = LIB.find(x=>x.name===ing.name);
    const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()+Math.random());
    draft.ingredients.push({
      id,
      name: ing.name,
      amount: (ing.amount==null ? (libItem?.defaultAmt ?? null) : ing.amount),
      unit: ing.unit || normalizeUnit(libItem?.unitHint || "ml"),
      role: ing.role || "boost",
      abv: libItem?.abv ?? 0,
      tags: libItem?.tags ?? []
    });
    touch();
  }

  function updateIngredient(id, patch){
    const i = draft.ingredients.findIndex(x=>x.id===id);
    if(i<0) return;
    draft.ingredients[i] = {...draft.ingredients[i], ...patch};
    touch();
  }

  function removeIngredient(id){
    draft.ingredients = draft.ingredients.filter(x=>x.id!==id);
    touch();
  }

  function moveIngredient(id, dir){
    const idx = draft.ingredients.findIndex(x=>x.id===id);
    if(idx<0) return;
    const to = idx + dir;
    if(to<0 || to>=draft.ingredients.length) return;
    const arr = draft.ingredients.slice();
    const [item] = arr.splice(idx,1);
    arr.splice(to,0,item);
    draft.ingredients = arr;
    touch();
  }

  // ---- Computations ----
  function estimateVolumeML(){
    // Convert amount+unit to rough ml; mark if estimated.
    let ml = 0;
    let estimated = false;
    for(const ing of draft.ingredients){
      const a = (ing.amount==null ? 0 : Number(ing.amount));
      const u = ing.unit;
      if(u==="ml"){ ml += a; }
      else if(u==="g"){ ml += a; estimated = true; } // assume 1g ~ 1ml
      else if(u==="dash"){ ml += a*0.9; estimated = true; }
      else if(u==="drop"){ ml += a*0.05; estimated = true; }
      else if(u==="spray"){ ml += a*0.15; estimated = true; }
      else if(u==="piece"){ ml += a*2; estimated = true; }
      else { estimated = true; }
    }
    return {ml, estimated};
  }

  function estimateABV(){
    // ABV = sum(alcohol_ml * abv) / total_ml
    const vol = estimateVolumeML();
    const total = Math.max(vol.ml, 0.0001);
    let alcoholPureML = 0;
    let estimated = vol.estimated;

    for(const ing of draft.ingredients){
      const abv = Number(ing.abv||0) / 100;
      if(abv<=0) continue;

      // estimate ingredient volume in ml
      const a = (ing.amount==null ? 0 : Number(ing.amount));
      let v = 0;
      if(ing.unit==="ml") v = a;
      else if(ing.unit==="g"){ v = a; estimated = true; }
      else if(ing.unit==="dash"){ v = a*0.9; estimated = true; }
      else if(ing.unit==="drop"){ v = a*0.05; estimated = true; }
      else if(ing.unit==="spray"){ v = a*0.15; estimated = true; }
      else if(ing.unit==="piece"){ v = a*2; estimated = true; }
      else { estimated = true; }

      alcoholPureML += v * abv;
    }

    const abvPct = (alcoholPureML / total) * 100;
    if(!isFinite(abvPct) || abvPct<0) return {abv:0, estimated:true};
    return {abv: abvPct, estimated};
  }

  function riskHints(){
    const issues = [];
    const hasCoffee = draft.ingredients.some(x=>x.name.includes("浓缩") || x.name.includes("冷萃") || x.name.includes("Coffee") || x.name.includes("Espresso"));
    if(!hasCoffee) issues.push("还没看到咖啡主体:建议至少加入 Espresso / Cold Brew / Coffee Liqueur 等。");

    const acid = draft.acid, sweet = draft.sweet, bitter = draft.bitter, body = draft.body;
    const hasAcidIng = draft.ingredients.some(x=>x.cat==="酸度") || draft.ingredients.some(x=>/柠檬|青柠|酸/.test(x.name));
    const hasSweetIng = draft.ingredients.some(x=>/糖浆|蜂蜜|枫糖|糖/.test(x.name));
    const hasTexture = draft.ingredients.some(x=>x.role==="texture" || /奶|Cream|蛋白|黄油/.test(x.name));

    if(acid>65 && !hasAcidIng) issues.push("你想要更高酸度,但原料里没有明显酸源(柠檬/苹果酸/柠檬酸等)。");
    if(sweet>65 && !hasSweetIng) issues.push("你想要更高甜度,但原料里没有甜味支撑(糖浆/蜂蜜/利口酒等)。");
    if(body>65 && !hasTexture) issues.push("你想要更高醇厚度,但原料里缺少质地支撑(奶/奶油/蛋白/黄油等)。");
    if(bitter>70 && sweet<35) issues.push("苦度高 + 甜度偏低:可能显得尖锐。可考虑增加糖/乳脂/可可类来圆润。");

    const abv = estimateABV();
    if(abv.abv>18 && draft.type==="hot") issues.push("热饮 ABV 偏高:注意酒精挥发与刺感,可用奶脂/蜂蜜/香料拉回平衡。");
    if(abv.abv>25) issues.push("ABV > 25%:更像短饮或餐后酒,稀释/出杯量/饮用节奏要更清晰。");

    // five elements completeness
    const roles = new Set(draft.ingredients.map(x=>x.role));
    if(!roles.has("boost")) issues.push("风味强化缺位:加一个“桥接香气/主香”会更完整(柑橘/花香/坚果/木质等)。");
    if(!roles.has("garnish")) issues.push("装饰缺位:建议至少一个视觉点(皮油、粉、薄片、喷雾、边口等)。");

    return issues;
  }

  // ---- Suggestions ----
  function selectedTags(){
    const tags = new Set();
    // from selected aroma families
    (draft.aromaFamily||[]).forEach(t=>tags.add(t));
    // from ingredients
    draft.ingredients.forEach(ing=>(ing.tags||[]).forEach(t=>tags.add(t)));
    // mood mapping
    const moodMap = {
      cozy:["caramel","dairy","nutty"],
      dreamy:["floral","citrus","fruity"],
      clean:["citrus","herbal","clean"],
      luxury:["floral","woody","dairy"],
      playful:["fruity","citrus","caramel"],
      dark:["woody","smoky","chocolate"]
    };
    (moodMap[draft.mood]||[]).forEach(t=>tags.add(t));
    return Array.from(tags);
  }

  function suggestBoosters(n=6){
    const tags = selectedTags();
    const used = new Set(draft.ingredients.map(x=>x.name));
    // Score library by overlap
    const candidates = LIB
      .filter(x=>!used.has(x.name))
      .filter(x=>["甜味","酸度","香气","香料/苦味","质地","装饰"].includes(x.cat))
      .map(x=>{
        const overlap = (x.tags||[]).filter(t=>tags.includes(t)).length;
        const moodBias = (x.tags||[]).includes(draft.mood) ? 1 : 0;
        const score = overlap*3 + moodBias;
        return {...x, score};
      })
      .sort((a,b)=>b.score-a.score || a.name.localeCompare(b.name,'zh-CN'));

    // Keep top and diversify categories
    const out = [];
    const catCount = new Map();
    for(const c of candidates){
      const k = c.cat;
      const ct = catCount.get(k)||0;
      if(ct>=3) continue;
      if(c.score<=0 && out.length>=Math.ceil(n/2)) break;
      out.push(c);
      catCount.set(k, ct+1);
      if(out.length>=n) break;
    }
    return out;
  }

  function renderRecommendations(picks = suggestBoosters(6), forceOpen=false){
    el.recommendations.innerHTML = "";
    picks.forEach(x=>{
      const btn = document.createElement('button');
      btn.className = "chip";
      btn.type = "button";
      btn.innerHTML = `${escapeHtml(x.name)} <small>${escapeHtml(x.cat)}</small>`;
      btn.addEventListener('click', ()=>{
        el.libCategory.value = x.cat;
        el.libSearch.value = "";
        refreshLibItems();
        el.libItem.value = x.name;
        const item = LIB.find(i=>i.name===x.name);
        if(item){
          el.amount.value = item.defaultAmt ?? "";
          el.unit.value = normalizeUnit(item.unitHint || "ml");
        }
        // guess role
        const roleGuess = (x.cat==="装饰") ? "garnish" : (x.cat==="质地" ? "texture" : "boost");
        el.role.value = roleGuess;
        setTab("ingredients");
        toast("已定位到原料");
      });
      el.recommendations.appendChild(btn);
    });
  }

  function applyRandomIdea(){
    // random mood, bases, plus 3 boosters
    const moods = ["cozy","dreamy","clean","luxury","playful","dark"];
    draft.mood = moods[Math.floor(Math.random()*moods.length)];
    draft.type = (Math.random()<0.72) ? "cold" : (Math.random()<0.5 ? "hot" : "dessert");
    draft.coffeeBase = COFFEE_BASES[Math.floor(Math.random()*COFFEE_BASES.length)];
    draft.spiritBase = SPIRIT_BASES[Math.floor(Math.random()*SPIRIT_BASES.length)];
    if(draft.spiritBase==="无") draft.type = (Math.random()<0.6) ? "lowabv" : draft.type;

    draft.sweet = randAround(draft.type==="dessert" ? 70 : 50, 20);
    draft.acid = randAround(draft.mood==="clean" ? 60 : 35, 22);
    draft.bitter = randAround(draft.mood==="dark" ? 70 : 45, 18);
    draft.boozy = randAround(draft.spiritBase==="无" ? 10 : 35, 22);
    draft.body = randAround(draft.mood==="cozy" ? 70 : 55, 18);

    if(!draft.name.trim()) draft.name = autoName();

    // reset ingredients lightly
    draft.ingredients = [];
    // add coffee base
    if(draft.coffeeBase.includes("浓缩")) addIngredient({name:"浓缩 Espresso", amount:30, unit:"ml", role:"main"});
    else if(draft.coffeeBase.includes("冷萃")) addIngredient({name:"冷萃 Cold Brew", amount:60, unit:"ml", role:"main"});
    else addIngredient({name:"美式/热水 Hot Water", amount:60, unit:"ml", role:"main"});

    // add spirit base if any
    if(draft.spiritBase!=="无"){
      const libName = draft.spiritBase;
      addIngredient({name: libName, amount: (LIB.find(x=>x.name===libName)?.defaultAmt ?? 25), unit:"ml", role:"main"});
    }

    // add 3 boosters
    const boosters = suggestBoosters(3);
    boosters.forEach(b=>{
      const roleGuess = (b.cat==="装饰") ? "garnish" : (b.cat==="质地" ? "texture" : "boost");
      addIngredient({name:b.name, amount:b.defaultAmt ?? 6, unit: normalizeUnit(b.unitHint||"ml"), role: roleGuess});
    });

    // add ice for cold
    if(draft.type==="cold" || draft.type==="nitro") addIngredient({name:"冰 Ice", amount:120, unit:"g", role:"texture"});

    applyDraftToUI();
    touch();
  }

  function randAround(center, spread){
    const v = Math.round(center + (Math.random()*2-1)*spread);
    return Math.max(0, Math.min(100, v));
  }

  // ---- Naming & Concept helpers ----
  function autoName(){
    const moodWords = {
      cozy:["Warm Bath","Chestnut Steam","Soft Towel","Amber Room","Sunday Robe"],
      dreamy:["Dream Bath","Moon Foam","Inception Mist","Cloud Curtain","Lucid Drip"],
      clean:["Citrus Rinse","Clear Tile","Cold Shower","White Steam","Bright Basin"],
      luxury:["Velvet Bath","Silk Mirror","Gold Steam","Opera Robe","Perfume Marble"],
      playful:["Bubble Pop","Foam Party","Splash!","Soap Candy","Tiny Whirlpool"],
      dark:["Midnight Tub","Black Tile","Smoke Mirror","Deep Steam","Shadow Rinse"]
    };
    const key = draft.mood || "dreamy";
    const pool = moodWords[key] || moodWords.dreamy;
    const w = pool[Math.floor(Math.random()*pool.length)];

    // add a taste anchor
    const tags = selectedTags();
    const anchor = tags.includes("citrus") ? "Bergamot" :
                   tags.includes("nutty") ? "Almond" :
                   tags.includes("chocolate") ? "Cacao" :
                   tags.includes("woody") ? "Vetiver" :
                   tags.includes("floral") ? "Rose" :
                   "Coffee";
    const style = draft.type==="hot" ? "Hot" : (draft.type==="dessert" ? "Dessert" : "Cold");
    return `${w} · ${anchor} · ${style}`;
  }

  function genPitch(){
    const keyw = (draft.keywords||"").split(/[,,]/).map(s=>s.trim()).filter(Boolean).slice(0,4);
    const kw = keyw.length ? keyw.join(" / ") : "一场有温度的想象";
    const abv = estimateABV();
    const abvText = (abv.abv>0.5) ? `(ABV 约 ${abv.abv.toFixed(1)}%${abv.estimated?"*":""})` : "(可做无酒精版本)";
    const topAroma = (draft.aromaFamily||[]).slice(0,3);
    const aromaText = topAroma.length ? `主香走向:${topAroma.join("、")}` : "主香走向:由你选择的强化原料决定";
    const base = `${draft.coffeeBase}${draft.spiritBase!=="无" ? " × "+draft.spiritBase : ""}`;
    return `一句话 Pitch:
这杯以「${base}」为骨架,把「${kw}」做成入口的节奏:先闻到香气,再落到咖啡的深处,最后收在干净的尾韵。${aromaText} ${abvText}`;
  }

  function genStageName(){
    const name1 = autoName();
    const name2 = autoName();
    return `命名候选:
1) ${name1}
2) ${name2}

剧场一句:
“把灯光压低,让杯口的第一口香气先登场;咖啡随后出现,像幕布拉开;最后的余韵,是你走出房间时还留在围巾上的那点气味。”`;
  }

  function checkFiveElements(){
    const roles = {main:0, boost:0, texture:0, garnish:0};
    draft.ingredients.forEach(x=>{ if(roles[x.role]!=null) roles[x.role]++; });
    const missing = [];
    if(roles.main===0) missing.push("主体元素");
    if(roles.boost===0) missing.push("风味强化");
    if(roles.texture===0) missing.push("质地");
    if(roles.garnish===0) missing.push("装饰");
    const conceptOk = (draft.story||"").trim().length>0 || (draft.keywords||"").trim().length>0;

    const score = (roles.main>0) + (roles.boost>0) + (roles.texture>0) + (roles.garnish>0) + (conceptOk?1:0);
    const lines = [];
    lines.push(`五要素检查:${score}/5`);
    lines.push(`- 主体元素:${roles.main} 项`);
    lines.push(`- 风味强化:${roles.boost} 项`);
    lines.push(`- 质地:${roles.texture} 项`);
    lines.push(`- 装饰:${roles.garnish} 项`);
    lines.push(`- 概念:${conceptOk ? "已填写" : "缺失(建议写一句话 pitch)"}`);

    if(missing.length){
      lines.push(`\n缺失项:${missing.join("、")}。`);
      lines.push(`建议:至少补齐 1 个“桥接强化”(柑橘/花香/坚果/木质之一)+ 1 个“视觉点”。`);
    }else{
      lines.push(`\n完整度不错:可以开始精修“比例与口感曲线”。`);
    }
    return lines.join("\n");
  }

  // ---- Renderers ----
  function applyDraftToUI(){
    el.name.value = draft.name || "";
    el.type.value = draft.type || "cold";
    el.coffeeBase.value = draft.coffeeBase || "浓缩 Espresso";
    el.spiritBase.value = draft.spiritBase || "无";
    el.glass.value = draft.glass || "rocks";
    el.mood.value = draft.mood || "dreamy";

    el.sweet.value = draft.sweet ?? 50; el.sweetVal.textContent = String(el.sweet.value);
    el.acid.value = draft.acid ?? 35; el.acidVal.textContent = String(el.acid.value);
    el.bitter.value = draft.bitter ?? 45; el.bitterVal.textContent = String(el.bitter.value);
    el.boozy.value = draft.boozy ?? 25; el.boozyVal.textContent = String(el.boozy.value);
    el.body.value = draft.body ?? 55; el.bodyVal.textContent = String(el.body.value);

    el.technique.value = draft.technique || "build";
    el.controlPoints.value = draft.controlPoints || "";
    el.steps.value = draft.steps || "";
    el.presentation.value = draft.presentation || "";
    el.experience.value = draft.experience || "";

    el.keywords.value = draft.keywords || "";
    el.story.value = draft.story || "";

    // aromaFamily
    Array.from(el.aromaFamily.options).forEach(o=>{
      o.selected = (draft.aromaFamily||[]).includes(o.value);
    });

    renderIngredientList();
    renderPreview();
    renderSavedList();
    renderRecommendations();
  }

  function renderIngredientList(){
    el.ingList.innerHTML = "";
    if(!draft.ingredients.length){
      el.ingList.innerHTML = `<div class="hint">还没有原料。你可以点 <b>+ 浓缩 30ml</b> 或从原料库添加。</div>`;
      return;
    }
    draft.ingredients.forEach((ing, idx)=>{
      const div = document.createElement('div');
      div.className = "item";
      const roleLabel = roleToLabel(ing.role);
      const amtText = formatAmount(ing.amount, ing.unit);
      const abv = ing.abv ? ` · ${ing.abv}%` : "";
      div.innerHTML = `
        <div>
          <h3>${escapeHtml(ing.name)} <span class="kbd">${escapeHtml(roleLabel)}</span></h3>
          <p>${escapeHtml(amtText)}${escapeHtml(abv)}<br/><span class="muted">${escapeHtml((ing.tags||[]).slice(0,6).join(" / "))}</span></p>
        </div>
        <div class="itemActions">
          <button class="btnGhost" data-act="up">上移</button>
          <button class="btnGhost" data-act="down">下移</button>
          <button class="btnGhost" data-act="edit">编辑</button>
          <button class="btnDanger" data-act="del">删除</button>
        </div>
      `;
      div.querySelector('[data-act="up"]').addEventListener('click', ()=>moveIngredient(ing.id, -1));
      div.querySelector('[data-act="down"]').addEventListener('click', ()=>moveIngredient(ing.id, +1));
      div.querySelector('[data-act="del"]').addEventListener('click', ()=>removeIngredient(ing.id));
      div.querySelector('[data-act="edit"]').addEventListener('click', ()=>openEditIngredient(ing));
      el.ingList.appendChild(div);
    });
  }

  function openEditIngredient(ing){
    // lightweight prompt-based editor (works well on mobile)
    const amount = prompt(`编辑用量(数字,当前:${ing.amount ?? ""})`, String(ing.amount ?? ""));
    if(amount===null) return;
    const num = parseFloat(amount);
    if(isFinite(num)) updateIngredient(ing.id, {amount: num});

    const unit = prompt(`编辑单位(ml/g/dash/drop/piece/spray,当前:${ing.unit})`, ing.unit);
    if(unit===null) return;
    const u = normalizeUnit(unit.trim());
    updateIngredient(ing.id, {unit: u});

    const role = prompt(`编辑归类(main/boost/texture/garnish,当前:${ing.role})`, ing.role);
    if(role===null) return;
    const r = ["main","boost","texture","garnish"].includes(role.trim()) ? role.trim() : ing.role;
    updateIngredient(ing.id, {role: r});
  }

  function roleToLabel(r){
    return r==="main"?"主体元素": r==="boost"?"风味强化": r==="texture"?"质地": r==="garnish"?"装饰":"—";
  }

  function formatAmount(a,u){
    const amt = (a==null || !isFinite(Number(a))) ? "" : Number(a).toString();
    return amt ? `${amt} ${u}` : `${u}`;
  }

  function renderPreview(){
    const abv = estimateABV();
    const vol = estimateVolumeML();
    const issues = riskHints();

    // badges
    el.badgeABV.textContent = `ABV:${(abv.abv>0.5 ? abv.abv.toFixed(1)+'%' : '—')}${abv.estimated && abv.abv>0.5 ? "*" : ""}`;
    el.badgeABV.className = "badge " + (abv.abv>18 ? "warn" : "good");

    el.badgeVol.textContent = `总量:${vol.ml>1 ? Math.round(vol.ml)+' ml' : '—'}${vol.estimated && vol.ml>1 ? "*" : ""}`;
    el.badgeVol.className = "badge " + (vol.ml>220 ? "warn" : "good");

    const riskLevel = issues.length>=3 ? "danger" : (issues.length>=1 ? "warn" : "good");
    el.badgeRisk.textContent = `提示:${issues.length ? issues.length+" 条" : "OK"}`;
    el.badgeRisk.className = "badge " + riskLevel;

    // smart hints block
    el.smartHints.innerHTML = (issues.length ? ("• " + issues.map(escapeHtml).join("<br/>• ")) : "目前结构很顺,下一步可以去精修“步骤与呈现脚本”。");

    // preview text
    el.preview.textContent = buildRecipeText(draft);

    // recommendations
    renderRecommendations();
  }

  function buildRecipeText(r){
    const abv = estimateABV();
    const vol = estimateVolumeML();
    const lines = [];

    lines.push(`${r.name?.trim() ? r.name.trim() : "(未命名配方)"}`);
    lines.push(`类型:${typeLabel(r.type)} | 杯型:${glassLabel(r.glass)} | 氛围:${moodLabel(r.mood)}`);
    lines.push(`主体:${r.coffeeBase}${r.spiritBase && r.spiritBase!=="无" ? " × "+r.spiritBase : ""}`);
    lines.push(`平衡意图:甜${r.sweet}/酸${r.acid}/苦${r.bitter}/酒精感${r.boozy}/醇厚${r.body}`);

    if(vol.ml>1 || abv.abv>0.5){
      const meta = [];
      if(vol.ml>1) meta.push(`总量≈${Math.round(vol.ml)}ml${vol.estimated?"*":""}`);
      if(abv.abv>0.5) meta.push(`ABV≈${abv.abv.toFixed(1)}%${abv.estimated?"*":""}`);
      lines.push(meta.join(" | "));
    }

    lines.push("");
    lines.push("【五要素】");
    const grouped = groupByRole(r.ingredients || []);
    lines.push(`主体元素:${formatGroup(grouped.main)}`);
    lines.push(`风味强化:${formatGroup(grouped.boost)}`);
    lines.push(`质地:${formatGroup(grouped.texture)}`);
    lines.push(`装饰:${formatGroup(grouped.garnish)}`);

    if(r.technique || r.controlPoints || r.steps){
      lines.push("");
      lines.push("【做法】");
      if(r.technique) lines.push(`方式:${techLabel(r.technique)}${r.controlPoints?.trim() ? " | 关键点:"+r.controlPoints.trim() : ""}`);
      if(r.steps?.trim()) lines.push(r.steps.trim());
    }

    if(r.presentation?.trim() || r.experience?.trim()){
      lines.push("");
      lines.push("【呈现/体验】");
      if(r.presentation?.trim()) lines.push("呈现:" + r.presentation.trim());
      if(r.experience?.trim()) lines.push("节奏:" + r.experience.trim());
    }

    if((r.keywords||"").trim() || (r.story||"").trim()){
      lines.push("");
      lines.push("【概念】");
      if((r.keywords||"").trim()) lines.push("关键词:" + r.keywords.trim());
      if((r.story||"").trim()) lines.push("一句话:" + r.story.trim());
    }

    lines.push("");
    lines.push(`* 说明:带 * 为估算(含 g/dash/drop/spray 等换算)。`);
    return lines.join("\n");
  }

  function groupByRole(ings){
    const g = {main:[], boost:[], texture:[], garnish:[]};
    (ings||[]).forEach(x=>{
      const k = g[x.role] ? x.role : "boost";
      g[k].push(x);
    });
    return g;
  }
  function formatGroup(arr){
    if(!arr || !arr.length) return "—";
    return arr.map(x=>{
      const amt = (x.amount==null || !isFinite(Number(x.amount))) ? "" : `${Number(x.amount)}${x.unit}`;
      return amt ? `${x.name} ${amt}` : `${x.name}`;
    }).join(";");
  }

  function typeLabel(v){
    return ({cold:"冷饮",hot:"热饮",nitro:"氮气/气泡",dessert:"甜品向",lowabv:"低酒精/无酒精"})[v] || v;
  }
  function glassLabel(v){
    return ({rocks:"Rocks",coupe:"Coupe",martini:"Martini",highball:"Highball",mug:"Mug",tulip:"Tulip",stemless:"Stemless",bottle:"Bottle"})[v] || v;
  }
  function moodLabel(v){
    return ({cozy:"温暖治愈",dreamy:"梦境奇幻",clean:"清爽干净",luxury:"高端精致",playful:"俏皮有趣",dark:"暗黑深邃"})[v] || v;
  }
  function techLabel(v){
    return ({shake:"Shake",stir:"Stir",build:"Build",blend:"Blend",hotbuild:"Hot Build",clarify:"Clarify"})[v] || v;
  }

  // ---- Library (save/load) ----
  function saveToLibrary(){
    const recipe = normalizeRecipe({...draft});
    if(!recipe.name.trim()) recipe.name = autoName();

    recipe.updatedAt = new Date().toISOString();
    // upsert by id
    const idx = saved.findIndex(x=>x.id===recipe.id);
    if(idx>=0) saved[idx] = recipe; else saved.unshift(recipe);

    // keep recent
    saved = saved.sort((a,b)=>(b.updatedAt||"").localeCompare(a.updatedAt||""));
    saveSaved(saved);

    // also update draft
    draft = recipe;
    saveDraft(draft);
    renderSavedList();
    renderPreview();
    el.name.value = draft.name;
    toast("已保存到配方库(⌘/Ctrl + S 也可保存)");
  }

  function renderSavedList(){
    const q = (el.libFilter.value || "").trim().toLowerCase();
    const list = saved.filter(r=>{
      if(!q) return true;
      const hay = `${r.name||""} ${r.keywords||""} ${r.story||""}`.toLowerCase();
      return hay.includes(q);
    });

    el.savedList.innerHTML = "";
    if(!list.length){
      el.savedList.innerHTML = `<div class="hint">暂无匹配配方。你可以先保存当前配方。</div>`;
      return;
    }

    list.forEach(r=>{
      const div = document.createElement('div');
      div.className = "item";
      const abv = estimateABVFor(r);
      const vol = estimateVolumeFor(r);
      const meta = [];
      if(vol.ml>1) meta.push(`≈${Math.round(vol.ml)}ml${vol.estimated?"*":""}`);
      if(abv.abv>0.5) meta.push(`ABV≈${abv.abv.toFixed(1)}%${abv.estimated?"*":""}`);
      const metaText = meta.length ? meta.join(" · ") : "—";

      div.innerHTML = `
        <div>
          <h3>${escapeHtml(r.name || "(未命名)")}</h3>
          <p>${escapeHtml(typeLabel(r.type))} | ${escapeHtml(metaText)}<br/>
             <span class="muted">${escapeHtml((r.keywords||"").slice(0,60))}${(r.keywords||"").length>60?"…":""}</span></p>
        </div>
        <div class="itemActions">
          <button class="btnGhost" data-act="load">载入</button>
          <button class="btnGhost" data-act="dup">复制</button>
          <button class="btnDanger" data-act="del">删除</button>
        </div>
      `;
      div.querySelector('[data-act="load"]').addEventListener('click', ()=>{
        draft = normalizeRecipe(r);
        saveDraft(draft);
        applyDraftToUI();
        setRTab('preview');
        toast("已载入配方");
      });
      div.querySelector('[data-act="dup"]').addEventListener('click', ()=>{
        const copy = normalizeRecipe({...r});
        copy.id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
        copy.name = (copy.name || "Untitled") + " (Copy)";
        copy.updatedAt = new Date().toISOString();
        saved.unshift(copy);
        saveSaved(saved);
        renderSavedList();
        toast("已复制到配方库");
      });
      div.querySelector('[data-act="del"]').addEventListener('click', ()=>{
        if(confirm(`删除配方「${r.name||"(未命名)"}」?此操作不可撤销。`)){
          saved = saved.filter(x=>x.id!==r.id);
          saveSaved(saved);
          renderSavedList();
          toast("已删除");
        }
      });

      el.savedList.appendChild(div);
    });
  }

  function normalizeRecipe(r){
    const base = defaultDraft();
    const out = {...base, ...r};
    out.ingredients = Array.isArray(r.ingredients) ? r.ingredients.map(ing=>{
      const libItem = LIB.find(x=>x.name===ing.name);
      return {
        id: ing.id || (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()+Math.random())),
        name: ing.name || "Unknown",
        amount: (ing.amount==null || ing.amount==="") ? null : Number(ing.amount),
        unit: normalizeUnit(ing.unit || libItem?.unitHint || "ml"),
        role: ["main","boost","texture","garnish"].includes(ing.role) ? ing.role : "boost",
        abv: (ing.abv!=null ? Number(ing.abv) : (libItem?.abv ?? 0)),
        tags: Array.isArray(ing.tags) ? ing.tags : (libItem?.tags ?? [])
      };
    }) : [];
    out.aromaFamily = Array.isArray(r.aromaFamily) ? r.aromaFamily : [];
    out.updatedAt = r.updatedAt || new Date().toISOString();
    return out;
  }

  // compute for saved items without touching draft
  function estimateVolumeFor(r){
    let ml=0, estimated=false;
    for(const ing of (r.ingredients||[])){
      const a = (ing.amount==null ? 0 : Number(ing.amount));
      const u = ing.unit;
      if(u==="ml"){ ml += a; }
      else if(u==="g"){ ml += a; estimated = true; }
      else if(u==="dash"){ ml += a*0.9; estimated = true; }
      else if(u==="drop"){ ml += a*0.05; estimated = true; }
      else if(u==="spray"){ ml += a*0.15; estimated = true; }
      else if(u==="piece"){ ml += a*2; estimated = true; }
      else { estimated = true; }
    }
    return {ml, estimated};
  }
  function estimateABVFor(r){
    const vol = estimateVolumeFor(r);
    const total = Math.max(vol.ml, 0.0001);
    let pure=0, estimated=vol.estimated;
    for(const ing of (r.ingredients||[])){
      const abv = Number(ing.abv||0)/100;
      if(abv<=0) continue;
      const a=(ing.amount==null?0:Number(ing.amount));
      let v=0;
      if(ing.unit==="ml") v=a;
      else if(ing.unit==="g"){ v=a; estimated=true; }
      else if(ing.unit==="dash"){ v=a*0.9; estimated=true; }
      else if(ing.unit==="drop"){ v=a*0.05; estimated=true; }
      else if(ing.unit==="spray"){ v=a*0.15; estimated=true; }
      else if(ing.unit==="piece"){ v=a*2; estimated=true; }
      else { estimated=true; }
      pure += v*abv;
    }
    return {abv: (pure/total)*100, estimated};
  }

  // ---- Export / Import helpers ----
  function exportJSON(r){
    const blob = new Blob([JSON.stringify(r, null, 2)], {type:"application/json;charset=utf-8"});
    downloadBlob(blob, `${safeFilename(r.name||"recipe")}.json`);
    toast("已导出 JSON");
  }

  function exportAllJSON(list){
    const blob = new Blob([JSON.stringify(list, null, 2)], {type:"application/json;charset=utf-8"});
    downloadBlob(blob, `coffee-recipes-${new Date().toISOString().slice(0,10)}.json`);
    toast("已导出全部 JSON");
  }

  function exportCSV(r){
    const header = ["name","role","ingredient","amount","unit","abv"];
    const rows = (r.ingredients||[]).map(x=>[
      csvEscape(r.name||""),
      csvEscape(roleToLabel(x.role)),
      csvEscape(x.name||""),
      (x.amount==null ? "" : x.amount),
      x.unit||"",
      (x.abv==null ? "" : x.abv)
    ].join(","));
    const metaLines = [
      ["# 类型", typeLabel(r.type)].join(","),
      ["# 杯型", glassLabel(r.glass)].join(","),
      ["# 主体", `${r.coffeeBase}${r.spiritBase!=="无" ? " x "+r.spiritBase : ""}`].join(","),
      ["# 平衡", `甜${r.sweet}/酸${r.acid}/苦${r.bitter}/酒精感${r.boozy}/醇厚${r.body}`].join(",")
    ];
    const csv = [metaLines.join("\n"), header.join(","), ...rows].join("\n");
    const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
    downloadBlob(blob, `${safeFilename(r.name||"recipe")}.csv`);
    toast("已导出 CSV");
  }

  function downloadBlob(blob, filename){
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(()=>{
      URL.revokeObjectURL(url);
      a.remove();
    }, 400);
  }

  function csvEscape(s){
    s = String(s ?? "");
    if(/[",\n]/.test(s)) return `"${s.replace(/"/g,'""')}"`;
    return s;
  }

  function safeFilename(s){
    return String(s).trim().replace(/[\\/:*?"<>|]+/g,"-").slice(0,60) || "recipe";
  }

  // ---- Persistence ----
  function touch(){
    draft.updatedAt = new Date().toISOString();
    saveDraft(draft);
    renderIngredientList();
    renderPreview();
  }

  function loadDraft(){
    try{
      const raw = localStorage.getItem(LS_KEY_DRAFT);
      if(!raw) return null;
      return normalizeRecipe(JSON.parse(raw));
    }catch{ return null; }
  }
  function saveDraft(obj){
    try{ localStorage.setItem(LS_KEY_DRAFT, JSON.stringify(obj)); }catch{}
  }
  function loadSaved(){
    try{
      const raw = localStorage.getItem(LS_KEY_SAVED);
      if(!raw) return [];
      const arr = JSON.parse(raw);
      return Array.isArray(arr) ? arr.map(normalizeRecipe) : [];
    }catch{ return []; }
  }
  function saveSaved(arr){
    try{ localStorage.setItem(LS_KEY_SAVED, JSON.stringify(arr)); }catch{}
  }

  // ---- Utilities ----
  function escapeHtml(s){
    return String(s ?? "")
      .replaceAll("&","&amp;")
      .replaceAll("<","&lt;")
      .replaceAll(">","&gt;")
      .replaceAll('"',"&quot;")
      .replaceAll("'","&#39;");
  }

  async function copyToClipboard(text){
    try{
      await navigator.clipboard.writeText(text);
    }catch{
      // fallback
      const ta = document.createElement('textarea');
      ta.value = text;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      ta.remove();
    }
  }

  let toastTimer = null;
  function toast(msg){
    el.toast.textContent = msg;
    el.toast.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(()=>el.toast.classList.remove('show'), 1600);
  }

  // ---- Boot ----
  initSelects();
  bind();
  applyDraftToUI();
  setTab("core");
  setRTab("preview");
  renderRecommendations();

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

        
编辑器加载中
预览
控制台