待辦事項應用開發
2025年5月7日大约 11 分鐘
第三部分:完整待辦事項應用開發
本部分將應用前面所學的知識,開發一個完整的待辦事項應用,並介紹一些優化技巧和最佳實踐。
學習目標
- 開發完整的待辦事項應用
- 實現進階功能:分類、搜索、優先級等
- 了解 Firestore 的最佳實踐
- 優化應用性能
1. 應用架構設計
1.1 文件結構
/src
/assets
/components
/todos
TodoList.vue # 待辦事項列表
TodoItem.vue # 單個待辦事項
TodoForm.vue # 新增/編輯表單
TodoFilter.vue # 篩選組件
/composables
useCollection.js # 集合操作
getCollection.js # 獲取集合
getDocument.js # 獲取文檔
/firebase
config.js # Firebase 配置
/views
HomeView.vue # 主頁面
App.vue
main.js
1.2 數據結構
待辦事項文檔結構:
{
id: "自動生成的ID",
title: "待辦事項標題",
details: "詳細描述(可選)",
completed: false, // 完成狀態
category: "工作", // 分類
priority: 2, // 優先級 (1:低, 2:中, 3:高)
dueDate: timestamp, // 截止日期
createdAt: timestamp, // 創建時間
completedAt: null // 完成時間
}
2. 實現核心組件
2.1 TodoForm 組件
<!-- src/components/todos/TodoForm.vue -->
<script setup>
import { ref, reactive, computed, watch } from 'vue'
const props = defineProps({
todo: {
type: Object,
default: null
}
})
const emit = defineEmits(['submit', 'cancel'])
// 判斷是新增還是編輯模式
const isEdit = computed(() => props.todo !== null)
// 表單數據
const todoData = reactive({
title: '',
details: '',
category: '',
priority: 2,
dueDate: null
})
// 截止日期格式轉換
const dueDateString = ref('')
// 當props.todo變更時,更新表單數據
watch(() => props.todo, (newTodo) => {
if (newTodo) {
todoData.title = newTodo.title || ''
todoData.details = newTodo.details || ''
todoData.category = newTodo.category || ''
todoData.priority = newTodo.priority || 2
if (newTodo.dueDate) {
const date = newTodo.dueDate.toDate()
dueDateString.value = formatDateTimeLocal(date)
} else {
dueDateString.value = ''
}
} else {
// 重置表單
todoData.title = ''
todoData.details = ''
todoData.category = ''
todoData.priority = 2
dueDateString.value = ''
}
}, { immediate: true })
// 監聽日期字串變更
watch(dueDateString, (newValue) => {
todoData.dueDate = newValue ? new Date(newValue) : null
})
// 提交表單
const handleSubmit = () => {
// 創建待辦事項對象
const formData = {
title: todoData.title,
details: todoData.details,
category: todoData.category,
priority: todoData.priority,
dueDate: todoData.dueDate
}
emit('submit', formData)
// 非編輯模式下,重置表單
if (!isEdit.value) {
todoData.title = ''
todoData.details = ''
dueDateString.value = ''
}
}
// 日期格式化
function formatDateTimeLocal(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}`
}
</script>
<template>
<div class="todo-form">
<h3>{{ isEdit ? '編輯待辦事項' : '新增待辦事項' }}</h3>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="title">標題</label>
<input
type="text"
id="title"
v-model="todoData.title"
required
placeholder="輸入待辦事項標題"
>
</div>
<div class="form-group">
<label for="details">詳細描述(選填)</label>
<textarea
id="details"
v-model="todoData.details"
placeholder="輸入詳細描述"
rows="3"
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="category">分類</label>
<select id="category" v-model="todoData.category">
<option value="">未分類</option>
<option value="工作">工作</option>
<option value="學習">學習</option>
<option value="個人">個人</option>
<option value="家庭">家庭</option>
</select>
</div>
<div class="form-group">
<label for="priority">優先級</label>
<select id="priority" v-model="todoData.priority">
<option :value="1">低</option>
<option :value="2">中</option>
<option :value="3">高</option>
</select>
</div>
</div>
<div class="form-group">
<label for="dueDate">截止日期(選填)</label>
<input
type="datetime-local"
id="dueDate"
v-model="dueDateString"
>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" :disabled="isPending">
{{ isEdit ? '更新' : '新增' }}
</button>
<button
v-if="isEdit"
type="button"
class="btn-secondary"
@click="$emit('cancel')"
>
取消
</button>
</div>
</form>
</div>
</template>
<style scoped>
.todo-form {
background-color: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
h3 {
margin-top: 0;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-row {
display: flex;
gap: 15px;
}
.form-row .form-group {
flex: 1;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input, textarea, select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.btn-primary {
background-color: #4caf50;
color: white;
}
.btn-secondary {
background-color: #f1f1f1;
color: #333;
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>
2.2 TodoItem 組件
<!-- src/components/todos/TodoItem.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
todo: {
type: Object,
required: true
}
})
defineEmits(['toggle', 'edit', 'delete'])
const showDetails = ref(false)
// 格式化截止日期
const hasDueDate = computed(() => props.todo.dueDate !== null && props.todo.dueDate !== undefined)
const formatDueDate = computed(() => {
if (!hasDueDate.value) return ''
const dueDate = props.todo.dueDate.toDate()
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (isSameDay(dueDate, today)) {
return '今天'
} else if (isSameDay(dueDate, tomorrow)) {
return '明天'
} else {
const month = dueDate.getMonth() + 1
const day = dueDate.getDate()
return `${month}/${day}`
}
})
const formatDueDateFull = computed(() => {
if (!hasDueDate.value) return ''
const dueDate = props.todo.dueDate.toDate()
return dueDate.toLocaleString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
})
// 判斷是否逾期
const isOverdue = computed(() => {
if (!hasDueDate.value) return false
const now = new Date()
const dueDate = props.todo.dueDate.toDate()
return dueDate < now && !props.todo.completed
})
// 判斷是否同一天
function isSameDay(date1, date2) {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
}
</script>
<template>
<div class="todo-item" :class="{ completed: todo.completed, [`priority-${todo.priority}`]: true }">
<div class="todo-checkbox">
<input
type="checkbox"
:checked="todo.completed"
@change="$emit('toggle')"
>
</div>
<div class="todo-content" @click="showDetails = !showDetails">
<div class="todo-header">
<h3 class="todo-title">{{ todo.title }}</h3>
<div class="todo-meta">
<span v-if="todo.category" class="todo-category">{{ todo.category }}</span>
<span v-if="todo.dueDate" class="todo-due-date" :class="{ overdue: isOverdue }">
{{ formatDueDate }}
</span>
</div>
</div>
<div v-if="showDetails && (todo.details || hasDueDate)" class="todo-details">
<p v-if="todo.details">{{ todo.details }}</p>
<p v-if="hasDueDate" class="due-date-full">
截止日期: {{ formatDueDateFull }}
</p>
</div>
</div>
<div class="todo-actions">
<button @click="$emit('edit')" class="btn-edit">編輯</button>
<button @click="$emit('delete')" class="btn-delete">刪除</button>
</div>
</div>
</template>
<style scoped>
.todo-item {
display: flex;
align-items: flex-start;
padding: 15px;
margin-bottom: 10px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-left: 4px solid #ddd;
}
.todo-item.priority-1 {
border-left-color: #8bc34a; /* 低優先級 - 綠色 */
}
.todo-item.priority-2 {
border-left-color: #ffc107; /* 中優先級 - 黃色 */
}
.todo-item.priority-3 {
border-left-color: #f44336; /* 高優先級 - 紅色 */
}
.todo-item.completed {
opacity: 0.7;
background-color: #f9f9f9;
}
.todo-checkbox {
margin-right: 15px;
padding-top: 3px;
}
.todo-content {
flex: 1;
cursor: pointer;
}
.todo-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.todo-title {
margin: 0;
font-size: 16px;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #888;
}
.todo-meta {
display: flex;
gap: 10px;
}
.todo-category {
font-size: 12px;
padding: 2px 6px;
background-color: #e1f5fe;
border-radius: 4px;
color: #0288d1;
}
.todo-due-date {
font-size: 12px;
padding: 2px 6px;
background-color: #e8f5e9;
border-radius: 4px;
color: #388e3c;
}
.todo-due-date.overdue {
background-color: #ffebee;
color: #d32f2f;
}
.todo-details {
margin-top: 10px;
font-size: 14px;
color: #666;
}
.todo-details p {
margin: 5px 0;
}
.todo-actions {
display: flex;
flex-direction: column;
gap: 5px;
margin-left: 10px;
}
button {
padding: 6px 10px;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-edit {
background-color: #f1f1f1;
color: #333;
}
.btn-delete {
background-color: #ffebee;
color: #d32f2f;
}
</style>
2.3 TodoFilter 組件
<!-- src/components/todos/TodoFilter.vue -->
<script setup>
import { ref, reactive } from 'vue'
const emit = defineEmits(['filter-change'])
// 篩選選項
const statusOptions = [
{ label: '全部', value: 'all' },
{ label: '進行中', value: 'active' },
{ label: '已完成', value: 'completed' }
]
const categoryOptions = [
{ label: '全部', value: 'all' },
{ label: '工作', value: '工作' },
{ label: '學習', value: '學習' },
{ label: '個人', value: '個人' },
{ label: '家庭', value: '家庭' },
{ label: '未分類', value: 'uncategorized' }
]
const priorityOptions = [
{ label: '全部', value: 0 },
{ label: '低', value: 1 },
{ label: '中', value: 2 },
{ label: '高', value: 3 }
]
// 篩選狀態
const filters = reactive({
status: 'all',
category: 'all',
priority: 0,
search: ''
})
// 搜索查詢
const searchQuery = ref('')
// 更新篩選器
function updateFilter(filterType, value) {
filters[filterType] = value
emit('filter-change', { ...filters })
}
</script>
<template>
<div class="todo-filter">
<div class="filter-section">
<label>狀態</label>
<div class="filter-options">
<button
v-for="option in statusOptions"
:key="option.value"
:class="{ active: filters.status === option.value }"
@click="updateFilter('status', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="filter-section">
<label>分類</label>
<div class="filter-options">
<button
v-for="option in categoryOptions"
:key="option.value"
:class="{ active: filters.category === option.value }"
@click="updateFilter('category', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="filter-section">
<label>優先級</label>
<div class="filter-options">
<button
v-for="option in priorityOptions"
:key="option.value"
:class="{ active: filters.priority === option.value }"
@click="updateFilter('priority', option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="search-box">
<input
type="text"
v-model="searchQuery"
placeholder="搜索待辦事項..."
@input="updateFilter('search', searchQuery)"
>
</div>
</div>
</template>
<style scoped>
.todo-filter {
margin-bottom: 20px;
padding: 15px;
background-color: #f9f9f9;
border-radius: 8px;
}
.filter-section {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
font-size: 14px;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
padding: 6px 12px;
background-color: #f1f1f1;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
}
button.active {
background-color: #4caf50;
color: white;
}
.search-box {
margin-top: 15px;
}
input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
</style>
2.4 TodoList 主組件
<!-- src/components/todos/TodoList.vue -->
<script setup>
import { ref, computed } from 'vue'
import { orderBy, Timestamp } from 'firebase/firestore'
import TodoItem from './TodoItem.vue'
import TodoForm from './TodoForm.vue'
import TodoFilter from './TodoFilter.vue'
import getCollection from '@/composables/getCollection'
import useCollection from '@/composables/useCollection'
// 獲取待辦事項列表
const { documents: todos, error, isPending } = getCollection(
'todos',
[orderBy('createdAt', 'desc')]
)
// 使用 Firestore 操作
const {
addDocument,
updateDocument,
deleteDocument
} = useCollection('todos')
// 當前編輯的待辦事項
const currentTodo = ref(null)
// 篩選條件
const filters = ref({
status: 'all',
category: 'all',
priority: 0,
search: ''
})
// 應用篩選條件
function applyFilters(newFilters) {
filters.value = { ...newFilters }
}
// 篩選後的待辦事項
const filteredTodos = computed(() => {
if (!todos.value) return []
return todos.value.filter(todo => {
// 狀態篩選
if (filters.value.status === 'active' && todo.completed) return false
if (filters.value.status === 'completed' && !todo.completed) return false
// 分類篩選
if (filters.value.category === 'uncategorized' && todo.category) return false
if (filters.value.category !== 'all' && filters.value.category !== 'uncategorized' && todo.category !== filters.value.category) return false
// 優先級篩選
if (filters.value.priority !== 0 && todo.priority !== filters.value.priority) return false
// 搜索篩選
if (filters.value.search && !todo.title.toLowerCase().includes(filters.value.search.toLowerCase())) return false
return true
})
})
// 計算已完成待辦事項數量
const completedCount = computed(() => {
if (!todos.value) return 0
return todos.value.filter(todo => todo.completed).length
})
// 獲取列表標題
const getListTitle = computed(() => {
if (filters.value.status === 'active') return '進行中的待辦事項'
if (filters.value.status === 'completed') return '已完成的待辦事項'
if (filters.value.category !== 'all') {
if (filters.value.category === 'uncategorized') return '未分類的待辦事項'
return `${filters.value.category}類待辦事項`
}
if (filters.value.priority !== 0) {
const priorityMap = { 1: '低', 2: '中', 3: '高' }
return `${priorityMap[filters.value.priority]}優先級待辦事項`
}
if (filters.value.search) return `搜索結果: "${filters.value.search}"`
return '所有待辦事項'
})
// 處理待辦事項提交
const handleTodoSubmit = async (todoData) => {
if (currentTodo.value) {
// 更新現有待辦事項
await updateDocument(currentTodo.value.id, {
...todoData,
updatedAt: Timestamp.now()
})
currentTodo.value = null
} else {
// 添加新待辦事項
await addDocument({
...todoData,
completed: false
// createdAt 會在 addDocument 中自動添加
})
}
}
// 切換待辦事項完成狀態
const toggleTodo = async (todo) => {
const isNowCompleted = !todo.completed
const updates = {
completed: isNowCompleted
}
// 如果標記為完成,添加完成時間
if (isNowCompleted) {
updates.completedAt = Timestamp.now()
} else {
updates.completedAt = null
}
await updateDocument(todo.id, updates)
}
// 編輯待辦事項
const editTodo = (todo) => {
currentTodo.value = { ...todo }
}
// 取消編輯
const cancelEdit = () => {
currentTodo.value = null
}
// 刪除待辦事項
const deleteTodo = async (id) => {
if (confirm('確定要刪除這個待辦事項嗎?')) {
await deleteDocument(id)
}
}
</script>
<template>
<div class="todo-container">
<h2>待辦事項管理</h2>
<todo-filter @filter-change="applyFilters" />
<todo-form
:todo="currentTodo"
@submit="handleTodoSubmit"
@cancel="cancelEdit"
/>
<div v-if="isPending" class="loading">
加載中...
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else>
<h3>{{ getListTitle }}</h3>
<div v-if="filteredTodos.length === 0" class="empty-list">
目前沒有待辦事項
</div>
<todo-item
v-for="todo in filteredTodos"
:key="todo.id"
:todo="todo"
@toggle="toggleTodo(todo)"
@edit="editTodo(todo)"
@delete="deleteTodo(todo.id)"
/>
<div class="list-stats">
總計: {{ todos?.length || 0 }} 項,已完成: {{ completedCount }} 項
</div>
</div>
</div>
</template>
<style scoped>
.todo-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
h2 {
text-align: center;
margin-bottom: 20px;
}
h3 {
margin-top: 30px;
margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.error {
color: #d32f2f;
padding: 20px;
background-color: #ffebee;
border-radius: 8px;
margin-bottom: 20px;
}
.empty-list {
text-align: center;
padding: 30px;
background-color: #f5f5f5;
border-radius: 8px;
color: #666;
}
.list-stats {
margin-top: 30px;
padding: 10px;
font-size: 14px;
text-align: right;
color: #666;
}
</style>
2.5 主頁面組件
<!-- src/views/HomeView.vue -->
<template>
<div class="home">
<header>
<h1>高效待辦事項</h1>
<p>使用 Vue 3 與 Firestore 開發的待辦事項應用</p>
</header>
<main>
<todo-list />
</main>
</div>
</template>
<script setup>
import TodoList from '@/components/todos/TodoList.vue'
</script>
<style scoped>
.home {
padding: 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
}
header p {
color: #666;
}
</style>
3. 應用優化與最佳實踐
3.1 Firestore 索引優化
當我們使用複合查詢(多個 where
條件或 where
與 orderBy
組合)時,Firestore 需要特定的索引。Firebase 會在控制台中提示創建所需的索引。
例如,以下查詢可能需要索引:
// 查詢特定類別且按創建時間排序的未完成待辦事項
const { documents } = getCollection(
'todos',
[
where('completed', '==', false),
where('category', '==', '工作'),
orderBy('createdAt', 'desc')
]
)
3.2 批量操作
對於需要同時修改多個文檔的操作,可以使用批量寫入:
// src/composables/useBatchUpdate.js
import { ref } from 'vue'
import { db } from '@/firebase/config'
import { writeBatch, doc } from 'firebase/firestore'
const useBatchUpdate = () => {
const error = ref(null)
const isPending = ref(false)
// 批量更新文檔
const batchUpdate = async (collectionName, updates) => {
error.value = null
isPending.value = true
try {
const batch = writeBatch(db)
// 遍歷更新
updates.forEach(update => {
const docRef = doc(db, collectionName, update.id)
batch.update(docRef, update.data)
})
await batch.commit()
isPending.value = false
} catch (err) {
console.error(err.message)
error.value = '批量更新失敗'
isPending.value = false
}
}
return { error, isPending, batchUpdate }
}
export default useBatchUpdate
3.3 離線支援
Firestore 支援離線操作,數據會在網絡恢復後自動同步。可以在 Firebase 配置中啟用離線持久化:
import { enableIndexedDbPersistence } from 'firebase/firestore'
// 啟用離線持久化
enableIndexedDbPersistence(db)
.catch((err) => {
console.error('離線持久化啟用失敗:', err)
})
3.4 安全規則配置
在生產環境中,應該配置 Firestore 的安全規則,限制訪問權限。以下是一個基本的安全規則示例:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 待辦事項集合的規則
match /todos/{todo} {
// 只允許已認證的用戶讀取和寫入自己的待辦事項
allow read, write: if request.auth != null && request.auth.uid == resource.data.userId;
// 創建新待辦事項的規則
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid &&
request.resource.data.createdAt == request.time;
}
}
}
4. 進階功能擴展
4.1 用戶認證
集成 Firebase Authentication,實現用戶註冊、登錄功能,並將待辦事項與特定用戶關聯。
4.2 數據遷移與備份
實現待辦事項的導入/導出功能,方便數據遷移與備份。
4.3 統計與分析
添加待辦事項完成率統計、時間分析等功能,幫助用戶提高效率。
練習
練習 1:完整應用實現
- 基於提供的組件和示例代碼,實現完整的待辦事項應用
- 確保所有基本功能正常運行:添加、編輯、刪除、標記完成等
- 實現篩選和搜索功能
練習 2:功能擴展
- 添加拖放排序功能,允許用戶調整待辦事項順序
- 實現標籤功能,允許用戶為待辦事項添加多個標籤
- 添加提醒功能,在待辦事項即將到期時發送通知
練習 3:性能優化
- 實現分頁加載,提高大量待辦事項的加載性能
- 優化查詢,減少讀取操作次數
- 使用 Vue 的
<Suspense>
實現更好的加載體驗
總結
在這一部分,我們實現了一個功能完整的待辦事項應用,涵蓋:
- 完整的 UI 組件設計
- 待辦事項的CRUD操作
- 高級篩選和搜索功能
- 性能優化和最佳實踐
通過這個專案,你已經掌握了 Vue 3 和 Firestore 的整合開發技能,可以應用於更複雜的實際項目中。