iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
Vue.js

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

Day 5:系統需求:清單管理 → reactive 陣列 → 新增/刪除/統計

  • 分享至 

  • xImage
  •  

前言|延續 Day 4 的思維

昨天 Day 4 我們完成了 狀態驅動樣式:只要狀態改變,畫面立即變化重新render。

今天 Day 5 要處理的是 「資料層面的管理」

  • 如何在前端新增、刪除與統計訂單
  • 如何讓畫面跟著陣列的新增或刪除自動刷新
  • 並且提供一個訂單統計總覽,協助統計飲料數量與客製化組合。

我們將學到 Vue 的 reactive 陣列管理,並綜合前四天的成果完成一個功能強大的飲料訂單系統。

https://ithelp.ithome.com.tw/upload/images/20250920/20121052MRzDJoIV5z.png

一、需求分析

1. User Story

我們把基本功能(判別飲料跟糖冰之後) => => 新增訂單人跟備註 => 綁定樣式 =>

我們接下來就需要操作已經送出的訂單,讓訂單更方便

這時候沒有統計相同客製化刪除訂單這樣要怎麼玩XDD

工程師給我加功能!!/images/emoticon/emoticon05.gif

  1. 可以刪除訂單
  2. 下面有表個需要自動計算相同客製化的飲料需要計算一起。
角色 故事 驗收條件
點餐者 作為使用者,我希望系統能保存多筆訂單,並可刪除修改,讓訂單維護更方便。 1. 新增訂單後清單立即更新。2. 可以刪除不需要的訂單。
統計人(秘書) 作為統計者,我希望可以即時計算每種飲料/甜度/冰量的數量,方便一次下單或結帳。 1. 清單下方有統計表。2. 任一訂單刪除後,統計結果同步更新。

2. 需求表格

我們把user story整理出來後

可以把資料變成需求表格

面向 需求描述
資料 使用一個 orders 陣列儲存多筆訂單,每筆包含 { name, drink, sweetness, ice, note }
互動 1. 可以新增訂單。2. 可以刪除訂單。3. 統計資料會隨新增/刪除自動更新。
UI/UX 1. 提供訂單列表,可一鍵刪除。2. 下方有統計表,顯示每種飲料/甜度/冰量的杯數。
驗收 1. 新增、刪除都能觸發列表和統計的即時刷新。2. 統計結果正確無誤。

3. 時序圖

這邊我們又可以把詳細的時序圖畫出來

方便理解怎麼操作

時序圖 在我們軟體設計的時候可以方便讓我們理解不同角色或階段的交互作用

https://ithelp.ithome.com.tw/upload/images/20250920/20121052ZFxWFz90zJ.png

4. UI設計

UI設計的部分

我們可以需要設計成

1.訂單列表區:顯示每筆訂單,提供刪除按鈕。
2.統計區:以表格呈現所有飲料/甜度/冰量的數量。而且重複飲料品項跟客製化的都會計算喔!!
3.即時更新:新增或刪除都能即時反映在列表與統計表中。

https://ithelp.ithome.com.tw/upload/images/20250920/201210522jn532TjLw.png

二、程式設計部分

1.程式 Flow Chart

程式的撰寫會把D1~D4的簡化

著重在今天的部分喔

https://ithelp.ithome.com.tw/upload/images/20250920/20121052UPNVYCXPS5.png

2. 對應的 Vue 技術

  • ref([]) / reactive([]):建立可響應的清單陣列,新增/刪除自動驅動畫面。
  • computed:由 orders 推導統計(飲料 × 甜度 × 冰量)。
  • v-for + :key:渲染訂單列表與統計表列。
  • @click 方法綁定:addOrder() 新增;removeOrder(index) 刪除。
  • 條件屬性::disabled="!canSubmit" 控制送出按鈕;(延續):class 呈現狀態樣式。
  • 結構轉換:用 Map/物件把 orders 歸納聚合 → 統計表資料列。

3. reactive 的核心概念

  • 回傳一個 Proxy 代理的物件或陣列,讓「讀/寫屬性」與「陣列變更」都可被追蹤。
  • 適合 物件或陣列 的情境;若是 單一原始型別(字串、數字、布林),請用 ref

4. 何時選 reactiveref

  • 陣列/物件reactive(或 ref([]) 也可,但操作方式不同)
  • 原始型別ref('')

