昨天 Day 4 我們完成了 狀態驅動樣式:只要狀態改變,畫面立即變化重新render。
今天 Day 5 要處理的是 「資料層面的管理」:
我們將學到 Vue 的 reactive
陣列管理,並綜合前四天的成果完成一個功能強大的飲料訂單系統。
我們把基本功能(判別飲料跟糖冰之後) => => 新增訂單人跟備註 => 綁定樣式 =>
我們接下來就需要操作已經送出的訂單,讓訂單更方便
這時候沒有統計相同客製化
跟刪除
訂單這樣要怎麼玩XDD
工程師給我加功能!!
角色 | 故事 | 驗收條件 |
---|---|---|
點餐者 | 作為使用者,我希望系統能保存多筆訂單,並可刪除修改,讓訂單維護更方便。 | 1. 新增訂單後清單立即更新。2. 可以刪除不需要的訂單。 |
統計人(秘書) | 作為統計者,我希望可以即時計算每種飲料/甜度/冰量的數量,方便一次下單或結帳。 | 1. 清單下方有統計表。2. 任一訂單刪除後,統計結果同步更新。 |
我們把user story整理出來後
可以把資料變成需求表格
面向 | 需求描述 |
---|---|
資料 | 使用一個 orders 陣列儲存多筆訂單,每筆包含 { name, drink, sweetness, ice, note } 。 |
互動 | 1. 可以新增訂單。2. 可以刪除訂單。3. 統計資料會隨新增/刪除自動更新。 |
UI/UX | 1. 提供訂單列表,可一鍵刪除。2. 下方有統計表,顯示每種飲料/甜度/冰量的杯數。 |
驗收 | 1. 新增、刪除都能觸發列表和統計的即時刷新。2. 統計結果正確無誤。 |
這邊我們又可以把詳細的時序圖畫出來
方便理解怎麼操作
時序圖
在我們軟體設計的時候可以方便讓我們理解不同角色或階段的交互作用
UI設計的部分
我們可以需要設計成
1.訂單列表區:顯示每筆訂單,提供刪除按鈕。
2.統計區:以表格呈現所有飲料/甜度/冰量的數量。而且重複飲料品項跟客製化的都會計算喔!!
3.即時更新:新增或刪除都能即時反映在列表與統計表中。
程式的撰寫會把D1~D4的簡化
著重在今天的部分喔
ref([])
/ reactive([])
:建立可響應的清單陣列,新增/刪除自動驅動畫面。computed
:由 orders
推導統計(飲料 × 甜度 × 冰量)。v-for
+ :key
:渲染訂單列表與統計表列。@click
方法綁定:addOrder()
新增;removeOrder(index)
刪除。:disabled="!canSubmit"
控制送出按鈕;(延續):class
呈現狀態樣式。Map
/物件把 orders
歸納聚合 → 統計表資料列。reactive
的核心概念ref
。reactive
或 ref
?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
}
是不是很方便呢?
有時候你會突然在寫code的時候突發奇想
因為UI設計完之後,有些UI或是功能沒有呈現
會希望變得更好
我們看到前面的UI思考後,會發現沒有編輯功能
這時候我會希望編輯
按鈕做再刪除
旁邊
之後點擊下去會觸發 editor.form
然編輯內容並且修改屬性
改過之後的UI會變成
加碼的程式思路我會這樣子去設計
editIndex
:目前編輯列的索引,-1
代表無編輯。editForm
:暫存編輯資料 { name, note, drink, sweetness, ice }
。toggleEdit(i)
:開啟/收合編輯,將 orders[i]
複製到 editForm
。applyEdit()
:Object.assign(orders[editIndex], editForm)
寫回並關閉編輯。cancelEdit()
:直接關閉編輯,不修改原資料。:checked + @change
,確保狀態與 UI 同步。orders
為 reactive([])
,新增/刪除/修改後 computed
統計自動重算。<style scoped>
:樣式限定在此元件。order
, actions
, edit-card
, edit-grid
。invalid
, complete
, is-ice
, is-noice
。.row
:display:flex; justify-content:space-between;
左資訊右操作。.edit-card
:虛線邊框、淡底色,凸顯編輯區塊。.edit-grid
:display:grid; grid-template-columns:repeat(3,1fr)
,飲料/甜度/冰量整齊排列。transition
:淡入淡出顯示/收合。.btn.btn-sm
:小型按鈕群,節省空間。我們把上述的功能統計整理起來就變成這樣
<!-- 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>
今天我們讓飲料點單系統完成了完整的清單管理:
computed
與 Map
,即時聚合「飲料 × 甜度 × 冰量」的數量。day6我們可以整理有沒有需要補充的重點,還有可以延伸什麼東西
Tips 其實ref跟reactive在我這邊範例都可以做到,但是影響物件的深淺
不同
這個後續有遇到的範例會一起補充進去