特调设计记录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>特调咖啡配方设计器 | Coffee Recipe Lab</title>
  <style>
    :root {
      --bg: #f5f2ec;
      --card: #ffffff;
      --ink: #1f2937;
      --muted: #6b7280;
      --line: #e5e7eb;
      --accent: #7a5c44;
      --accent-soft: #e3d3c1;
      --danger: #b91c1c;
      --radius: 18px;
      --shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
        "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui,
        sans-serif;
      background: radial-gradient(circle at top left, #fdfaf5 0, #f2eee7 45%, #ece7de 100%);
      color: var(--ink);
      padding: 16px;
    }

    .app-shell {
      max-width: 1200px;
      margin: 0 auto 40px;
    }

    header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 16px;
    }

    header h1 {
      font-size: 1.4rem;
      margin: 0;
      display: flex;
      align-items: center;
      gap: 10px;
    }

    header h1 span.logo-dot {
      width: 26px;
      height: 26px;
      border-radius: 50%;
      background: radial-gradient(circle at 30% 30%, #f8f4ef 0, #c69b76 40%, #5a3923 100%);
      box-shadow: 0 4px 10px rgba(0,0,0,.25);
      position: relative;
    }

    header h1 span.logo-dot::after {
      content: "";
      position: absolute;
      inset: 40%;
      border-radius: 50%;
      background: rgba(255,255,255,.35);
      filter: blur(2px);
    }

    header p {
      margin: 0;
      font-size: 0.8rem;
      color: var(--muted);
    }

    .pill {
      font-size: 0.78rem;
      padding: 5px 10px;
      border-radius: 999px;
      background: rgba(122,92,68,.08);
      color: var(--accent);
      border: 1px solid rgba(122,92,68,.25);
      white-space: nowrap;
    }

    .layout {
      display: grid;
      grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
      gap: 18px;
    }

    @media (max-width: 900px) {
      .layout {
        grid-template-columns: minmax(0, 1fr);
      }
      header {
        flex-direction: column;
        align-items: flex-start;
      }
    }

    .card {
      background: var(--card);
      border-radius: var(--radius);
      box-shadow: var(--shadow);
      padding: 18px 18px 16px;
      border: 1px solid rgba(148, 163, 184, .3);
    }

    .card h2 {
      font-size: 1rem;
      margin: 0 0 12px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }

    .card h2 .sub {
      font-size: 0.75rem;
      font-weight: 500;
      color: var(--muted);
    }

    .section-row {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
      margin-bottom: 8px;
    }

    label.field {
      display: flex;
      flex-direction: column;
      gap: 4px;
      font-size: 0.78rem;
      color: var(--muted);
      flex: 1 1 120px;
      min-width: 0;
    }

    .field span.label-main {
      font-weight: 500;
      color: #4b5563;
    }

    input[type="text"],
    input[type="number"],
    select,
    textarea {
      border-radius: 999px;
      border: 1px solid var(--line);
      padding: 7px 10px;
      font-size: 0.82rem;
      outline: none;
      background: #fbfbfb;
      transition: border-color .15s ease, box-shadow .15s ease, background .15s;
      width: 100%;
    }

    textarea {
      border-radius: 14px;
      resize: vertical;
      min-height: 70px;
      font-family: inherit;
      line-height: 1.4;
    }

    input:focus,
    select:focus,
    textarea:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 1px rgba(122,92,68,.25);
      background: #ffffff;
    }

    input::placeholder,
    textarea::placeholder {
      color: #9ca3af;
    }

    .hint {
      font-size: 0.72rem;
      color: #9ca3af;
    }

    .tag-row {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      margin-top: 4px;
    }

    .tag {
      font-size: 0.72rem;
      padding: 4px 8px;
      border-radius: 999px;
      border: 1px solid var(--line);
      cursor: pointer;
      user-select: none;
      background: #f9fafb;
    }

    .tag.active {
      background: var(--accent-soft);
      border-color: var(--accent);
      color: var(--accent);
      font-weight: 500;
    }

    /* sliders */
    .slider-row {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 6px;
    }

    .slider-row span.slider-label {
      flex: 0 0 74px;
      font-size: 0.78rem;
      color: #4b5563;
    }

    .slider-row input[type="range"] {
      flex: 1;
      accent-color: var(--accent);
    }

    .slider-value {
      width: 28px;
      font-size: 0.8rem;
      text-align: right;
      color: var(--muted);
    }

    /* ingredient table */
    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 6px;
      font-size: 0.78rem;
    }

    thead {
      background: #f3f4f6;
    }

    th, td {
      padding: 6px 6px;
      border-bottom: 1px solid #edf0f4;
      text-align: left;
    }

    th {
      font-weight: 600;
      color: #4b5563;
      font-size: 0.75rem;
    }

    td select,
    td input[type="text"],
    td input[type="number"] {
      border-radius: 999px;
      padding: 5px 8px;
      font-size: 0.76rem;
    }

    td .small-input {
      width: 70px;
    }

    td .unit-select {
      width: 70px;
    }

    td .category-select {
      min-width: 80px;
    }

    td .ingredient-select {
      min-width: 120px;
    }

    td .custom-ingredient {
      margin-top: 4px;
    }

    .table-actions {
      display: flex;
      gap: 6px;
      justify-content: flex-end;
      margin-top: 8px;
    }

    button {
      border-radius: 999px;
      border: none;
      padding: 7px 14px;
      font-size: 0.8rem;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: #111827;
      color: #f9fafb;
      transition: transform .08s ease, box-shadow .08s ease, background .15s;
    }

    button.small {
      padding: 4px 10px;
      font-size: 0.75rem;
    }

    button.secondary {
      background: #f3f4f6;
      color: #374151;
    }

    button.ghost {
      background: transparent;
      color: var(--muted);
      border: 1px dashed var(--line);
    }

    button.danger {
      background: rgba(220,38,38,.04);
      color: var(--danger);
      border: 1px solid rgba(220,38,38,.3);
    }

    button:hover {
      transform: translateY(-1px);
      box-shadow: 0 8px 20px rgba(15,23,42,.18);
    }

    button.secondary:hover,
    button.ghost:hover,
    button.danger:hover {
      box-shadow: 0 4px 12px rgba(148,163,184,.4);
    }

    button:active {
      transform: translateY(0);
      box-shadow: none;
    }

    .btn-icon {
      font-size: 1rem;
      line-height: 1;
    }

    .divider {
      height: 1px;
      background: linear-gradient(90deg, rgba(148,163,184,.25), rgba(148,163,184,.05));
      margin: 10px 0 10px;
    }

    .footer-actions {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      justify-content: flex-end;
      margin-top: 6px;
    }

    .canvas-card {
      margin-top: 16px;
    }

    #recipeCanvas {
      width: 100%;
      max-width: 480px;
      border-radius: 16px;
      border: 1px solid rgba(148,163,184,.4);
      display: block;
      margin: 6px auto 4px;
      background: var(--bg);
    }

    .canvas-footer {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      font-size: 0.76rem;
      color: var(--muted);
      flex-wrap: wrap;
    }

    a#downloadLink {
      color: var(--accent);
      text-decoration: none;
      font-weight: 500;
    }

    a#downloadLink:hover {
      text-decoration: underline;
    }

    .error {
      font-size: 0.75rem;
      color: var(--danger);
      margin-top: 4px;
    }
  </style>
