在現代的飲料魔法世界裡,秘書(或飲料召喚師)每天都要替團隊點飲料。
但想想看——每次都要手動輸入整份菜單?
茉莉綠茶、波霸奶茶、珍珠紅茶拿鐵…… 一行一行打字,像是老法師在刻符文陣。
我們要的,不是這樣的凡人作業。
我們要的是——自動化召喚魔法!
只要秘書上傳一張菜單圖片(以「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到後端的功能~!!
明天會把這個簡單的功能整合到我們先前的訂餐系統裡!!!
讓我們的菜單上傳更自動化~!! 也解放秘書們的雙手~!!![]()
「作為一位飲料系統的管理者,我希望只要上傳菜單圖片,就能自動辨識出品項與價格,讓我不再手動輸入菜單。」
| 使用者需求 | 背後動機 | 對應功能 | 實作方式 |
|---|---|---|---|
| 上傳飲料菜單圖片 | 不想重打菜單 | 前端上傳功能 | Vue + <input type="file"> |
| 自動辨識菜單內容 | 讓 AI 幫我看圖變文字 | 後端呼叫 Google Cloud Vision API | @google-cloud/vision |
| 整理成結構化資料 | 讓資料可以進系統使用 | 自動格式化成 JSON | Node.js + 正規表達式處理 |
| 使用者確認並儲存 | 確認辨識無誤後保存 | 存成 menu.json |
Express 寫檔系統 |
| 簡單、直覺 | 減少複雜設定 | 雙端本地開發架構 | Vite + Express 本機串接 |

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

前往 Google Cloud Console
建立專案,例如 menu-ocr-demo。
啟用 Vision API:
👉 點這裡快速開啟 Vision API

建立 Service Account:
vision-ocr-service
建立金鑰(JSON):

5. .env 設定:
GOOGLE_APPLICATION_CREDENTIALS=./gcp-service-account.json
PORT=8080
開通計費(必要)
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.
在瀏覽器開啟這個網址(或點錯誤裡給的連結):
👉 https://console.developers.google.com/billing/enable?project=你的專案ID
它會自動帶你進入「為專案啟用計費」的畫面。
如果你從沒建立過:點 「建立帳單帳戶」(Create billing account)。
按照步驟填信用卡資料(只要為了認證身份,不會立即收費)。
Google 會提供一筆 $300 美元試用金(新帳號限定,12 個月有效。 根據現實狀況而定
⚠️ 你只要不超過免費額度或試用金額,就完全不會被扣款。
回到:
👉 https://console.cloud.google.com/projectselector2/billing
檢查你的專案(例如 menu-ocr-demo 或該編號 30848556063)旁邊,應該顯示:
✅ 已連結至 [你的計費帳號名稱]
| 專案夾 | 套件 | 用途說明 |
|---|---|---|
| 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 單檔組件 |
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 |
控制按鈕啟用/停用、顯示進度、呈現結果 |
onPick(e)function onPick(e) {
file.value = e.target.files[0]
}
這是最基本的 「素材召喚咒語」。<input type="file"> 會傳出一個 FileList,這裡取第一個檔案存在 file(ref(null))裡。
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 則像是在施放同步咒語,讓流程更自然、可控。
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,也方便後續擴充版本管理。
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 時,
按下這個按鈕就能讓後端把目前檔案內容送回來,
使用者可以直接在前端預覽或覆寫更新。
ref)| 變數名稱 | 初始值 | 用途說明 |
|---|---|---|
file |
null |
使用者上傳的圖片檔案 |
loading |
false |
控制按鈕狀態與顯示載入中 |
rawText |
'' |
後端回傳的原始 OCR 文本(可在 textarea 顯示) |
menuJson |
'' |
經過後端整理的 JSON 結構(字串形式) |
前端的每一個 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)multer 接收上傳圖片vision.ImageAnnotatorClient() → documentTextDetection()
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,})(?:元)?$/;
奶茶 35 50
鮮柚綠 45
這兩個咒語配合字串清理與分類符號(###找好茶 等)
能讓系統自動產出結構清晰的 menu.json。
menu.json 的系統menu.json

明天,我們將把這份「菜單召喚術」與前幾天做的 飲料點餐系統 整合,
讓整個流程從「上傳菜單 → 自動生成選單 → 點選飲料 → 自動生成訂單」一氣呵成!
屆時我們會讓魔法更上一層,加入:
rules.json(自動判斷熱飲/冰飲、甜度限制等)Ps. 像今天如果你有下載google或是其他服務給的API key,記得一定要注意gitignore,
不要git push上去
否則會造成你的key公開給大家使用的情境喔![]()
建議實作前推上專案前再檢查一次 .gitignore有沒有這兩個檔案
gcp-service-account.json
.env