現實世界裡,飲料店就像不斷增生的魔物圖鑑:新品、季節款、價格波動從不手軟。手刻一份 menu.json 還好,但多店共管就很痛。
今天把 Day22 的 OCR 召喚術「產品化」:管理者丟圖片 / 貼純文字 → 後端喚起 Google Vision → 解析成結構化 menu.json → 命名成店家並上架;使用者只需 選店 → 選分類 → 選品項 → 選尺寸;最後再用圖表看哪家最紅。
一口氣打通「上傳 → 解析 → 儲存 → 點單 → 分析」的魔法回路。昨天文章參考:Day22:用 Google Vision (OCR) 召喚飲料菜單。
menu.json,尚未串回主系統;需要轉成「多店共管」模式。本篇將 Day22 的 OCR 流程產品化、模組化,納入點單平台,並用 API 與 State 管理打通資料流。
| 角色 | 需求 | 目的 | 功能 | 
|---|---|---|---|
| 管理者 | 上傳圖片/貼文字並解析 | 快速上架/覆蓋菜單 | POST /api/ocr-image、POST /api/ocr-text、POST /api/menus | 
| 使用者 | 選店→選品項→下單 | 低摩擦完成下單 | /menu精靈式選單;/order也可選店 | 
| 管理者 | 看哪家最熱門 | 決策與活動規劃 | 分析頁「店家熱度」圖 | 

