iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 29

Day22 : Vue 魔法書進階篇:用 Google Vision (OCR)召喚飲料菜單的自動化魔法!

  • 分享至 

  • xImage
  •  

前言:AI 魔法師的日常需求

在現代的飲料魔法世界裡,秘書(或飲料召喚師)每天都要替團隊點飲料。
但想想看——每次都要手動輸入整份菜單?
茉莉綠茶、波霸奶茶、珍珠紅茶拿鐵…… 一行一行打字,像是老法師在刻符文陣。

我們要的,不是這樣的凡人作業。
我們要的是——自動化召喚魔法!
只要秘書上傳一張菜單圖片(以「5x嵐」為例),
Google Vision 的 AI 魔法就能幫你辨識出菜單品項、價格、分成 JSON 格式,
自動匯入系統,等使用者確認後就能直接使用。

這就是今天要練的咒語:

OCR(Optical Character Recognition) × Vue 前端 × Express 後端 × Google Cloud Vision 魔法 API

今天我們會另起一個專案,因為在測試功能的時候,Roni喜歡會先做一個sample功能的code
沒問題再併回去我們先前的專案當作新feature,但我們不會教git分之merge的用法

並且專注於需求至上的vue功能之旅
所以我們今天需求為主,vue為魔法工具來實踐這個AI上傳menu到後端的功能~!!

明天會把這個簡單的功能整合到我們先前的訂餐系統裡!!!
讓我們的菜單上傳更自動化~!! 也解放秘書們的雙手~!!/images/emoticon/emoticon01.gif


第一章:使用者故事與功能對照表

使用者故事(User Story)

「作為一位飲料系統的管理者,我希望只要上傳菜單圖片,就能自動辨識出品項與價格,讓我不再手動輸入菜單。」

使用者需求 背後動機 對應功能 實作方式
上傳飲料菜單圖片 不想重打菜單 前端上傳功能 Vue + <input type="file">
自動辨識菜單內容 讓 AI 幫我看圖變文字 後端呼叫 Google Cloud Vision API @google-cloud/vision
整理成結構化資料 讓資料可以進系統使用 自動格式化成 JSON Node.js + 正規表達式處理
使用者確認並儲存 確認辨識無誤後保存 存成 menu.json Express 寫檔系統
簡單、直覺 減少複雜設定 雙端本地開發架構 Vite + Express 本機串接

第二章:系統設計與魔法流動(Flow)

系統設計 Flow

https://ithelp.ithome.com.tw/upload/images/20251013/20121052PMKvyCU9f0.png

魔法能量從前端「上傳圖片」開始,經過後端召喚 Google Vision 的咒語,
AI 幫我們從圖片中讀出所有字,回傳給 Node.js 後端。
然後再用正規表達式符文(Regular Expression)整理成結構化的菜單清單,
最後使用者確認後,就能一鍵儲存成靜態的 menu.json


🧙‍ 前往魔法塔:Google Cloud Vision 設定流程

https://ithelp.ithome.com.tw/upload/images/20251013/20121052rDnM5bWGhz.png

  1. 前往 Google Cloud Console
    建立專案,例如 menu-ocr-demo

  2. 啟用 Vision API:
    👉 點這裡快速開啟 Vision API

    https://ithelp.ithome.com.tw/upload/images/20251013/201210521TVrdcfnWz.png

  3. 建立 Service Account:

    • 名稱:vision-ocr-service
    • 角色:Vision API 使用者專案編輯者
  4. 建立金鑰(JSON):

    • 點「新增金鑰」→ 選「JSON」→ 下載
    • 放到專案根目錄

https://ithelp.ithome.com.tw/upload/images/20251013/2012105237su2j0Jpy.png
5. .env 設定:

GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json
PORT=8080
  1. 開通計費(必要)
    Google Vision 即使有免費額度,也必須啟用計費帳號才能使用。

    注意如果你沒有開啟,你會收到api failed

OCR 失敗:7 PERMISSION_DENIED: This API method requires billing to be enabled. Please enable billing on project #你的專案ID by visiting https://console.developers.google.com/billing/enable?project=專案ID then retry. If you enabled billing for this project recently, wait a few minutes for the action to propagate to our systems and retry.

修正步驟:啟用計費

1. 打開錯誤訊息裡的連結

在瀏覽器開啟這個網址(或點錯誤裡給的連結):
👉 https://console.developers.google.com/billing/enable?project=你的專案ID

它會自動帶你進入「為專案啟用計費」的畫面。

