有時候使用者(祕書)會拿到一份大訂單,想直接覆蓋整個訂單列表,再繼續點其他飲料。
為了支援這種情境,我們把訂單的「衍生統計」與「批次匯入邏輯」集中到 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": "去冰" }
]