| 使用者故事 | 需要的功能/端點 | 主要改動檔案 | 為什麼需要它 | 
|---|---|---|---|
| 管理者要上傳圖片並解析 | POST /api/ocr-image(multipart) | backend/server.js、frontend/src/pages/AdminOCRPage.vue、frontend/src/services/http.js | 讓瀏覽器以 FormData 自動帶 boundary,上傳圖片給後端呼叫 Vision API,回傳 rawText+menu 預覽 | 
| 管理者要貼上純文字解析 | POST /api/ocr-text | 同上 | demo 與除錯用,避免圖片失敗時無從測試;也可貼 OCR 結果重跑解析器 | 
| 管理者要命名並儲存為店家 | POST /api/menus | backend/server.js、backend/menus/index.json | 以「檔案」做店家菜單版本化與落地;索引集中管理店名清單 | 
| 使用者在前端選店 | GET /api/menus取得清單、GET /api/menus/:name取菜單 | frontend/src/pages/MenuWizardPage.vue、frontend/src/stores/menuStore.js | 列表化店名、點選後載入該店菜單並顯示分類與品項 | 
| 點餐時要存下店名/尺寸/價格 | 訂單欄位擴充 | backend/server.js (POST /api/orders)、frontend/src/pages/OrderPage.vue | 分析與對帳需要知道來源店家與價格,保留 drink以相容舊資料 | 
| 看到各店熱度 | 分析彙整 | backend/server.js (/api/analytics/summary)、frontend/src/pages/AnalyticsPage.vue | 將同店杯數聚合,產出「店家熱度」圖表 | 
server.js
POST /api/ocr-image:上傳圖片解析(admin)POST /api/ocr-text:貼文字解析(admin)GET /api/menus:列出已儲存店家GET /api/menus/:name:取得指定店家菜單POST /api/menus:儲存 { name, menu }(admin)parseMenuText:
shop, category, item, size, price(保留 drink 相容)設計取捨:菜單改用「檔案化」而非資料庫,對個人或小團隊部署最輕;若改成 DB,只要把
saveMenuByName/readMenuByName改成 CRUD 即可。
package.json
@google-cloud/vision、multer、dotenv
.env(本機)
GOOGLE_APPLICATION_CREDENTIALS=完整路徑\to\gcp-service-account.json
憑證與 .env 請勿入庫,已在 .gitignore 排除。
路由(src/router/index.js)
/menu(菜單召喚樹)/admin/ocr(OCR 管理,admin)版頭(src/layouts/MainLayout.vue)
Admin 頁(src/pages/AdminOCRPage.vue)
multipart/form-data → /api/ocr-image
/api/ocr-text
/api/menus 儲存重點:用 axios 攔截器偵測
FormData,不要手動設Content-Type,讓瀏覽器自動帶 boundary,multer 才能正確解析。
菜單召喚樹(src/pages/MenuWizardPage.vue)
點餐頁(src/pages/OrderPage.vue)
分析頁(src/pages/AnalyticsPage.vue)
狀態(src/stores/menuStore.js)
drinks: [])與新 OCR 菜單(categories[].items[].name)HTTP(src/services/http.js)
FormData,移除固定 Content-Type,交由瀏覽器自動帶 boundary原文顯示;若需要翻譯,可於 i18n.json 的 drinks 區塊自行補齊。範例:在 day23/backend/i18n.json 補入 drinks 對應(選擇性):
{
  "languages": ["zh-TW", "en-US"],
  "drinks": {
    "茉莉綠茶": { "en-US": "Jasmine Green Tea" },
    "阿薩姆紅茶": { "en-US": "Assam Black Tea" },
    "珍珠奶茶": { "en-US": "Pearl Milk Tea" },
    "1號(四季春珍波椰)": { "en-US": "No.1 Four Seasons with Jelly & Coconut" }
  },
  "sweetness": {
    "去糖": { "en-US": "No Sugar" },
    "微糖": { "en-US": "Light" },
    "半糖": { "en-US": "Half" },
    "少糖": { "en-US": "Less" },
    "全糖": { "en-US": "Full" }
  },
  "ice": {
    "去冰": { "en-US": "No Ice" },
    "微冰": { "en-US": "Light Ice" },
    "少冰": { "en-US": "Less Ice" },
    "正常冰": { "en-US": "Regular Ice" },
    "熱飲": { "en-US": "Hot" }
  }
}
後端:圖片 OCR 端點
app.post('/api/ocr-image', upload.single('file'), async (req, res) => {
  const [result] = await ocrClient.documentTextDetection({ image: { content: req.file.buffer } })
  const text = result.fullTextAnnotation?.text || ''
  const menu = parseMenuText(text)
  res.json({ rawText: text, menu })
})
後端:解析器(雙行/同行、分類偵測、尺寸)
function parseMenuText(text) {
  let clean = (text || '').replace(/[●•·]/g, '').replace(/\u3000/g, ' ').replace(/\s{2,}/g, ' ')
  const lines = clean.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
  const categories = []
  let current = null
  function normCat(s){
    if (/^找\s*好\s*茶/.test(s)) return '找好茶'
    if (/^找\s*奶\s*茶/.test(s)) return '找奶茶(奶精)'
    if (/^找\s*拿\s*鐵/.test(s)) return '找拿鐵(鮮奶)'
    if (/^找\s*新\s*鮮/.test(s)) return '找新鮮(無咖啡因)'
    return null
  }
  const nameOnly = /^[A-Za-z0-9\u4e00-\u9fa5の/\/\(\)\.\-\s]+$/
  const two = /^(\d{2,})\s+(\d{2,})$/
  const one = /^(?:NT\$|\$)?(\d{2,})(?:元)?$/
  const sameLineTwo = /^(.+?)\s+(\d{2,})\s+(\d{2,})$/
  const sameLineOne = /^(.+?)\s+(\d{2,})$/
  for (let i=0;i<lines.length;i++){
    const line = lines[i]
    const cat = normCat(line)
    if (cat){ current = { name: cat, items: [] }; categories.push(current); continue }
    if (!current) continue
    let m = line.match(sameLineTwo)
    if (m){ current.items.push({ id: m[1], name: m[1], sizes:[{name:'M',price:+m[2]},{name:'L',price:+m[3]}]}); continue }
    m = line.match(sameLineOne)
    if (m){ current.items.push({ id: m[1], name: m[1], sizes:[{name:'default',price:+m[2]}]}); continue }
    if (nameOnly.test(line) && !/^M$|^L$/i.test(line)){
      const next = lines[i+1] || ''
      let mm = next.match(two)
      if (mm){ current.items.push({ id: line, name: line, sizes:[{name:'M',price:+mm[1]},{name:'L',price:+mm[2]}]}); i++; continue }
      mm = next.match(one)
      if (mm){ current.items.push({ id: line, name: line, sizes:[{name:'default',price:+mm[1]}]}); i++; continue }
    }
  }
  return { metadata:{ currency:'TWD', locale:'zh-TW', version:new Date().toISOString().slice(0,10) }, categories }
}
前端:FormData 自動 boundary(axios 攔截器)
http.interceptors.request.use((config) => {
  if (config.data instanceof FormData) {
    if (config.headers) delete config.headers['Content-Type']
  } else {
    config.headers = { ...(config.headers || {}), 'Content-Type': 'application/json' }
  }
  return config
})
前端:OCR 菜單展平成 drinks(相容舊格式)
const drinks = computed(() => {
  const d = menu.value.drinks
  if (Array.isArray(d) && d.length) return d
  const cats = menu.value.categories
  if (Array.isArray(cats) && cats.length) {
    const names = []
    for (const c of cats) for (const it of (c.items || [])) names.push(it.name)
    return names
  }
  return []
})
前端:Admin OCR 上傳與解析(節錄)
<input type="file" accept="image/*" @change="onFileChange" />
<button @click="parseImage" :disabled="!hasFile">上傳圖片並解析</button>
// ...
const file = ref(null)
const hasFile = computed(() => !!file.value)
async function parseImage () {
  const fd = new FormData(); fd.append('file', file.value)
  const { data } = await http.post('/api/ocr-image', fd)
  rawText.value = data.rawText; menuJson.value = JSON.stringify(data.menu, null, 2)
}
前端:MenuWizard(選店→分類→品項→尺寸→加入訂單,節錄)
const categories = computed(() => (menuStore.menu?.categories || []).map(c => c.name))
const items = computed(() => {
  const cat = (menuStore.menu?.categories || []).find(c => c.name === selectedCategory.value)
  return cat ? cat.items : []
})
async function addToOrder(){
  const sizeObj = selectedItem.value.sizes.find(s => s.name === selectedSize.value)
  await orderStore.createOrder({
    name:'guest', shop:selectedShop.value, category:selectedCategory.value,
    item:selectedItem.value.name, size:selectedSize.value, price:sizeObj?.price,
    drink:selectedItem.value.name, sweetness:'', ice:''
  })
}
以下為今天新增/改動的三個主要前端頁面(已簡化樣式,只保留核心程式與結構)。
<!-- day23/frontend/src/pages/AdminOCRPage.vue -->
<script setup>
import { ref, computed } from 'vue'
import { http } from '../services/http'
import { useToastStore } from '../stores/toastStore'
const toast = useToastStore()
const rawText = ref('')
const menuJson = ref('')
const name = ref('')
const file = ref(null)
const hasFile = computed(() => !!file.value)
function onFileChange(e){ file.value = e?.target?.files?.[0] || null }
async function parseText(){
  const { data } = await http.post('/api/ocr-text', { text: rawText.value })
  menuJson.value = JSON.stringify(data.menu, null, 2)
}
async function parseImage(){
  if (!file.value) return
  const fd = new FormData(); fd.append('file', file.value)
  const { data } = await http.post('/api/ocr-image', fd)
  rawText.value = data.rawText
  menuJson.value = JSON.stringify(data.menu, null, 2)
}
async function saveMenu(){
  const menu = JSON.parse(menuJson.value || '{}')
  await http.post('/api/menus', { name: name.value, menu })
  toast.success('菜單已儲存')
}
</script>
<template>
  <section>
    <h2>OCR 上傳與命名(admin)</h2>
    <div class="grid2">
      <div>
        <h3>貼上 OCR 原文</h3>
        <textarea v-model="rawText" placeholder="貼上 OCR 純文字"></textarea>
        <button class="btn" @click="parseText" :disabled="!rawText">解析</button>
        <div class="uploader">
          <input type="file" accept="image/*" @change="onFileChange" />
          <button class="btn" @click="parseImage" :disabled="!hasFile">上傳圖片並解析</button>
        </div>
      </div>
      <div>
        <h3>解析結果 menu.json</h3>
        <textarea v-model="menuJson" placeholder="解析結果"></textarea>
        <div class="row">
          <input v-model="name" placeholder="輸入菜單名稱,如 50lan" />
          <button class="btn primary" @click="saveMenu" :disabled="!name || !menuJson">儲存為店家菜單</button>
        </div>
      </div>
    </div>
  </section>