</head>
<body>
  <div class="app-shell">
    <header>
      <div>
        <h1>
          <span class="logo-dot"></span>
          Coffee Recipe Lab
        </h1>
        <p>设计一杯完全属于你的特调咖啡:从食材 → 风味 → 配方图片,一气呵成。</p>
      </div>
      <div class="pill">本地运行 · 无需联网 · 仅教学/创作用途</div>
    </header>

    <div class="layout">
      <!-- 左侧:基础信息 + 食材 -->
      <section class="card">
        <h2>
          基础信息
          <span class="sub">命名、调制方式、杯型等</span>
        </h2>

        <div class="section-row">
          <label class="field">
            <span class="label-main">饮品名称</span>
            <input id="drinkName" type="text" placeholder="例如:Midnight Cascara、栗子与木头……" />
          </label>
        </div>

        <div class="section-row">
          <label class="field">
            <span class="label-main">服务温度</span>
            <select id="serveTemp">
              <option value="冷饮">冷饮</option>
              <option value="热饮">热饮</option>
              <option value="温饮">温饮</option>
              <option value="冷热分层">冷热分层</option>
            </select>
          </label>

          <label class="field">
            <span class="label-main">咖啡基底</span>
            <select id="coffeeBase">
              <option value="意式浓缩">意式浓缩</option>
              <option value="意式浓缩(Ristretto)">意式浓缩(Ristretto)</option>
              <option value="双倍浓缩">双倍浓缩</option>
              <option value="美式咖啡">美式咖啡</option>
              <option value="冷萃咖啡浓缩">冷萃咖啡浓缩</option>
              <option value="手冲滤泡">手冲滤泡</option>
              <option value="摩卡壶萃取">摩卡壶萃取</option>
              <option value="挂耳咖啡浓缩">挂耳咖啡浓缩</option>
              <option value="其他/多重咖啡基底">其他/多重咖啡基底</option>
            </select>
          </label>

          <label class="field">
            <span class="label-main">酒精结构</span>
            <select id="spiritStyle">
              <option value="低酒精(0–5%)">低酒精(0–5%)</option>
              <option value="轻酒精(5–10%)">轻酒精(5–10%)</option>
              <option value="中等酒精(10–15%)">中等酒精(10–15%)</option>
              <option value="较高酒精(15–20%)">较高酒精(15–20%)</option>
              <option value="无酒精 / Mocktail">无酒精 / Mocktail</option>
            </select>
          </label>
        </div>

        <div class="section-row">
          <label class="field">
            <span class="label-main">调制方式</span>
            <select id="method">
              <option value="Build 直接注入杯中">Build 直接注入杯中</option>
              <option value="Shake 摇和后滤出">Shake 摇和后滤出</option>
              <option value="Stir 搅拌冷却后滤出">Stir 搅拌冷却后滤出</option>
              <option value="Layer 分层注入">Layer 分层注入</option>
              <option value="Blend 搅打 / 冰沙">Blend 搅打 / 冰沙</option>
              <option value="注入并轻轻滚杯">注入并轻轻滚杯</option>
            </select>
          </label>

          <label class="field">
            <span class="label-main">杯型</span>
            <select id="glassware">
              <option value="Rocks / 平底威士忌杯">Rocks / 平底威士忌杯</option>
              <option value="Coupe / 碟形杯">Coupe / 碟形杯</option>
              <option value="Nick & Nora / 小鸡尾杯">Nick & Nora / 小鸡尾杯</option>
              <option value="高球杯 / Collins">高球杯 / Collins</option>
              <option value="爱尔兰咖啡杯">爱尔兰咖啡杯</option>
              <option value="马克杯">马克杯</option>
              <option value="无柄玻璃杯">无柄玻璃杯</option>
              <option value="特殊容器 / 主题器皿">特殊容器 / 主题器皿</option>
            </select>
          </label>

          <label class="field">
            <span class="label-main">冰块/温度细节</span>
            <input id="iceNote" type="text" placeholder="大冰块、碎冰、无冰、预热杯等" />
          </label>
        </div>

        <div class="divider"></div>

        <h2 style="margin-top: 4px;">
          食材结构
          <span class="sub">咖啡 × 烈酒 × 非酒精 × 装饰</span>
        </h2>
        <p class="hint">
          建议先从「咖啡 + 主体烈酒」开始,再加入甜味、酸度、香料、装饰等层次。可无限新增行,自由编辑。
        </p>

        <div style="overflow-x:auto; border-radius: 12px; border:1px solid #e5e7eb;">
          <table>
            <thead>
              <tr>
                <th style="width:90px;">类别</th>
                <th>食材</th>
                <th style="width:80px;">用量</th>
                <th style="width:68px;">单位</th>
                <th>备注/处理方式</th>
                <th style="width:60px;">操作</th>
              </tr>
            </thead>
            <tbody id="ingredientTableBody">
              <!-- JS 填充 -->
            </tbody>
          </table>
        </div>

        <div class="table-actions">
          <button type="button" class="secondary small" id="addIngredientBtn">
            <span class="btn-icon">+</span> 增加一行
          </button>
        </div>
      </section>

      <!-- 右侧:风味 + 配方卡 -->
      <section class="card">
        <h2>
          风味构架
          <span class="sub">从各个维度把这杯特调想清楚</span>
        </h2>

        <div class="section-row">
          <label class="field">
            <span class="label-main">风味一句话</span>
            <input id="flavorTagline" type="text" placeholder="例如:焦糖坚果包裹的柑橘酸度,尾韵像一块融化的黑巧克力。" />
          </label>
        </div>

        <div class="section-row" style="flex-direction:column; gap:4px;">
          <span class="label-main" style="font-size:0.8rem;color:#4b5563;">风味重心 & 酒精感</span>
          <div class="slider-row">
            <span class="slider-label">甜度</span>
            <input id="sweetness" type="range" min="0" max="10" value="5">
            <span class="slider-value" data-for="sweetness">5</span>
          </div>
          <div class="slider-row">
            <span class="slider-label">酸度</span>
            <input id="acidity" type="range" min="0" max="10" value="5">
            <span class="slider-value" data-for="acidity">5</span>
          </div>
          <div class="slider-row">
            <span class="slider-label">苦度</span>
            <input id="bitterness" type="range" min="0" max="10" value="4">
            <span class="slider-value" data-for="bitterness">4</span>
          </div>
          <div class="slider-row">
            <span class="slider-label">醇厚感</span>
            <input id="body" type="range" min="0" max="10" value="6">
            <span class="slider-value" data-for="body">6</span>
          </div>
          <div class="slider-row">
            <span class="slider-label">酒精存在感</span>
            <input id="booziness" type="range" min="0" max="10" value="5">
            <span class="slider-value" data-for="booziness">5</span>
          </div>
          <div class="slider-row">
            <span class="slider-label">香气强度</span>
            <input id="aromaIntensity" type="range" min="0" max="10" value="7">
            <span class="slider-value" data-for="aromaIntensity">7</span>
          </div>
        </div>

        <div class="section-row" style="flex-direction:column; gap:4px;">
          <span class="label-main" style="font-size:0.8rem;color:#4b5563;">香气方向(可多选)</span>
          <div class="tag-row" id="aromaTags">
            <!-- JS 填充 -->
          </div>
        </div>

        <div class="section-row">
          <label class="field">
            <span class="label-main">制作步骤 / 概念说明</span>
            <textarea id="methodText" placeholder="例如:在摇壶中加入所有液体食材和冰块,猛烈摇晃10秒,滤入预先冰镇好的杯中,最后喷洒佛手柑皮油并以一枝迷迭香做装饰……"></textarea>
          </label>
        </div>

        <div class="divider"></div>

        <div class="footer-actions">
          <div class="hint" style="flex:1 1 auto;max-width:360px;">
            提示:点击「生成配方图片」,会在下方自动绘制一张<span style="font-weight:500;">配方卡海报</span>,可直接下载分享。
          </div>
          <button type="button" id="generateBtn">
            <span class="btn-icon">📐</span> 生成配方图片
          </button>
        </div>

        <p id="errorMsg" class="error" style="display:none;"></p>

        <div class="canvas-card" id="canvasContainer" style="display:none;">
          <canvas id="recipeCanvas" width="900" height="1600"></canvas>
          <div class="canvas-footer">
            <span>配方卡预览(适合手机全屏查看或发朋友圈)</span>
            <a id="downloadLink" href="#" download="coffee-recipe.png">⬇ 下载配方图片</a>
          </div>
        </div>
      </section>
    </div>
  </div>

  <script>
    // —— 食材库定义 —— //
    const ingredientLibrary = {
      coffee: {
        label: "咖啡基底",
        items: [
          "意式浓缩 30ml",
          "双倍浓缩 40ml",
          "意式浓缩 Ristretto 20ml",
          "冷萃咖啡浓缩 40ml",
          "手冲滤泡浓缩 60ml",
          "挂耳咖啡浓缩 80ml",
          "摩卡壶咖啡 45ml",
          "冷萃原液 80ml"
        ]
      },
      spirit: {
        label: "烈酒 / 利口酒",
        items: [
          "波本威士忌 30ml",
          "黑麦威士忌 30ml",
          "朗姆酒(白)30ml",
          "朗姆酒(金)30ml",
          "陈年朗姆酒 30ml",
          "金酒 30ml",
          "龙舌兰 Blanco 30ml",
          "龙舌兰 Reposado 30ml",
          "干邑白兰地 30ml",
          "葡萄渣白兰地 30ml",
          "咖啡利口酒 20ml",
          "橙味利口酒 15ml",
          "榛子利口酒 15ml",
          "香草利口酒 15ml",
          "草本利口酒 15ml",
          "阿玛罗苦味酒 15ml"
        ]
      },
      syrup: {
        label: "糖浆 / 甜味",
        items: [
          "1:1 简单糖浆 10ml",
          "2:1 浓缩糖浆 10ml",
          "蜂蜜糖浆 10ml",
          "枫糖浆 10ml",
          "红糖糖浆 10ml",
          "焦糖糖浆 10ml",
          "榛子糖浆 10ml",
          "香草糖浆 10ml",
          "椰子糖浆 10ml",
          "水果风味糖浆 10ml"
        ]
      },
      acid: {
        label: "酸度 / 果汁",
        items: [
          "新鲜柠檬汁 10ml",
          "新鲜青柠汁 10ml",
          "西柚汁 20ml",
          "橙汁 20ml",
          "苹果汁 20ml",
          "菠萝汁 20ml",
          "莓果泥 20ml",
          "苹果醋 3ml",
          "柠檬酸溶液 3ml"
        ]
      },
      dairy: {
        label: "乳制品 / 植物奶",
        items: [
          "全脂牛奶 80ml",
          "鲜奶油 30ml",
          "半奶油 40ml",
          "炼乳 20ml",
          "燕麦奶 80ml",
          "杏仁奶 80ml",
          "豆奶 80ml",
          "椰奶 60ml",
          "椰子奶油 20ml"
        ]
      },
      tea: {
        label: "茶 / 浸泡液",
        items: [
          "茉莉花茶 60ml",
          "伯爵茶 60ml",
          "红茶浓缩 40ml",
          "乌龙茶 60ml",
          "抹茶溶液 25ml",
          "洋甘菊茶 60ml",
          "花果茶 60ml"
        ]
      },
      spice: {
        label: "香料 / 草本",
        items: [
          "肉桂棒 1根",
          "八角 1枚",
          "丁香 2颗",
          "肉豆蔻粉 少许",
          "小豆蔻荚 2颗",
          "黑胡椒碎 少许",
          "花椒油 1滴",
          "新鲜薄荷 1枝",
          "迷迭香 1枝",
          "百里香 1枝",
          "罗勒叶 几片",
          "佛手柑皮屑 少许",
          "柠檬皮油 1喷"
        ]
      },
      modifier: {
        label: "结构 / 质地",
        items: [
          "蛋清 25ml",
          "Aquafaba 鹰嘴豆水 25ml",
          "盐水溶液 2滴",
          "苦精 2dash",
          "巧克力苦精 2dash",
          "橙皮苦精 2dash",
          "苏打水 40ml",
          "汤力水 60ml",
          "气泡水 60ml"
        ]
      },
      garnish: {
        label: "装饰 / 表层",
        items: [
          "咖啡粉 拉花",
          "鲜奶油 顶层",
          "柠檬皮 扭香",
          "橙皮 扭香",
          "巧克力屑 少许",
          "可可粉 少许",
          "肉桂粉 少许",
          "坚果碎 少许",
          "干花瓣 少许",
          "干果片 一片"
        ]
      }
    };

    const aromaOptions = [
      "水果 / 果酱感",
      "柑橘 / 皮油",
      "热带水果",
      "花香 / 白花",
      "坚果 / 榛子 / 杏仁",
      "可可 / 黑巧克力",
      "焦糖 / 太妃糖",
      "烘焙 / 烟熏 / 木质",
      "香料(肉桂、豆蔻等)",
      "草本 / 药草 / 苦味酒",
      "发酵感 / 葡萄酒感",
      "奶油 / 乳制品",
      "植物奶 / 谷物香",
      "其他个性风味"
    ];

    // —— 初始化 —— //
    document.addEventListener("DOMContentLoaded", () => {
      const tbody = document.getElementById("ingredientTableBody");
      const addBtn = document.getElementById("addIngredientBtn");
      const aromaTagContainer = document.getElementById("aromaTags");

      // 初始食材行(咖啡、烈酒、糖浆、酸度、装饰)
      addIngredientRow("coffee", "意式浓缩 30ml");
      addIngredientRow("spirit", "波本威士忌 30ml");
      addIngredientRow("syrup", "1:1 简单糖浆 10ml");
      addIngredientRow("acid", "新鲜柠檬汁 10ml");
      addIngredientRow("garnish", "柠檬皮 扭香");

      addBtn.addEventListener("click", () => addIngredientRow());

      // 香气方向标签
      aromaOptions.forEach(text => {
        const tag = document.createElement("div");
        tag.className = "tag";
        tag.textContent = text;
        tag.addEventListener("click", () => {
          tag.classList.toggle("active");
        });
        aromaTagContainer.appendChild(tag);
      });

      // slider 数值显示
      document.querySelectorAll('input[type="range"]').forEach(slider => {
        const valueSpan = document.querySelector(`.slider-value[data-for="${slider.id}"]`);
        if (valueSpan) {
          valueSpan.textContent = slider.value;
          slider.addEventListener("input", () => {
            valueSpan.textContent = slider.value;
          });
        }
      });

      // 生成配方图片
      document.getElementById("generateBtn").addEventListener("click", () => {
        const data = collectRecipeData();
        if (!data) return;
        drawRecipeCard(data);
      });
    });

    // —— 食材行相关 —— //
    function addIngredientRow(defaultCategoryKey, defaultIngredientText) {
      const tbody = document.getElementById("ingredientTableBody");
      const tr = document.createElement("tr");

      // 类别
      const tdCategory = document.createElement("td");
      const categorySelect = document.createElement("select");
      categorySelect.className = "category-select";
      Object.keys(ingredientLibrary).forEach(key => {
        const opt = document.createElement("option");
        opt.value = key;
        opt.textContent = ingredientLibrary[key].label;
        categorySelect.appendChild(opt);
      });
      const optOther = document.createElement("option");
      optOther.value = "other";
      optOther.textContent = "自定义类别";
      categorySelect.appendChild(optOther);

      if (defaultCategoryKey && ingredientLibrary[defaultCategoryKey]) {
        categorySelect.value = defaultCategoryKey;
      }

      tdCategory.appendChild(categorySelect);
      tr.appendChild(tdCategory);

      // 食材 select + 自定义
      const tdIngredient = document.createElement("td");
      const ingredientSelect = document.createElement("select");
      ingredientSelect.className = "ingredient-select";
      const customInput = document.createElement("input");
      customInput.type = "text";
      customInput.className = "custom-ingredient";
      customInput.placeholder = "自定义食材名称与基础量,例如:桂花糖浆 10ml";
      customInput.style.display = "none";

      tdIngredient.appendChild(ingredientSelect);
      tdIngredient.appendChild(customInput);
      tr.appendChild(tdIngredient);

      // 用量
      const tdAmount = document.createElement("td");
      const amountInput = document.createElement("input");
      amountInput.type = "number";
      amountInput.min = "0";
      amountInput.step = "0.5";
      amountInput.className = "small-input";
      amountInput.placeholder = "数字";
      tdAmount.appendChild(amountInput);
      tr.appendChild(tdAmount);

      // 单位
      const tdUnit = document.createElement("td");
      const unitSelect = document.createElement("select");
      unitSelect.className = "unit-select";
      ["ml", "g", "shot", "oz", "dash", "tsp", "片", "枝", "个", "少许"].forEach(u => {
        const opt = document.createElement("option");
        opt.value = u;
        opt.textContent = u;
        unitSelect.appendChild(opt);
      });
      tdUnit.appendChild(unitSelect);
      tr.appendChild(tdUnit);

      // 备注
      const tdNote = document.createElement("td");
      const noteInput = document.createElement("input");
      noteInput.type = "text";
      noteInput.placeholder = "例如:奶洗、预浸、油洗、事先冷冻、喷洒表层等";
      tdNote.appendChild(noteInput);
      tr.appendChild(tdNote);

      // 操作
      const tdAction = document.createElement("td");
      const removeBtn = document.createElement("button");
      removeBtn.type = "button";
      removeBtn.className = "ghost small";
      removeBtn.innerHTML = '<span class="btn-icon">✕</span>';
      removeBtn.addEventListener("click", () => {
        const tbodyNow = document.getElementById("ingredientTableBody");
        if (tbodyNow.children.length > 1) {
          tr.remove();
        } else {
          alert("至少保留一行食材。");
        }
      });
      tdAction.appendChild(removeBtn);
      tr.appendChild(tdAction);

      tbody.appendChild(tr);

      // 根据类别填充食材选项
      function refreshIngredientOptions() {
        const cat = categorySelect.value;
        ingredientSelect.innerHTML = "";
        const placeholderOpt = document.createElement("option");
        placeholderOpt.value = "";
        placeholderOpt.textContent = "选择常用食材 / 自定义";
        ingredientSelect.appendChild(placeholderOpt);

        if (ingredientLibrary[cat]) {
          ingredientLibrary[cat].items.forEach(text => {
            const opt = document.createElement("option");
            opt.value = text;
            opt.textContent = text;
            ingredientSelect.appendChild(opt);
          });
        }

        const customOpt = document.createElement("option");
        customOpt.value = "__custom__";
        customOpt.textContent = "✎ 自定义食材…";
        ingredientSelect.appendChild(customOpt);

        customInput.style.display = "none";
      }

      categorySelect.addEventListener("change", () => {
        refreshIngredientOptions();
      });

      ingredientSelect.addEventListener("change", () => {
        if (ingredientSelect.value === "__custom__") {
          customInput.style.display = "block";
          customInput.focus();
        } else {
          customInput.style.display = "none";
        }
      });

      // 初始化一次
      refreshIngredientOptions();

      if (defaultIngredientText) {
        // 尝试选中默认食材
        let found = false;
        Array.from(ingredientSelect.options).forEach(opt => {
          if (opt.value === defaultIngredientText) {
            ingredientSelect.value = defaultIngredientText;
            found = true;
          }
        });
        if (!found) {
          ingredientSelect.value = "__custom__";
          customInput.style.display = "block";
          customInput.value = defaultIngredientText;
        }
      }
    }

    // —— 收集配方数据 —— //
    function collectRecipeData() {
      const name = (document.getElementById("drinkName").value || "").trim();
      const serveTemp = document.getElementById("serveTemp").value;
      const coffeeBase = document.getElementById("coffeeBase").value;
      const spiritStyle = document.getElementById("spiritStyle").value;
      const method = document.getElementById("method").value;
      const glassware = document.getElementById("glassware").value;
      const iceNote = (document.getElementById("iceNote").value || "").trim();
      const flavorTagline = (document.getElementById("flavorTagline").value || "").trim();
      const methodText = (document.getElementById("methodText").value || "").trim();

      const errorMsg = document.getElementById("errorMsg");
      errorMsg.style.display = "none";
      errorMsg.textContent = "";

      if (!name) {
        errorMsg.textContent = "请先给这杯特调起一个名字。";
        errorMsg.style.display = "block";
        return null;
      }

      // 食材
      const ingredients = [];
      const tbody = document.getElementById("ingredientTableBody");
      Array.from(tbody.children).forEach(tr => {
        const tds = tr.querySelectorAll("td");
        if (tds.length < 6) return;
        const catSelect = tds[0].querySelector("select");
        const ingSelect = tds[1].querySelector("select");
        const customInput = tds[1].querySelector("input");
        const amountInput = tds[2].querySelector("input");
        const unitSelect = tds[3].querySelector("select");
        const noteInput = tds[4].querySelector("input");

        let ingredientText = "";
        if (ingSelect.value === "__custom__" || !ingSelect.value) {
          ingredientText = (customInput.value || "").trim();
        } else {
          ingredientText = ingSelect.value;
        }
        const amount = amountInput.value;
        const unit = unitSelect.value;
        const note = (noteInput.value || "").trim();

        if (!ingredientText) return; // 空行跳过

        ingredients.push({
          category: catSelect.value,
          ingredient: ingredientText,
          amount: amount,
          unit: unit,
          note: note
        });
      });

      if (ingredients.length === 0) {
        errorMsg.textContent = "请至少填写一种食材。";
        errorMsg.style.display = "block";
        return null;
      }

      // 香气方向
      const aromaTags = [];
      document.querySelectorAll("#aromaTags .tag.active").forEach(tag => {
        aromaTags.push(tag.textContent.trim());
      });

      const sliders = {
        sweetness: Number(document.getElementById("sweetness").value),
        acidity: Number(document.getElementById("acidity").value),
        bitterness: Number(document.getElementById("bitterness").value),
        body: Number(document.getElementById("body").value),
        booziness: Number(document.getElementById("booziness").value),
        aromaIntensity: Number(document.getElementById("aromaIntensity").value)
      };

      return {
        name,
        serveTemp,
        coffeeBase,
        spiritStyle,
        method,
        glassware,
        iceNote,
        flavorTagline,
        methodText,
        ingredients,
        aromaTags,
        sliders
      };
    }

    // —— 绘制配方卡 —— //
    function drawRecipeCard(data) {
      const canvas = document.getElementById("recipeCanvas");
      const ctx = canvas.getContext("2d");

      const W = canvas.width;
      const H = canvas.height;

      // 背景
      ctx.clearRect(0, 0, W, H);
      const bgGradient = ctx.createLinearGradient(0, 0, 0, H);
      bgGradient.addColorStop(0, "#f9f4ec");
      bgGradient.addColorStop(0.6, "#f2ece3");
      bgGradient.addColorStop(1, "#e7dfd4");
      ctx.fillStyle = bgGradient;
      ctx.fillRect(0, 0, W, H);

      const marginX = 70;
      let y = 120;

      // 头部小标签
      ctx.fillStyle = "rgba(122,92,68,0.12)";
      roundRect(ctx, marginX - 6, y - 34, 170, 26, 13, true, false);
      ctx.fillStyle = "#7a5c44";
      ctx.font = "14px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.textBaseline = "middle";
      ctx.fillText("COFFEE × SPIRITS 特调配方", marginX + 8, y - 21);

      // 标题
      ctx.fillStyle = "#1f2937";
      ctx.font = "bold 34px 'SF Pro Display', -apple-system, system-ui, sans-serif";
      wrapText(ctx, data.name, marginX, y, W - marginX * 2, 40);
      y += 60;

      // 副标题
      ctx.font = "16px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#6b7280";
      const subtitle = `${data.serveTemp} · ${data.coffeeBase} · ${data.spiritStyle}`;
      wrapText(ctx, subtitle, marginX, y, W - marginX * 2, 22);
      y += 40;

      // 分割线
      ctx.strokeStyle = "rgba(148,163,184,0.6)";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(marginX, y);
      ctx.lineTo(W - marginX, y);
      ctx.stroke();
      y += 30;

      // 左侧块:食材
      const leftX = marginX;
      const rightX = marginX + 360;

      // 食材标题
      ctx.fillStyle = "#111827";
      ctx.font = "bold 18px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillText("配方结构 / INGREDIENTS", leftX, y);

      ctx.font = "13px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#6b7280";
      ctx.fillText("所有用量仅为建议,可按容量/口感微调。", leftX, y + 20);

      y += 40;
      ctx.font = "14px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#374151";

      const ingredientsMaxWidth = 360;
      data.ingredients.forEach((ing, index) => {
        let lineText;
        if (ing.amount) {
          lineText = `• ${ing.ingredient}  —  ${ing.amount}${ing.unit}`;
        } else {
          lineText = `• ${ing.ingredient}`;
        }
        if (ing.note) {
          lineText += `(${ing.note})`;
        }
        wrapText(ctx, lineText, leftX, y, ingredientsMaxWidth, 22);
        y += 22;
      });

      // 右侧块:风味雷达条
      const flavorTopY = 230;
      ctx.fillStyle = "#111827";
      ctx.font = "bold 18px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillText("风味构架 / FLAVOR MAP", rightX, flavorTopY);

      let fy = flavorTopY + 28;
      ctx.font = "13px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#6b7280";
      ctx.fillText("0–10 分刻度,仅作结构参考:", rightX, fy);
      fy += 26;

      const sliderLabels = [
        ["甜度", data.sliders.sweetness],
        ["酸度", data.sliders.acidity],
        ["苦度", data.sliders.bitterness],
        ["醇厚", data.sliders.body],
        ["酒精感", data.sliders.booziness],
        ["香气强度", data.sliders.aromaIntensity]
      ];

      sliderLabels.forEach(([label, value]) => {
        drawFlavorBar(ctx, rightX, fy, label, value, 10);
        fy += 30;
      });

      fy += 14;
      ctx.font = "12px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#6b7280";
      ctx.fillText("方向标签:", rightX, fy);
      fy += 22;
      ctx.font = "12px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#4b5563";
      const aromaText = data.aromaTags.length
        ? data.aromaTags.join(" · ")
        : "自由发挥(未指定香气方向)";
      wrapText(ctx, aromaText, rightX, fy, W - rightX - marginX, 18);
      fy += 50;

      // 中部:一句话风味描述
      let midY = Math.max(y + 20, fy);
      ctx.strokeStyle = "rgba(148,163,184,0.45)";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(marginX, midY);
      ctx.lineTo(W - marginX, midY);
      ctx.stroke();
      midY += 26;

      ctx.fillStyle = "#111827";
      ctx.font = "bold 18px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillText("风味印象 / TASTING NOTE", marginX, midY);
      midY += 28;

      ctx.font = "14px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#374151";
      const tastingText = data.flavorTagline || "可以在这里写下你希望客人记住的那一句风味话。";
      wrapText(ctx, tastingText, marginX, midY, W - marginX * 2, 22);
      midY += 80;

      // 制作步骤
      ctx.fillStyle = "#111827";
      ctx.font = "bold 18px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillText("制作方法 / METHOD", marginX, midY);
      midY += 26;
      ctx.font = "14px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#374151";
      const methodText = data.methodText || "在此写明制作步骤:先做什么,再做什么,摇多长时间、搅拌几圈、是否需要预冷杯子等。";
      wrapText(ctx, methodText, marginX, midY, W - marginX * 2, 22);
      midY += 120;

      // 底部信息
      const bottomY = H - 120;
      ctx.strokeStyle = "rgba(148,163,184,0.5)";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(marginX, bottomY - 30);
      ctx.lineTo(W - marginX, bottomY - 30);
      ctx.stroke();

      ctx.font = "12px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#6b7280";
      const bottomLeftText = `${data.glassware} · ${data.method}${data.iceNote ? " · " + data.iceNote : ""}`;
      ctx.fillText(bottomLeftText, marginX, bottomY - 10);

      const dateStr = new Date().toLocaleDateString("zh-CN");
      const bottomRight = `Coffee Recipe Lab · ${dateStr}`;
      const textWidth = ctx.measureText(bottomRight).width;
      ctx.fillText(bottomRight, W - marginX - textWidth, bottomY - 10);

      // 显示容器 + 下载链接
      const container = document.getElementById("canvasContainer");
      container.style.display = "block";
      const dl = document.getElementById("downloadLink");
      const safeName = data.name.replace(/[\\\/\?\*\:\|\<\>\"']/g, "_").slice(0, 40) || "coffee-recipe";
      dl.download = safeName + ".png";
      dl.href = canvas.toDataURL("image/png");
    }

    // —— 辅助函数 —— //
    function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
      if (!text) return;
      const chars = text.split("");
      let line = "";

      for (let n = 0; n < chars.length; n++) {
        const testLine = line + chars[n];
        const metrics = ctx.measureText(testLine);
        const testWidth = metrics.width;
        if (testWidth > maxWidth && n > 0) {
          ctx.fillText(line, x, y);
          line = chars[n];
          y += lineHeight;
        } else {
          line = testLine;
        }
      }
      if (line) {
        ctx.fillText(line, x, y);
      }
    }

    function roundRect(ctx, x, y, width, height, radius, fill, stroke) {
      if (typeof stroke === "undefined") {
        stroke = true;
      }
      if (typeof radius === "undefined") {
        radius = 5;
      }
      if (typeof radius === "number") {
        radius = { tl: radius, tr: radius, br: radius, bl: radius };
      } else {
        const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 };
        for (let side in defaultRadius) {
          radius[side] = radius[side] || defaultRadius[side];
        }
      }
      ctx.beginPath();
      ctx.moveTo(x + radius.tl, y);
      ctx.lineTo(x + width - radius.tr, y);
      ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr);
      ctx.lineTo(x + width, y + height - radius.br);
      ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height);
      ctx.lineTo(x + radius.bl, y + height);
      ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl);
      ctx.lineTo(x, y + radius.tl);
      ctx.quadraticCurveTo(x, y, x + radius.tl, y);
      ctx.closePath();
      if (fill) {
        ctx.fill();
      }
      if (stroke) {
        ctx.stroke();
      }
    }

    function drawFlavorBar(ctx, x, y, label, value, maxValue) {
      const barWidth = 260;
      const barHeight = 7;
      const ratio = Math.max(0, Math.min(1, value / maxValue));
      ctx.font = "12px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillStyle = "#4b5563";
      ctx.textBaseline = "middle";
      ctx.fillText(label, x, y + barHeight / 2);
      const barX = x + 52;
      const barY = y;
      // 背景条
      ctx.fillStyle = "rgba(148,163,184,0.22)";
      roundRect(ctx, barX, barY, barWidth, barHeight, 4, true, false);
      // 前景条
      const fgWidth = Math.max(6, barWidth * ratio);
      const gradient = ctx.createLinearGradient(barX, barY, barX + fgWidth, barY);
      gradient.addColorStop(0, "#caa17b");
      gradient.addColorStop(1, "#7a5c44");
      ctx.fillStyle = gradient;
      roundRect(ctx, barX, barY, fgWidth, barHeight, 4, true, false);
      // 数值
      ctx.fillStyle = "#6b7280";
      ctx.font = "11px 'SF Pro Text', -apple-system, system-ui, sans-serif";
      ctx.fillText(value.toFixed(0) + "/10", barX + barWidth + 10, y + barHeight / 2);
    }
  </script>
</body>
</html>

        
编辑器加载中
预览
控制台