如果今天你的物件可能會經常需要修改底data value那我會比較推薦使用reactive的寫法

我們核心的表格功能就可以這樣寫

<script setup>
import { reactive, computed } from 'vue'

/** ✅ 使用 reactive 建立響應式陣列 */
const orders = reactive([
  { drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
  { drink: '紅茶', sweetness: '去糖',   ice: '去冰'   },
  { drink: '綠茶', sweetness: '去糖',   ice: '去冰'   },
])

/** 依 drink|sweetness|ice 聚合計數 */
const summaryMap = computed(() => {
  const m = new Map()
  for (const o of orders) { // ✅ 直接使用 orders,無須 .value
    const key = `${o.drink}|${o.sweetness}|${o.ice}`
    m.set(key, (m.get(key) || 0) + 1)
  }
  return m
})

/** 轉成表格可渲染的陣列 */
const summaryRows = computed(() => {
  return Array.from(summaryMap.value.entries()).map(([key, count]) => {
    const [drink, sweetness, ice] = key.split('|')
    return { key, drink, sweetness, ice, count }
  })
})
</script>

<template>
  <table>
    <thead>
      <tr>
        <th>飲料</th>
        <th>甜度</th>
        <th>冰量</th>
        <th>數量</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in summaryRows" :key="row.key">
        <td>{{ row.drink }}</td>
        <td>{{ row.sweetness }}</td>
        <td>{{ row.ice }}</td>
        <td>{{ row.count }}</td>
      </tr>
    </tbody>
  </table>
</template>

Javascript Map型別的用法

其實今天有一個重點

就是我們可以透過 Javascript new Map() 型別處理這種事情

好處就是他可以把相同類型的資料聚合起來

const orders = [
  { drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
  { drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
  { drink: '綠茶', sweetness: '去糖',   ice: '去冰'   }
]

// 建立 Map 來統計
const m = new Map()

for (const o of orders) {
  // 以「飲料|甜度|冰量」作為唯一 key
  const key = `${o.drink}|${o.sweetness}|${o.ice}`
  // 如果 key 已存在,就取出舊值 + 1;否則給 1
  m.set(key, (m.get(key) || 0) + 1)
}

console.log(m)

輸出會是

Map(2) {
  '紅茶|正常甜|正常冰' => 2,
  '綠茶|去糖|去冰'   => 1
}

是不是很方便呢? /images/emoticon/emoticon12.gif

3. 程式的加碼(editor form)

有時候你會突然在寫code的時候突發奇想

因為UI設計完之後,有些UI或是功能沒有呈現

會希望變得更好

我們看到前面的UI思考後,會發現沒有編輯功能

這時候我會希望編輯按鈕做再刪除旁邊

之後點擊下去會觸發 editor.form 然編輯內容並且修改屬性

改過之後的UI會變成https://ithelp.ithome.com.tw/upload/images/20250920/20121052guVqaMO8PC.png

加碼的程式思路我會這樣子去設計

3.1 程式思路

  • 核心狀態
    • editIndex:目前編輯列的索引,-1 代表無編輯。
    • editForm:暫存編輯資料 { name, note, drink, sweetness, ice }
  • 流程
    • toggleEdit(i):開啟/收合編輯,將 orders[i] 複製到 editForm
    • applyEdit()Object.assign(orders[editIndex], editForm) 寫回並關閉編輯。
    • cancelEdit():直接關閉編輯,不修改原資料。
  • 表單控制
    • Radio 使用 :checked + @change,確保狀態與 UI 同步。
  • 反應鏈
    • ordersreactive([]),新增/刪除/修改後 computed 統計自動重算。

3.2 CSS 設計

  • <style scoped>:樣式限定在此元件。
  • 結構類 classorder, actions, edit-card, edit-grid
  • 狀態類 classinvalid, complete, is-ice, is-noice
  • 版面
    • .rowdisplay:flex; justify-content:space-between; 左資訊右操作。
    • .edit-card:虛線邊框、淡底色,凸顯編輯區塊。
    • .edit-griddisplay:grid; grid-template-columns:repeat(3,1fr),飲料/甜度/冰量整齊排列。
  • 互動提示
    • transition:淡入淡出顯示/收合。
    • .btn.btn-sm:小型按鈕群,節省空間。

4. 完整的程式碼

我們把上述的功能統計整理起來就變成這樣

<!-- DrinkOrdersDay5-Full-UI.vue -->
<template>
  <h2>飲料點單(Day 4 + Day 5:點餐流程 + 編輯/刪除/統計)</h2>

  <!-- Day 3:姓名/備註 -->
  <div :class="['block', name ? 'complete' : 'invalid']">
    <label>姓名(必填)
      <input type="text" v-model.trim="name" placeholder="請輸入你的名字" />
    </label>
    <p class="hint" v-if="!name">尚未填寫姓名</p>
  </div>

  <div class="block">
    <label>備註(選填)
      <textarea v-model.trim="note" placeholder="例如:三點拿、少冰"></textarea>
    </label>
  </div>

  <!-- 步驟 1:飲料 -->
  <fieldset :class="['block', hasDrink ? 'complete' : 'invalid']">
    <legend>步驟 1:選擇飲料</legend>
    <label>
      <input type="radio" name="drink" value="紅茶"
             :checked="drink === '紅茶'"
             @change="onDrinkChange('紅茶')" />
      紅茶
    </label>
    <label>
      <input type="radio" name="drink" value="綠茶"
             :checked="drink === '綠茶'"
             @change="onDrinkChange('綠茶')" />
      綠茶
    </label>
    <p class="hint" v-if="!hasDrink">尚未選取飲料</p>
  </fieldset>

  <!-- 步驟 2:甜度 -->
  <fieldset v-if="hasDrink" :class="['block', hasSweetness ? 'complete' : 'invalid']">
    <legend>步驟 2:選擇甜度</legend>
    <label>
      <input type="radio" name="sweetness" value="正常甜"
             :checked="sweetness === '正常甜'"
             @change="onSweetnessChange('正常甜')" />
      正常甜
    </label>
    <label>
      <input type="radio" name="sweetness" value="去糖"
             :checked="sweetness === '去糖'"
             @change="onSweetnessChange('去糖')" />
      去糖
    </label>
    <p class="hint" v-if="!hasSweetness">尚未選擇甜度</p>
  </fieldset>

  <!-- 步驟 3:冰量 -->
  <fieldset v-if="hasDrink && hasSweetness" :class="['block', hasIce ? 'complete' : 'invalid']">
    <legend>步驟 3:選擇冰量</legend>
    <label>
      <input type="radio" name="ice" value="正常冰"
             :checked="ice === '正常冰'"
             @change="onIceChange('正常冰')" />
      正常冰
    </label>
    <label>
      <input type="radio" name="ice" value="去冰"
             :checked="ice === '去冰'"
             @change="onIceChange('去冰')" />
      去冰
    </label>
    <p class="hint" v-if="!hasIce">尚未選擇冰量</p>
  </fieldset>

  <!-- 送出 -->
  <button :disabled="!canSubmit" @click="addOrder"
          :class="['submit', canSubmit ? 'enabled' : 'disabled']">
    {{ canSubmit ? '送出' : '請完成所有必填' }}
  </button>

  <!-- 清單:顯示 + 編輯 + 刪除 -->
  <section v-if="orders.length" class="list">
    <h3>目前已送出的訂單</h3>
    <ul>
      <li v-for="(o, i) in orders" :key="i" class="order">
        <!-- 第一行:摘要 + 小按鈕群(不滿版,靠右) -->
        <div class="row">
          <div class="col">
            <span class="idx">{{ i + 1 }}.</span>
            <span class="name">{{ o.name }}</span>
            <span class="pill">{{ o.drink }}</span>
            <span class="pill" :class="o.ice === '去冰' ? 'is-noice' : 'is-ice'">{{ o.ice }}</span>
            <span class="pill" :class="o.sweetness === '去糖' ? 'is-nosugar' : 'is-sugar'">{{ o.sweetness }}</span>
            <span v-if="o.note" class="note">備註:{{ o.note }}</span>
          </div>
          <div class="actions">
            <button class="btn btn-sm" @click="toggleEdit(i)">{{ editIndex === i ? '收合' : '編輯' }}</button>
            <button class="btn btn-sm del" @click="removeOrder(i)">刪除</button>
          </div>
        </div>

        <!-- 第二行:就地編輯表單(只有當前列展開) -->
        <transition name="fade">
          <div v-if="editIndex === i" class="edit-card">
            <div class="edit-row">
              <label>姓名:
                <input type="text" v-model.trim="editForm.name" />
              </label>
              <label>備註(選填):
                <input type="text" v-model.trim="editForm.note" />
              </label>
            </div>

            <div class="edit-grid">
              <fieldset class="block">
                <legend>飲料</legend>
                <label><input type="radio" name="edit-drink" value="紅茶"
                       :checked="editForm.drink === '紅茶'"
                       @change="editForm.drink = '紅茶'" /> 紅茶</label>
                <label><input type="radio" name="edit-drink" value="綠茶"
                       :checked="editForm.drink === '綠茶'"
                       @change="editForm.drink = '綠茶'" /> 綠茶</label>
              </fieldset>

              <fieldset class="block">
                <legend>甜度</legend>
                <label><input type="radio" name="edit-sweet" value="正常甜"
                       :checked="editForm.sweetness === '正常甜'"
                       @change="editForm.sweetness = '正常甜'" /> 正常甜</label>
                <label><input type="radio" name="edit-sweet" value="去糖"
                       :checked="editForm.sweetness === '去糖'"
                       @change="editForm.sweetness = '去糖'" /> 去糖</label>
              </fieldset>

              <fieldset class="block">
                <legend>冰量</legend>
                <label><input type="radio" name="edit-ice" value="正常冰"
                       :checked="editForm.ice === '正常冰'"
                       @change="editForm.ice = '正常冰'" /> 正常冰</label>
                <label><input type="radio" name="edit-ice" value="去冰"
                       :checked="editForm.ice === '去冰'"
                       @change="editForm.ice = '去冰'" /> 去冰</label>
              </fieldset>
            </div>

            <div class="edit-actions">
              <button class="btn btn-sm primary" @click="applyEdit">儲存</button>
              <button class="btn btn-sm" @click="cancelEdit">取消</button>
            </div>
          </div>
        </transition>
      </li>
    </ul>
  </section>

  <!-- 統計結果 -->
  <section v-if="orders.length" class="stats">
    <h3>統計結果</h3>
    <table class="table">
      <thead>
        <tr>
          <th>飲料</th><th>甜度</th><th>冰量</th><th>數量</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in summaryRows" :key="row.key">
          <td>{{ row.drink }}</td>
          <td>{{ row.sweetness }}</td>
          <td>{{ row.ice }}</td>
          <td class="qty">{{ row.count }}</td>
        </tr>
      </tbody>
      <tfoot>
        <tr>
          <td colspan="3">總杯數</td>
          <td class="qty">{{ totalCount }}</td>
        </tr>
      </tfoot>
    </table>
  </section>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'

/* 表單(Day3/4) */
const name = ref('')
const note = ref('')
const drink = ref('')
const sweetness = ref('')
const ice = ref('')

/* 清單(reactive 陣列) */
const orders = reactive([
  { name: 'alice', note: '',       drink: '紅茶', sweetness: '正常甜', ice: '正常冰' },
  { name: 'roni',  note: '主次',   drink: '紅茶', sweetness: '去糖',   ice: '去冰'   },
  { name: 'corgi', note: '7F office', drink: '綠茶', sweetness: '正常甜', ice: '去冰' }
])

/* Day4:旗標 */
const hasDrink = computed(() => !!drink.value)
const hasSweetness = computed(() => !!sweetness.value)
const hasIce = computed(() => !!ice.value)
const canSubmit = computed(() => !!(name.value && hasDrink.value && hasSweetness.value && hasIce.value))

/* 事件 */
function onDrinkChange(v) { drink.value = v }
function onSweetnessChange(v) { sweetness.value = v }
function onIceChange(v) { ice.value = v }

/* 新增訂單 */
function addOrder() {
  if (!canSubmit.value) return
  orders.push({ name: name.value, note: note.value, drink: drink.value, sweetness: sweetness.value, ice: ice.value })
  name.value = note.value = ''
  drink.value = sweetness.value = ice.value = ''
}

/* 編輯/刪除(就地編輯) */
const editIndex = ref(-1)
const editForm  = reactive({ name: '', note: '', drink: '', sweetness: '', ice: '' })

function toggleEdit(i){
  if (editIndex.value === i) { // 收合
    editIndex.value = -1
    return
  }
  editIndex.value = i
  Object.assign(editForm, orders[i])
}
function applyEdit() {
  if (editIndex.value < 0) return
  Object.assign(orders[editIndex.value], editForm)
  editIndex.value = -1
}
function cancelEdit() {
  editIndex.value = -1
}
function removeOrder(i) {
  orders.splice(i, 1)
  if (editIndex.value === i) editIndex.value = -1
}

/* 統計 */
const summaryMap = computed(() => {
  const m = new Map()
  for (const o of orders) {
    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 [d, s, i] = key.split('|')
    return { key, drink: d, sweetness: s, ice: i, count }
  })
)
const totalCount = computed(() => orders.length)
</script>

<style scoped>
/* 狀態回饋(Day4) */
.block { padding: 8px; border: 1px solid #ddd; border-radius: 8px; margin: 10px 0; }
.invalid { border-color: #e57373; background: #fff5f5; }
.complete { border-color: #66bb6a; background: #f3fff3; }
.hint { font-size: 12px; color: #c62828; margin-top: 4px; }

/* 送出按鈕 */
.submit { padding: 8px 12px; border-radius: 6px; border: 1px solid #ccc; margin: 8px 0; cursor: pointer; }
.submit.enabled { background: #1976d2; color: #fff; border-color: #1976d2; }
.submit.disabled { background: #f0f0f0; color: #888; cursor: not-allowed; }

/* 清單區 */
.list { margin-top: 14px; }
.order { border: 1px solid #eee; border-radius: 8px; padding: 8px; margin: 8px 0; background: #fff; }
.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.col { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.idx { color: #666; width: 24px; text-align: right; }
.name { font-weight: 600; margin-right: 6px; }
.pill { padding: 2px 8px; border-radius: 9999px; border: 1px solid #ccc; font-size: 12px; }
.is-ice { background: #e3f2fd; border-color: #90caf9; }
.is-noice { background: #e8f5e9; border-color: #a5d6a7; }
.is-sugar { background: #fff3e0; border-color: #ffcc80; }
.is-nosugar { background: #fce4ec; border-color: #f48fb1; }
.note { color: #555; font-size: 12px; }

/* 小按鈕群(不滿版) */
.actions { display: inline-flex; gap: 6px; }
.btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #999; background: #fff; cursor: pointer; }
.btn-sm { padding: 2px 8px; font-size: 12px; }
.btn.primary { border-color: #1976d2; background: #1976d2; color: #fff; }
.btn.del { border-color: #e57373; color: #e57373; }
.btn.del:hover { background: #ffeef0; }

/* 編輯卡片(展開時) */
.edit-card { margin-top: 8px; border: 1px dashed #ddd; border-radius: 8px; padding: 8px; background: #fafafa; }
.edit-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.edit-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 8px; }
.edit-actions { display: inline-flex; gap: 8px; }

/* 統計表 */
.stats { margin-top: 16px; }
.table { border-collapse: collapse; width: 100%; }
.table th, .table td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
.table thead { background: #fafafa; }
.qty { text-align: right; font-variant-numeric: tabular-nums; }

/* 動畫 */
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

Day5的程式碼跟play ground

🏁 Day 5 總結回顧

今天我們讓飲料點單系統完成了完整的清單管理

功能重點

  • 新增:沿用 Day 4 的送單流程,把使用者輸入資料新增到訂單清單。
  • 刪除:每筆訂單都可個別刪除,並自動重新計算統計。
  • 統計:使用 computedMap,即時聚合「飲料 × 甜度 × 冰量」的數量。
  • 編輯:新增 Editor Form,就地修改任一筆訂單並即時更新畫面。

day6我們可以整理有沒有需要補充的重點,還有可以延伸什麼東西

Tips 其實ref跟reactive在我這邊範例都可以做到,但是影響物件的深淺不同

這個後續有遇到的範例會一起補充進去


上一篇
Day 4 : 為什麼需要資料綁定?用「狀態→樣式」引出 :class
下一篇
Day 6:從實習魔法師到大魔法師,不被AI取代的關鍵(更進化的思考學習到的東西)
系列文
需求至上的 Vue 魔法之旅7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言