</template>
<!-- day23/frontend/src/pages/MenuWizardPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useMenuStore } from '../stores/menuStore'
import { useOrderStore } from '../stores/orderStore'
import { useToastStore } from '../stores/toastStore'
const menuStore = useMenuStore()
const orderStore = useOrderStore()
const toast = useToastStore()
const step = ref(1)
const selectedShop = ref('')
const selectedCategory = ref('')
const selectedItem = ref(null)
const selectedSize = ref('')
const categories = computed(() => (menuStore.menu?.categories || []).map(c => c.name))
const items = computed(() => {
  const cat = (menuStore.menu?.categories || []).find(c => c.name === selectedCategory.value)
  return cat ? cat.items : []
})
onMounted(menuStore.loadShops)
async function startPick(){ if (selectedShop.value) { await menuStore.loadShopMenu(selectedShop.value); step.value = 2 } }
function pickCategory(n){ selectedCategory.value = n; step.value = 3 }
function pickItem(it){ selectedItem.value = it; step.value = 4 }
function pickSize(sz){ selectedSize.value = sz.name }
async function addToOrder(){
  const sz = selectedItem.value.sizes.find(s => s.name === selectedSize.value)
  await orderStore.createOrder({
    name: 'guest', shop: selectedShop.value, category: selectedCategory.value,
    item: selectedItem.value.name, size: selectedSize.value, price: sz?.price,
    drink: selectedItem.value.name, sweetness: '', ice: ''
  })
  toast.success('已加入訂單')
}
</script>
<template>
  <section>
    <h2>菜單召喚樹</h2>
    <div class="card">
      <select v-model="selectedShop">
        <option value="" disabled>請選擇店家</option>
        <option v-for="s in menuStore.shops" :key="s" :value="s">{{ s }}</option>
      </select>
      <button class="btn" @click="startPick" :disabled="!selectedShop">載入菜單</button>
    </div>
    <div class="card" v-if="step>=2">
      <h3>選擇分類</h3>
      <button class="chip" v-for="c in categories" :key="c" @click="pickCategory(c)">{{ c }}</button>
    </div>
    <div class="card" v-if="step>=3">
      <h3>選擇品項</h3>
      <div class="grid">
        <div class="item" v-for="it in items" :key="it.id" @click="pickItem(it)">
          <div class="name">{{ it.name }}</div>
          <div class="sizes"><span v-for="s in it.sizes" :key="s.name">{{ s.name }}: {{ s.price }}</span></div>
        </div>
      </div>
    </div>
    <div class="card" v-if="step>=4 && selectedItem">
      <h3>選擇尺寸</h3>
      <button class="chip" v-for="s in selectedItem.sizes" :key="s.name" @click="pickSize(s)">{{ s.name }}({{ s.price }})</button>
      <div class="actions"><button class="btn primary" :disabled="!selectedSize" @click="addToOrder">加入訂單</button></div>
    </div>
  </section>
