有時候使用者(祕書)會拿到一份大訂單,想直接覆蓋整個訂單列表,再繼續點其他飲料。
為了支援這種情境,我們把訂單的「衍生統計」與「批次匯入邏輯」集中到 Pinia 的 getters 與 setters,讓元件變得更乾淨、只處理 UI 與事件。
User Story 表(簡述)
角色 | 需求 | 目的 |
---|---|---|
祕書 | 貼上 JSON 訂單並覆蓋整份清單 | 快速更新訂單、不必逐筆輸入 |
系統 | 立即驗證與顯示錯誤 | 避免髒資料寫入 |
後端 | 提供批次覆蓋 API | 與前端狀態一致 |
User Story(Mermaid Story Map 近似)
時序圖(貼上 → 驗證 → 覆蓋)
state
管訂單清單與錯誤、loading。summaryRows
將 Day 5 的「統計結果」集中計算。ordersJson
,可直接綁定 <textarea>
做即時驗證與匯入。replaceAllOrders
呼叫後端 PUT /api/orders/bulk
,與後端資料一致化。orderStore
(訂單 + 統計 + 匯入)、menuStore
(飲料選單 + 每飲料規則)。ordermenu.json
:提供 drinks / sweetness / ice 與 per-drink 規則(例如:巧克力只能熱飲)。GET /api/ordermenu
:讀取 ordermenu.json
回傳。PUT /api/orders/bulk
:接收整份陣列、驗證必要欄位、正規化後覆蓋 order.json
。PUT /api/orders/:id
之前,避免 :id='bulk'
被誤攔截。主要改動檔案:
server.js
readMenu()
與 GET /api/ordermenu
PUT /api/orders/bulk
(並上移到 :id
之前)ordermenu.json
rules
:
allowedIce = ["熱飲"]
、allowedSweetness = ["正常甜","少糖"]
ordermenu.json我的設計是這樣的
{
"drinks": ["紅茶", "綠茶", "巧克力"],
"sweetnessOptions": ["正常甜", "少糖", "去糖"],
"iceOptions": ["正常冰", "去冰", "熱飲"],
"rules": {
"紅茶": {
"allowedSweetness": ["正常甜", "去糖"],
"allowedIce": ["正常冰", "去冰", "熱飲"]
},
"綠茶": {
"allowedSweetness": ["正常甜", "去糖"],
"allowedIce": ["正常冰", "去冰"]
},
"巧克力": {
"allowedSweetness": ["正常甜", "少糖"],
"allowedIce": ["熱飲"]
}
}
}
檔案 | 變更重點 | 說明 |
---|---|---|
backend/server.js |
新增 PUT /api/orders/bulk |
批次覆蓋整份訂單,並加入 per-drink 業務規則驗證 |
backend/server.js |
新增 GET /api/ordermenu |
提供飲料/甜度/冰量與規則給前端 |
backend/server.js |
路由順序調整 | 確保 /bulk 在 /:id 之前,避免被參數路由攔截 |
backend/ordermenu.json |
新增 rules |
例如「巧克力只能熱飲」等限制 |
PUT /api/orders/bulk
(含業務規則驗證)
// 🔁 PUT /api/orders/bulk - 批次覆蓋整份訂單清單(需放在 :id 路由之前)
app.put("/api/orders/bulk", async (req, res) => {
try {
const incoming = req.body
if (!Array.isArray(incoming)) {
return res.status(400).json({ error: "payload 應為陣列" })
}
// 讀取菜單規則以進行業務驗證
const menu = await readMenu();
const rules = menu?.rules || {};
// 基本驗證 + 業務規則驗證 + 正規化
const normalized = incoming.map((o) => {
if (!o || typeof o !== 'object') {
throw new Error('每筆應為物件')
}
const requiredOk = ['name','drink','sweetness','ice'].every((k) => typeof o[k] === 'string')
if (!requiredOk) {
throw new Error('缺少必要欄位或型別不正確')
}
// 規則驗證:若該飲料有規則則必須符合
const rule = rules[o.drink];
if (rule) {
const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
if (allowSweet.length && !allowSweet.includes(o.sweetness)) {
throw new Error(`飲料「${o.drink}」不允許甜度「${o.sweetness}」`);
}
if (allowIce.length && !allowIce.includes(o.ice)) {
throw new Error(`飲料「${o.drink}」不允許冰量「${o.ice}」`);
}
}
return {
id: typeof o.id === 'string' ? o.id : Date.now().toString() + Math.random().toString(16).slice(2),
name: o.name,
note: typeof o.note === 'string' ? o.note : '',
drink: o.drink,
sweetness: o.sweetness,
ice: o.ice,
createdAt: o.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
})
const ok = await writeOrders(normalized)
if (!ok) {
return res.status(500).json({ error: '無法覆蓋訂單' })
}
res.json(normalized)
} catch (error) {
res.status(400).json({ error: error.message || "批次覆蓋失敗" })
}
})
package.json
加入 pinia
src/main.js
使用 createPinia()
src/stores/orderStore.js
orders
, loading
, error
summaryRows
, summaryMap
, totalCount
ordersJson
(JSON 雙向綁定 + 驗證)loadOrders
, createOrder
, updateOrder
, removeOrder
, replaceAllOrders
src/stores/menuStore.js
menu
, loading
, error
drinks
, sweetnessOptions
, iceOptions
, rules
loadMenu()
讀取 GET /api/ordermenu
src/services/orderService.js
新增 replaceAll(orders)
對應 PUT /api/orders/bulk
src/App.vue
orders
與 menu
<textarea v-model="orderStore.ordersJson">
、按鈕呼叫 orderStore.replaceAllOrders(...)
src/components/OrderForm.vue
drinks/sweetnessOptions/iceOptions/menuRules
變更摘要(前端)
檔案 | 變更重點 | 說明 |
---|---|---|
src/main.js |
掛載 Pinia | 啟用中央狀態管理 |
src/stores/orderStore.js |
新增 getters(統計)、setter(ordersJson)、replaceAllOrders |
集中衍生資料與批次匯入邏輯 |
src/stores/menuStore.js |
暴露 rules |
提供前端依飲料收斂選項 |
src/services/orderService.js |
新增 replaceAll |
呼叫 PUT /api/orders/bulk |
src/App.vue |
新增匯入面板 | textarea v-model ordersJson + 套用到後端 |
src/components/OrderForm.vue |
依 menuRules 動態限制 |
巧克力→只顯示「熱飲」 |
關鍵程式片段
orderStore
的可寫 getter 與批次覆蓋 action
// 可寫 getter:提供 JSON 字串的雙向綁定(便於匯入/匯出)
const ordersJson = computed({
get() {
return JSON.stringify(orders.value, null, 2)
},
set(txt) {
try {
const parsed = JSON.parse(txt)
if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
for (const o of parsed) {
if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
if (!hasRequired) throw new Error('缺少必要欄位或型別不正確')
}
orders.value = parsed
error.value = ''
} catch (e) {
error.value = '匯入訂單 JSON 失敗: ' + e.message
}
}
})
// 批次覆蓋(祕書匯入json格式後,與後端同步)
async function replaceAllOrders(newOrders) {
try {
loading.value = true
error.value = ''
const data = await OrderService.replaceAll(newOrders)
orders.value = data
} catch (err) {
error.value = '批次覆蓋失敗: ' + (err.response?.data?.error || err.message)
console.error('批次覆蓋失敗:', err)
} finally {
loading.value = false
}
}
menuStore
這邊就是我們可以載入menu的地方拉
export const useMenuStore = defineStore('menu', () => {
const menu = ref({ drinks: [], sweetnessOptions: [], iceOptions: [], rules: {} })
const loading = ref(false)
const error = ref('')
async function loadMenu() {
try {
loading.value = true
error.value = ''
const { data } = await http.get('/api/ordermenu')
menu.value = data
} catch (err) {
error.value = '載入菜單失敗: ' + (err.response?.data?.error || err.message)
console.error('載入菜單失敗:', err)
} finally {
loading.value = false
}
}
OrderService.replaceAll
async replaceAll(orders) {
const { data } = await http.put('/api/orders/bulk', orders)
return data
},
App.vue
的匯入面板
<!-- 祕書匯入(示範 Pinia 可寫 getter + 後端 bulk 覆蓋) -->
<section class="block" style="margin-top:16px">
<h3>祕書匯入訂單(貼上 JSON 陣列)</h3>
<textarea
v-model="orderStore.ordersJson"
style="width:100%; min-height:160px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
placeholder='[ { "name": "王小美", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" } ]'
></textarea>
<div class="actions" style="margin-top:8px">
<button class="btn primary" @click="orderStore.replaceAllOrders(JSON.parse(orderStore.ordersJson))">套用到後端</button>
<button class="btn" @click="orderStore.loadOrders">重新載入(後端)</button>
</div>
</section>
OrderForm
依規則收斂選項
watch(drink, (d) => {
if (!d) return
const rule = props.menuRules[d]
if (rule) {
opt.sweetness = rule.allowedSweetness?.length ? rule.allowedSweetness : props.sweetnessOptions
opt.ice = rule.allowedIce?.length ? rule.allowedIce : props.iceOptions
} else {
opt.sweetness = props.sweetnessOptions
opt.ice = props.iceOptions
}
if (!opt.sweetness.includes(sweetness.value)) sweetness.value = ''
if (!opt.ice.includes(ice.value)) ice.value = ''
})
summaryRows
都能拿到一致的衍生結果。const summaryMap = computed(() => {
const m = new Map()
for (const o of orders.value) {
const key = `${o.drink}|${o.sweetness}|${o.ice}`
m.set(key, (m.get(key) || 0) + 1)
}
return m
})
const summaryRows = computed(() =>
Array.from(summaryMap.value.entries()).map(([key, count]) => {
const [drink, sweetness, ice] = key.split('|')
return { key, drink, sweetness, ice, count }
})
)
<textarea v-model>
直接綁定 store,貼上同時就進行格式驗證與本地預覽;這是最直覺的 UX。error
。const ordersJson = computed({
get() {
return JSON.stringify(orders.value, null, 2)
},
set(txt) {
try {
const parsed = JSON.parse(txt)
if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
for (const o of parsed) {
if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
if (!hasRequired) throw new Error('缺少必要欄位或型別不正確')
}
orders.value = parsed
error.value = ''
} catch (e) {
error.value = '匯入訂單 JSON 失敗: ' + e.message
}
}
})
replaceAllOrders
:為什麼?
async function replaceAllOrders(newOrders) {
try {
loading.value = true
error.value = ''
const data = await OrderService.replaceAll(newOrders)
orders.value = data
} catch (err) {
error.value = '批次覆蓋失敗: ' + (err.response?.data?.error || err.message)
console.error('批次覆蓋失敗:', err)
} finally {
loading.value = false
}
}
menuStore
而不是元件:
export const useMenuStore = defineStore('menu', () => {
const menu = ref({ drinks: [], sweetnessOptions: [], iceOptions: [], rules: {} })
...
})
const rule = rules[o.drink];
if (rule) {
const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
if (allowSweet.length && !allowSweet.includes(o.sweetness)) throw new Error(...)
if (allowIce.length && !allowIce.includes(o.ice)) throw new Error(...)
}
/bulk
要放在 /:id
之前?
id=bulk
,導致 404 或更新失敗。// 🔁 PUT /api/orders/bulk - 批次覆蓋整份訂單清單(需放在 :id 路由之前)
npm i && npm run start
npm i && npm run dev
PUT /api/orders/bulk
覆蓋整份清單可貼上的 JSON 範例:
[
{ "name": "user1", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" },
{ "name": "corgi", "note": "", "drink": "綠茶", "sweetness": "正常甜", "ice": "正常冰" },
{ "name": "Roni", "note": "下午三點", "drink": "巧克力", "sweetness": "少糖", "ice": "熱飲" }
]
orderStore
與 menuStore
,降低耦合、提高維護性;menuStore
的 per-drink 規則能讓 UI 選項自動收斂。ordermenu
要放在 store?GET /api/ordermenu
的資料或 JSON 檔,即可一次影響全站,不必逐頁調整。menuStore
,任何頁面或元件都從同一來源讀取,避免 App 規模變大後,由 App.vue
層層傳遞 props 造成「不同頁面看到的選項不一致」或「資料傳遞遺漏」的風險。面向 | 由 App.vue 傳 props |
由 menuStore 提供 |
---|---|---|
單一真相來源 | 可能分散在不同頁面重複宣告 | 集中於 store,任何頁面讀取一致 |
可維護性 | 更改清單需追蹤所有引用處 | 換品牌只需替換 ordermenu.json /API |
跨頁共享 | 需層層傳遞或事件巴士 | 直接 useMenuStore() 取得 |
效能 | 每頁進入可能各自請求 | store 可快取、避免重複拉取 |
測試 | 分散、難以 mock | 單點 mock store 輕鬆測試 |
下面是執行的結果
可以驗證我們寫的code或是rule有沒有被正確執行
{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
if (!Array.isArray(parsed)) throw new Error('JSON 根應為陣列')
["不是物件", 123]
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
if (!o || typeof o !== 'object') throw new Error('每筆應為物件')
name/drink/sweetness/ice
[{ "name": "王小美", "drink": "紅茶", "ice": "去冰" }]
[{ "name": "王小美", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }]
const hasRequired = ['name','drink','sweetness','ice'].every(k => typeof o[k] === 'string')
[{ "name": "小明", "drink": "巧克力", "sweetness": "少糖", "ice": "正常冰" }]
[{ "name": "小明", "drink": "巧克力", "sweetness": "少糖", "ice": "熱飲" }]
PUT /api/orders/bulk
按 ordermenu.json
的 rules
驗證。const rule = rules[o.drink];
if (rule) {
const allowSweet = Array.isArray(rule.allowedSweetness) ? rule.allowedSweetness : [];
const allowIce = Array.isArray(rule.allowedIce) ? rule.allowedIce : [];
if (allowSweet.length && !allowSweet.includes(o.sweetness)) throw new Error(...)
if (allowIce.length && !allowIce.includes(o.ice)) throw new Error(...)
}
[
{ "name": "A", "drink": "紅茶" "sweetness": "去糖" }
]
[
{ "name": "A", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" }
]