2. 建立或連結一個計費帳號

如果你從沒建立過:點 「建立帳單帳戶」(Create billing account)。

按照步驟填信用卡資料(只要為了認證身份,不會立即收費)。

Google 會提供一筆 $300 美元試用金(新帳號限定,12 個月有效。 根據現實狀況而定

⚠️ 你只要不超過免費額度或試用金額,就完全不會被扣款。

3. 確認專案與計費帳號綁定成功

回到:
👉 https://console.cloud.google.com/projectselector2/billing

檢查你的專案(例如 menu-ocr-demo 或該編號 30848556063)旁邊,應該顯示:

✅ 已連結至 [你的計費帳號名稱]


npm 套件安裝清單

專案夾 套件 用途說明
menu-ocr-server express Node.js 後端框架
multer 接收前端上傳的圖片
@google-cloud/vision 呼叫 Google Vision API 的官方 SDK
cors 開啟跨域讓前端能連到後端
dotenv 讀取 .env 環境設定
menu-ocr-frontend vue 建立使用者介面
vite 開發伺服器與建置工具
@vitejs/plugin-vue 讓 Vite 支援 Vue 單檔組件

第三章:程式實作與魔法符文解析

前端 Vue 程式(App.vue

使用者可以上傳圖片,後端會回傳 OCR 結果與自動生成的 menu.json
按「確認清單正確」即可儲存到後端。

今天我們前端的重點是建立一個「上傳圖片 → 呼叫後端 → 顯示辨識結果 → 儲存 JSON」的完整流程。
整個介面只用了一個單檔組件(App.vue),但其實每個 function 都是有它的魔法邏輯與對應需求的。

關鍵魔法:

const res = await fetch('/api/ocr', { method:'POST', body: fd })
const data = await res.json()
menuJson.value = JSON.stringify(data.menu, null, 2)

📘 意思是:把上傳的圖片交給 /api/ocr
後端會召喚 Vision 魔法陣幫你把圖片變成文字+結構化資料。


一、整體功能對應表

功能 Vue 對應的 function 使用的變數 (ref) 功能說明
上傳菜單圖片 onPick file 綁定 <input type="file">,取得使用者選擇的圖片檔案
送出 OCR 請求 run file, loading, rawText, menuJson 建立 FormData,上傳圖片給後端 /api/ocr,取得辨識文字與轉換後的 JSON
儲存辨識後的菜單 saveMenu menuJson, loading 將使用者確認過的 menu.json 送回後端 /api/menu/save 寫入本地檔案
載入現有菜單 loadSaved menuJson 從後端 /api/menu 取得目前儲存的菜單 JSON,用於預覽或覆寫
狀態管理 無(reactive 狀態) file, loading, rawText, menuJson 控制按鈕啟用/停用、顯示進度、呈現結果

二、程式講解與魔法意圖分析

1️⃣ 上傳圖片魔法 — onPick(e)

function onPick(e) { 
  file.value = e.target.files[0] 
}

這是最基本的 「素材召喚咒語」
<input type="file"> 會傳出一個 FileList,這裡取第一個檔案存在 fileref(null))裡。


2️⃣ 呼叫 OCR 魔法陣 — run()

async function run(){
  if(!file.value) return;
  loading.value = true;
  try{
    const fd = new FormData();
    fd.append('file', file.value);
    const res = await fetch('/api/ocr', { method:'POST', body: fd });
    const data = await res.json();
    rawText.value = data.rawText || '';
    menuJson.value = JSON.stringify(data.menu, null, 2);
  }catch(e){
    alert('OCR 失敗:' + e.message);
  }finally{
    loading.value = false;
  }
}

這是最核心的召喚函式。

  • 先建立一個 FormData,把使用者選的 file 傳給後端。

  • 後端(Express)呼叫 Google Vision API 進行文字辨識。

  • 回傳結果中會有兩部分:

    • rawText:Google OCR 回傳的純文字原文。
    • menu:經過正規表達式清理後的結構化資料。

這裡我們把 menu 轉成 JSON 文字格式,用 JSON.stringify(..., null, 2) 美化縮排。
Vue 的 ref 自動讓 textarea 即時更新,讓使用者看到辨識結果。

為什麼要用 async/await?
因為這是非同步請求。若直接用 Promise 會讓程式可讀性差;
await 則像是在施放同步咒語,讓流程更自然、可控。


3️⃣ 儲存辨識結果 — saveMenu()

async function saveMenu(){
  try{
    const payload = JSON.parse(menuJson.value || '{}');
    const res = await fetch('/api/menu/save', {
      method:'POST',
      headers:{ 'Content-Type':'application/json' },
      body: JSON.stringify(payload)
    });
    const data = await res.json();
    alert('已儲存到後端 menu.json!');
  }catch(e){
    alert('儲存失敗:' + e.message);
  }
}

這一段對應到「使用者確認 → 寫入 menu.json」。

  • menuJson 是字串,需要先轉成物件(JSON.parse)。
  • 然後透過 fetch POST 給後端 /api/menu/save
  • 後端會直接把資料寫入本地檔案系統(fs.writeFileSync)。

為什麼用 /api/menu/save 而不是 /api/ocr
因為我們要區分「辨識行為」與「儲存行為」,
這樣可避免誤覆蓋前一次的 menu.json,也方便後續擴充版本管理。


4️⃣ 載入現有菜單 — loadSaved()

async function loadSaved(){
  try{
    const res = await fetch('/api/menu');
    const data = await res.json();
    menuJson.value = JSON.stringify(data, null, 2);
  }catch(e){
    alert('讀取失敗:' + e.message);
  }
}

當系統已經有現成的 menu.json 時,
按下這個按鈕就能讓後端把目前檔案內容送回來,
使用者可以直接在前端預覽或覆寫更新。


5️⃣ 狀態控制變數(Vue ref

變數名稱 初始值 用途說明
file null 使用者上傳的圖片檔案
loading false 控制按鈕狀態與顯示載入中
rawText '' 後端回傳的原始 OCR 文本(可在 textarea 顯示)
menuJson '' 經過後端整理的 JSON 結構(字串形式)

🧠 總結:Vue 前端就是魔法界的「召喚陣」

前端的每一個 ref 都像是咒語能量池,
Vue 的反應式系統確保畫面與狀態自動同步,
使用者只要按下「上傳」或「儲存」,
就能驅動背後整套 Google Vision + Node.js 的魔法流程。

換句話說:Vue 是魔法陣的召喚師,
Google Vision 是辨識的預言水晶球,
Express 是連接兩者的傳送門。

完整前端程式碼如下

<template>
  <main style="padding:24px; font-family:system-ui, -apple-system, Segoe UI, Roboto, Noto Sans TC, sans-serif;">
    <h1>Menu OCR</h1>

    <section style="margin:12px 0; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
      <input type="file" accept="image/*,application/pdf" @change="onPick" />
      <button :disabled="!file || loading" @click="run">上傳並 OCR</button>
      <button :disabled="loading" @click="loadSaved">載入後端 menu.json</button>
      <span v-if="file" style="opacity:.7">{{ file.name }}</span>
    </section>

    <section class="card">
      <h3>OCR 原文</h3>
      <textarea :value="rawText" readonly rows="12"></textarea>
    </section>

    <section class="card">
      <h3>menu.json</h3>
      <textarea v-model="menuJson" rows="16"></textarea>
      <div style="margin-top:8px; display:flex; gap:8px;">
        <button :disabled="!menuJson || loading" @click="saveMenu">確認清單正確,存到後端</button>
      </div>
    </section>
  </main>
</template>

<script setup>
import { ref } from 'vue';

const file = ref(null);
const loading = ref(false);
const rawText = ref('');
const menuJson = ref('');

function onPick(e){ file.value = e.target.files[0] }

async function run(){
  if(!file.value) return;
  loading.value = true;
  try{
    const fd = new FormData();
    fd.append('file', file.value);
    const res = await fetch('/api/ocr', { method:'POST', body: fd });
    const data = await res.json();
    if(!res.ok || data.error) throw new Error(data.error || 'OCR failed');
    rawText.value = data.rawText || '';
    menuJson.value = JSON.stringify(data.menu, null, 2);
  }catch(e){
    alert('OCR 失敗:' + e.message);
  }finally{
    loading.value = false;
  }
}

async function saveMenu(){
  try{
    const payload = JSON.parse(menuJson.value || '{}');
    const res = await fetch('/api/menu/save', {
      method:'POST',
      headers:{ 'Content-Type':'application/json' },
      body: JSON.stringify(payload)
    });
    const data = await res.json();
    if(!res.ok || data.error) throw new Error(data.error || 'Save failed');
    alert('已儲存到後端 menu.json!');
  }catch(e){
    alert('儲存失敗:' + e.message);
  }
}

async function loadSaved(){
  try{
    const res = await fetch('/api/menu');
    const data = await res.json();
    menuJson.value = JSON.stringify(data, null, 2);
  }catch(e){
    alert('讀取失敗:' + e.message);
  }
}
</script>

<style>
.card { border:1px solid #e5e7eb; border-radius:12px; padding:12px; margin:12px 0; }
textarea { width:100%; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
button { padding:8px 12px; border-radius:8px; border:1px solid #d1d5db; background:#fff; cursor:pointer; }
button:disabled { opacity:.6; cursor:not-allowed; }
</style>


後端程式(index.js

主要魔法步驟

  1. multer 接收上傳圖片
  2. 呼叫 vision.ImageAnnotatorClient()documentTextDetection()
  3. 把 Vision 回傳的全文做清理與分類
  4. 用正規表達式解析出「名稱、M、L 價格」
  5. 整理成 JSON 結構後回傳給前端
import express from 'express';
import multer from 'multer';
import vision from '@google-cloud/vision';
import dotenv from 'dotenv';
import cors from 'cors';
import fs from 'node:fs';

dotenv.config();

const app = express();
const upload = multer({ storage: multer.memoryStorage() });
const client = new vision.ImageAnnotatorClient();

app.use(cors({ origin: true }));
app.use(express.json({ limit: '5mb' }));

// 1) OCR:接收圖片/PDF buffer -> Vision -> 自動解析 -> 回傳 { rawText, menu }
app.post('/api/ocr', upload.single('file'), async (req, res) => {
  try {
    if (!req.file) return res.status(400).json({ error: 'No file uploaded' });

    const [result] = await client.documentTextDetection({ image: { content: req.file.buffer } });
    const text = result.fullTextAnnotation?.text || '';

    const menu = parseMenuText(text);
    return res.json({ rawText: text, menu });
  } catch (e) {
    console.error(e);
    return res.status(500).json({ error: e.message });
  }
});

// 2) 儲存 menu.json(前端按「確認清單正確」後呼叫)
app.post('/api/menu/save', (req, res) => {
  try {
    const payload = req.body;
    if (!payload || !Array.isArray(payload?.categories)) {
      return res.status(400).json({ error: 'Invalid menu payload' });
    }
    fs.writeFileSync('./menu.json', JSON.stringify(payload, null, 2), 'utf8');
    return res.json({ ok: true });
  } catch (e) {
    console.error(e);
    return res.status(500).json({ error: e.message });
  }
});

// 3) 讀取目前的 menu.json(方便你在前端預載)
app.get('/api/menu', (req, res) => {
  try {
    if (!fs.existsSync('./menu.json')) {
      return res.json({
        metadata: { currency: 'TWD', locale: 'zh-TW', version: new Date().toISOString().slice(0, 10) },
        categories: []
      });
    }
    const data = JSON.parse(fs.readFileSync('./menu.json', 'utf8'));
    return res.json(data);
  } catch (e) {
    console.error(e);
    return res.status(500).json({ error: e.message });
  }
});

const port = process.env.PORT || 8080;
app.listen(port, () => console.log(`Server listening on http://localhost:${port}`));

/* -------------------------- 解析器:自動擷取 -------------------------- */
function parseMenuText(text) {
  // 前處理:去符號、全形空白、把分類打上標記
  let clean = text
    .replace(/[●•·]/g, '')
    .replace(/\u3000/g, ' ')
    .replace(/\s{2,}/g, ' ')
    .replace(/找\s*好\s*茶/g, '###找好茶')
    .replace(/找\s*奶\s*茶.*?/g, '###找奶茶(奶精)') // 「(奶精)」常被 OCR 斷開
    .replace(/找\s*拿\s*鐵.*?/g, '###找拿鐵(鮮奶)')
    .replace(/找\s*新\s*鮮.*?/g, '###找新鮮(無咖啡因)');

  // 有些行會長成「葡萄柚多多(….)50 70」=> 在價格前補空白
  clean = clean.replace(/(\D)(\d{2,}\s+\d{2,})/g, '$1 $2');

  const lines = clean
    .split(/\r?\n/)
    .map(s => s.trim())
    .filter(Boolean)
    // 過濾只有 M / L 的孤行
    .filter(s => !/^(M|L)$/.test(s));

  const menu = {
    metadata: { currency: 'TWD', locale: 'zh-TW', version: new Date().toISOString().slice(0, 10) },
    categories: []
  };

  let current = null;

  // 規則:
  // 1) 兩價格(多半是 M/L):  品名  35 50
  // 2) 單價格:               品名  45
  // 名稱允許中英數、斜線、全形日文の、括號註記
  const twoPrice = /^([A-Za-z0-9\u4e00-\u9fa5の/\/\(\)\.\-\s]+?)\s+(\d{2,})\s+(\d{2,})$/;
  const onePrice = /^([A-Za-z0-9\u4e00-\u9fa5の/\/\(\)\.\-\s]+?)\s+(?:NT\$|\$)?(\d{2,})(?:元)?$/;

  for (const raw of lines) {
    const line = raw.replace(/\s+/g, ' ').trim();
    if (line.startsWith('###')) {
      current = { name: line.replace(/^###/, ''), items: [] };
      menu.categories.push(current);
      continue;
    }
    if (!current) continue;

    // 兩價格
    let m = line.match(twoPrice);
    if (m) {
      const name = sanitizeName(m[1]);
      const p1 = Number(m[2]), p2 = Number(m[3]);
      current.items.push({
        id: slug(name),
        name,
        sizes: [{ name: 'M', price: p1 }, { name: 'L', price: p2 }]
      });
      continue;
    }

    // 單價格(且避免只有 'M' 'L' 誤抓)
    m = line.match(onePrice);
    if (m && !/\b(M|L)\b/.test(line)) {
      const name = sanitizeName(m[1]);
      const p = Number(m[2]);
      current.items.push({
        id: slug(name),
        name,
        sizes: [{ name: 'default', price: p }]
      });
    }
  }

  // 若完全沒有分類被抓到,至少給一個「未分類」
  if (menu.categories.length === 0) {
    menu.categories.push({ name: '未分類', items: [] });
  }

  return menu;
}

function sanitizeName(name) {
  return name
    .replace(/\s{2,}/g, ' ')
    .replace(/\s*(/g, ' (') // 全形括號轉半形前加空白
    .replace(/)/g, ')')
    .trim();
}

function slug(s) {
  return s.toLowerCase().replace(/\s+/g, '_').replace(/[^\w\u4e00-\u9fa5\-]/g, '');
}


🔍 關鍵咒語(正規表達式)

const twoPrice = /^([A-Za-z0-9\u4e00-\u9fa5の/\/\(\)\.\-\s]+?)\s+(\d{2,})\s+(\d{2,})$/;
const onePrice = /^([A-Za-z0-9\u4e00-\u9fa5の/\/\(\)\.\-\s]+?)\s+(?:NT\$|\$)?(\d{2,})(?:元)?$/;
  • 第一個符文:找出「品名 + M價 + L價」格式
    👉 例如 奶茶 35 50
  • 第二個符文:找出「品名 + 價格」格式
    👉 例如 鮮柚綠 45

這兩個咒語配合字串清理與分類符號(###找好茶 等)
能讓系統自動產出結構清晰的 menu.json


第四章:今日小結與明日預告

今天的技能點

  • 學會了使用 Google Vision API 召喚 AI 辨識魔法
  • 完成了前後端整合、可自動生成 menu.json 的系統
  • 透過正規表達式符文,將混亂的文字資料轉為乾淨 JSON
  • 建立了後端儲存 API,前端可一鍵存入 menu.json

https://ithelp.ithome.com.tw/upload/images/20251013/20121052aKpOEZcJqm.png

明天,我們將把這份「菜單召喚術」與前幾天做的 飲料點餐系統 整合,
讓整個流程從「上傳菜單 → 自動生成選單 → 點選飲料 → 自動生成訂單」一氣呵成!

屆時我們會讓魔法更上一層,加入:

  • rules.json(自動判斷熱飲/冰飲、甜度限制等)
  • 資料綁定與品項自動生成 UI
  • 最後打造出一個可以自動學習菜單的智慧點餐魔法陣

day22

Ps. 像今天如果你有下載google或是其他服務給的API key,記得一定要注意gitignore

不要git push上去

否則會造成你的key公開給大家使用的情境喔/images/emoticon/emoticon02.gif

建議實作前推上專案前再檢查一次 .gitignore有沒有這兩個檔案

gcp-service-account.json
.env

上一篇
Day 21:主題魔法的自由變換骨架 - MainLayout + CSS 變數主題系統
下一篇
魔法最終章|OCR 菜單大平台:一鍵上傳、多店共管、智慧點單
系列文
需求至上的 Vue 魔法之旅30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言