iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
Vue.js

邊學邊做:Vue.js 實戰養成計畫系列 第 15

Day 15:實戰小行星 — 做一個小 To Do List

  • 分享至 

  • xImage
  •  

實戰小行星 — To-Do List 就是把前 14 天學到的核心招式整合起來,做一個可用的小作品:
新增任務、完成/取消完成、刪除、搜尋/過濾、統計數量,還能把資料存到瀏覽器(重整不會不見)。

經過第12、13天實作練習後,肯定會覺得今天的很簡單哈哈哈,那我們正式開始吧!
下面給是這次的目標、學到的重點、讓我們一步一步完成版本(也可直接貼進 Vite + Vue3 專案的 src/App.vue 跑起來)。

功能目標

  • 新增任務(含輸入驗證)
  • 勾選完成 / 取消完成
  • 刪除任務
  • 關鍵字搜尋 + 完成狀態過濾(全部 / 待辦 / 已完成)
  • 統計:總數、已完成、未完成
  • 自動儲存到 localStorage,重整不會消失

會用到的觀念

  • v-model(表單雙向綁定)
  • v-for(任務列表)
  • v-if / v-show(空狀態、提示)
  • 事件處理 v-on
    - :class 動態樣式(完成劃線…)
  • computed(過濾/統計)
    - watchEffectwatch(同步 localStorage)
  • (可選)onMounted 讀取初始值

src/App.vue

<template>
  <main class="todo">
    <h1>🪨 小行星任務清單</h1>

    <!-- 新增任務 -->
    <form class="add" @submit.prevent="addTask">
      <input
        v-model.trim="draft"
        placeholder="輸入任務,例如:校對艦隊日誌"
        maxlength="80"
      />
      <button class="primary">加入</button>
    </form>
    <p v-if="error" class="error">{{ error }}</p>

    <!-- 搜尋與過濾 -->
    <section class="filters">
      <input v-model.trim="keyword" placeholder="搜尋關鍵字…" />
      <div class="tabs">
        <button
          v-for="opt in filters"
          :key="opt.value"
          :class="{ active: status === opt.value }"
          @click="status = opt.value"
        >
          {{ opt.label }}
        </button>
      </div>
    </section>

    <!-- 統計 -->
    <p class="stats">
      總數:{{ total }} 未完成:{{ remaining }} 已完成:{{ done }}
    </p>

    <!-- 清單 -->
    <ul class="list" v-if="filtered.length">
      <li v-for="t in filtered" :key="t.id" class="item">
        <label>
          <input type="checkbox" v-model="t.done" />
          <span :class="{ done: t.done }">{{ t.text }}</span>
        </label>
        <button class="ghost" @click="removeTask(t.id)">刪除</button>
      </li>
    </ul>
    <p v-else class="empty">目前沒有符合條件的任務 ✨</p>
  </main>
</template>

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

const STORAGE_KEY = 'asteroid-todos'

// 狀態
const todos   = ref([])
const draft   = ref('')
const error   = ref('')
const keyword = ref('')
const status  = ref('all') // all | active | completed

// 讀取 localStorage
onMounted(() => {
  try {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw) todos.value = JSON.parse(raw)
  } catch {}
})

// 自動同步到 localStorage
watchEffect(() => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.value))
})

// 新增
function addTask() {
  error.value = ''
  if (!draft.value) {
    error.value = '請輸入任務內容'
    return
  }
  todos.value.unshift({
    id: Date.now(),
    text: draft.value,
    done: false
  })
  draft.value = ''
}

// 刪除
function removeTask(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

// 過濾與搜尋
const filters = [
  { label: '全部', value: 'all' },
  { label: '待辦', value: 'active' },
  { label: '已完成', value: 'completed' }
]

const filtered = computed(() => {
  const kw = keyword.value.toLowerCase()
  return todos.value
    .filter(t => {
      if (status.value === 'active') return !t.done
      if (status.value === 'completed') return t.done
      return true
    })
    .filter(t => (kw ? t.text.toLowerCase().includes(kw) : true))
})

// 統計
const total     = computed(() => todos.value.length)
const done      = computed(() => todos.value.filter(t => t.done).length)
const remaining = computed(() => total.value - done.value)
</script>

<style scoped>
:root { color-scheme: dark; }
.todo { max-width: 720px; margin: 48px auto; font: 16px/1.6 ui-sans-serif, system-ui; color: #ffffff; }
h1 { margin: 0 0 16px; color: #4279b0ff; }
.add { display: flex; gap: 8px; }
.add input { flex: 1; padding: 10px 12px; border-radius: 10px; border: 1px solid #334155; background:#0b1020; color:#e2e8f0; }
button { padding: 8px 12px; border-radius: 10px; border: 1px solid #334155; background:#1f2a44; color:#e2e8f0; cursor:pointer; }
button.primary { background:#2563eb; border-color:#1d4ed8; }
.error { color:#fca5a5; margin:6px 0 0; }
.filters { display:flex; gap:12px; align-items:center; margin:14px 0; }
.filters input { flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid #334155; background:#0b1020; color:#e2e8f0; }
.tabs { display:flex; gap:8px; }
.tabs .active { outline:2px solid #dcc891ff; }
.stats { color:#94a3b8; margin-top:-2px; }
.list { list-style:none; padding:0; margin:12px 0 0; }
.item { display:flex; justify-content:space-between; align-items:center; padding:10px 12px; border:1px solid #24324d; border-radius:12px; background:#0f172a; margin-bottom:8px; }
.item .done { text-decoration: line-through; opacity:.6; }
.empty { color:#94a3b8; }
</style>

https://ithelp.ithome.com.tw/upload/images/20250927/20178644jCUnTTbapS.png

建議的「進階任務」

  • 編輯任務(點兩下改文字、Enter 儲存、Esc 取消)
  • 拖曳排序(整合 @vueuse/motionSortableJS
  • 加入截止日期與排序(最近到期排上面)
  • 批次操作:刪除已完成、全部完成 / 全部取消
  • 以 slot 做出可重用的 <TodoItem> 元件(把操作列做成 #actions 插槽)

經過今天,我們更明白前面幾天學會的概念與如何運用!明天我們將了解Vue Router的概念~

參考資源
https://vuejs.org/guide
https://www.runoob.com/vue3


上一篇
Day 14:黑洞觀測站 — Vue 的生命週期(Lifecycle Hooks)
下一篇
Day 16:星際航路圖 — Vue Router 入門
系列文
邊學邊做:Vue.js 實戰養成計畫18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言