</template>
<!-- day23/frontend/src/pages/OrderPage.vue (節錄) -->
<script setup>
import { onMounted, ref } from 'vue'
import { useOrderStore } from '../stores/orderStore'
import { useMenuStore } from '../stores/menuStore'
import OrderForm from '../components/OrderForm.vue'
import OrderList from '../components/OrderList.vue'
import { useI18nStore } from '../stores/i18nStore'
const orderStore = useOrderStore()
const menuStore = useMenuStore()
const i18nStore = useI18nStore()
const selectedShop = ref('')
onMounted(async () => { await orderStore.loadOrders(); await menuStore.loadShops() })
async function onPickShop(){ if (selectedShop.value) await menuStore.loadShopMenu(selectedShop.value) }
function handleSubmit(payload){
  const withShop = { ...payload, shop: menuStore.currentShop || '', item: payload.drink, category: '', size: '', price: undefined }
  orderStore.createOrder(withShop)
}
</script>
<template>
  <section>
    <div class="shop-picker">
      <label>選擇店家</label>
      <select v-model="selectedShop" @change="onPickShop">
        <option value="" disabled>請選擇</option>
        <option v-for="s in menuStore.shops" :key="s" :value="s">{{ s }}</option>
      </select>
      <span v-if="menuStore.currentShop">目前菜單:{{ menuStore.currentShop }}</span>
    </div>
    <OrderForm
      :drinks="menuStore.drinks.map(d => ({ value: d, label: d }))"
      :sweetness-options="menuStore.sweetnessOptions.map(s => ({ value: s, label: i18nStore.translate('sweetness', s, $i18n.locale) }))"
      :ice-options="menuStore.iceOptions.map(i => ({ value: i, label: i18nStore.translate('ice', i, $i18n.locale) }))"
      :menu-rules="menuStore.rules"
      @submit="handleSubmit"
    />
    <h3>目前已送出的訂單({{ orderStore.orders.length }})</h3>
    <OrderList :orders="orderStore.orders" />
  </section>
</template>
npm i
npm run dev
.env:GOOGLE_APPLICATION_CREDENTIALS=.../gcp-service-account.json
npm i
npm run dev
/admin/ocr:上傳圖片解析、命名儲存/menu 或 /order:選店 → 分類/品項/尺寸 → 送單/analytics:檢視「店家熱度(下單杯數)」



✅以 admin 登入可進入 /admin/ocr
✅上傳圖片或貼文字可得到 rawText + menu.json 預覽
✅輸入店名後可成功儲存 /api/menus
✅/menu 可列出店家 → 載入分類/品項/尺寸 → 加入訂單
✅/order 可選店載入菜單(相容舊資料)
✅分析頁看得到「店家熱度」圖表
這 30 天,我們從「魔法入門」的火花,走到能上線的火把:路由、權限、i18n、轉場、Teleport、Modal/Toast、統計視覺化,最後把 OCR 也刻進系統,讓菜單自己長出來。
還來不及把 CI/CD、資料庫正規化、雲端監控與快取寫進來,但可運作、可維護、可擴充的骨架已經站起來。
如果這趟學習有讓你少繞一點路、少打一點工,那就值了。
如果還有機會,想把它接上真正的資料庫與佈署管線,也許再加上 A/B 測試與觀測性。
走吧,魔法師;在實務專案裡保持好奇與紀律,就是最強的咒語。🌟
明天再看看有沒有機會跟各位分享這幾天學到的東西或心得吧~~!!!
感謝大家這30天的收看~!! 需求至上的點餐系統