概述
从零开始构建一个完整的Vue.js待办事项管理应用,涵盖Vue 3的核心概念、组合式API、状态管理、路由等现代前端开发技术。
项目初始化
1. 环境准备
# 使用Vite创建Vue项目
npm create vue@latest vue-todo-app
cd vue-todo-app
# 安装依赖
npm install
# 安装额外依赖
npm install axios vue-router@4 pinia
2. 项目结构
vue-todo-app/
├── public/
├── src/
│ ├── components/
│ │ ├── TodoList.vue
│ │ ├── TodoItem.vue
│ │ ├── TodoForm.vue
│ │ └── Navigation.vue
│ ├── views/
│ │ ├── Login.vue
│ │ ├── Dashboard.vue
│ │ ├── TodoView.vue
│ │ └── Profile.vue
│ ├── stores/
│ │ └── auth.js
│ │ └── todos.js
│ ├── services/
│ │ └── api.js
│ ├── router/
│ │ └── index.js
│ ├── utils/
│ │ └── auth.js
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js
核心代码实现
1. 主入口文件 (main.js)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
2. 路由配置 (router/index.js)
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresGuest: true }
},
{
path: '/todos',
name: 'Todos',
component: () => import('@/views/TodoView.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/login')
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router
3. API服务层 (services/api.js)
import axios from 'axios'
const API_BASE_URL = 'http://localhost:8000'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器 - 添加token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 处理token过期
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authAPI = {
login: (credentials) => api.post('/token', credentials),
getProfile: () => api.get('/users/me')
}
export const todosAPI = {
getAll: () => api.get('/todos'),
getById: (id) => api.get(`/todos/${id}`),
create: (todo) => api.post('/todos', todo),
update: (id, todo) => api.put(`/todos/${id}`, todo),
delete: (id) => api.delete(`/todos/${id}`)
}
export default api
4. 认证状态管理 (stores/auth.js)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authAPI } from '@/services/api'
import router from '@/router'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('access_token'))
const isAuthenticated = computed(() => !!token.value)
const login = async (credentials) => {
try {
const response = await authAPI.login(credentials)
const { access_token } = response.data
token.value = access_token
localStorage.setItem('access_token', access_token)
await fetchUser()
router.push('/')
} catch (error) {
throw new Error('登录失败,请检查用户名和密码')
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('access_token')
router.push('/login')
}
const fetchUser = async () => {
try {
const response = await authAPI.getProfile()
user.value = response.data
} catch (error) {
logout()
}
}
// 初始化时获取用户信息
if (token.value) {
fetchUser()
}
return {
user,
token,
isAuthenticated,
login,
logout,
fetchUser
}
})
5. 待办事项状态管理 (stores/todos.js)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { todosAPI } from '@/services/api'
export const useTodosStore = defineStore('todos', () => {
const todos = ref([])
const loading = ref(false)
const error = ref(null)
const completedTodos = computed(() =>
todos.value.filter(todo => todo.completed)
)
const pendingTodos = computed(() =>
todos.value.filter(todo => !todo.completed)
)
const fetchTodos = async () => {
loading.value = true
error.value = null
try {
const response = await todosAPI.getAll()
todos.value = response.data
} catch (err) {
error.value = '获取待办事项失败'
console.error('Error fetching todos:', err)
} finally {
loading.value = false
}
}
const addTodo = async (todoData) => {
try {
const response = await todosAPI.create(todoData)
todos.value.push(response.data)
} catch (err) {
error.value = '创建待办事项失败'
throw err
}
}
const updateTodo = async (id, updates) => {
try {
const response = await todosAPI.update(id, updates)
const index = todos.value.findIndex(todo => todo.id === id)
if (index !== -1) {
todos.value[index] = response.data
}
} catch (err) {
error.value = '更新待办事项失败'
throw err
}
}
const deleteTodo = async (id) => {
try {
await todosAPI.delete(id)
todos.value = todos.value.filter(todo => todo.id !== id)
} catch (err) {
error.value = '删除待办事项失败'
throw err
}
}
const toggleTodo = async (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
await updateTodo(id, { completed: !todo.completed })
}
}
return {
todos,
loading,
error,
completedTodos,
pendingTodos,
fetchTodos,
addTodo,
updateTodo,
deleteTodo,
toggleTodo
}
})
6. 主应用组件 (App.vue)
<template>
<div id="app">
<Navigation v-if="authStore.isAuthenticated" />
<main :class="{ 'with-nav': authStore.isAuthenticated }">
<router-view />
</main>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import Navigation from '@/components/Navigation.vue'
const authStore = useAuthStore()
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: #f5f5f5;
color: #333;
}
#app {
min-height: 100vh;
}
main {
padding: 2rem;
}
main.with-nav {
padding-top: 6rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.btn-primary {
background-color: #4f46e5;
color: white;
}
.btn-primary:hover {
background-color: #4338ca;
}
.btn-danger {
background-color: #dc2626;
color: white;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.btn-success {
background-color: #16a34a;
color: white;
}
.btn-success:hover {
background-color: #15803d;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.loading {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error {
color: #dc2626;
background-color: #fef2f2;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
</style>
7. 导航组件 (components/Navigation.vue)
<template>
<nav class="navbar">
<div class="nav-container">
<div class="nav-brand">
<router-link to="/">TodoApp</router-link>
</div>
<div class="nav-links">
<router-link to="/" class="nav-link">仪表板</router-link>
<router-link to="/todos" class="nav-link">待办事项</router-link>
<router-link to="/profile" class="nav-link">个人资料</router-link>
<button @click="handleLogout" class="btn btn-outline">退出登录</button>
</div>
</div>
</nav>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogout = () => {
authStore.logout()
}
</script>
<style scoped>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand a {
font-size: 1.5rem;
font-weight: bold;
color: #4f46e5;
text-decoration: none;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
}
.nav-link {
color: #6b7280;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.3s ease;
}
.nav-link:hover,
.nav-link.router-link-active {
color: #4f46e5;
background-color: #f8fafc;
}
.btn-outline {
background: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-outline:hover {
background-color: #f3f4f6;
}
</style>
8. 登录页面 (views/Login.vue)
<template>
<div class="login-container">
<div class="login-card">
<h1>登录</h1>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="email" class="form-label">邮箱</label>
<input
id="email"
v-model="form.email"
type="email"
class="form-input"
required
placeholder="请输入邮箱"
/>
</div>
<div class="form-group">
<label for="password" class="form-label">密码</label>
<input
id="password"
v-model="form.password"
type="password"
class="form-input"
required
placeholder="请输入密码"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const error = ref('')
const form = reactive({
email: '',
password: ''
})
const handleLogin = async () => {
if (!form.email || !form.password) {
error.value = '请填写所有字段'
return
}
loading.value = true
error.value = ''
try {
await authStore.login({
username: form.email,
password: form.password
})
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-card h1 {
text-align: center;
margin-bottom: 2rem;
color: #1f2937;
}
.login-form {
display: flex;
flex-direction: column;
}
.btn-block {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
}
</style>
9. 仪表板页面 (views/Dashboard.vue)
<template>
<div class="dashboard">
<div class="container">
<h1>欢迎回来, {{ authStore.user?.email }}</h1>
<div class="stats-grid">
<div class="stat-card">
<h3>总任务数</h3>
<p class="stat-number">{{ todosStore.todos.length }}</p>
</div>
<div class="stat-card">
<h3>已完成</h3>
<p class="stat-number">{{ todosStore.completedTodos.length }}</p>
</div>
<div class="stat-card">
<h3>待完成</h3>
<p class="stat-number">{{ todosStore.pendingTodos.length }}</p>
</div>
</div>
<div class="recent-todos">
<h2>最近任务</h2>
<div v-if="todosStore.loading" class="loading">加载中...</div>
<div v-else-if="recentTodos.length === 0" class="empty-state">
<p>暂无任务,<router-link to="/todos">创建第一个任务</router-link></p>
</div>
<div v-else class="todos-list">
<TodoItem
v-for="todo in recentTodos"
:key="todo.id"
:todo="todo"
@toggle="handleToggleTodo"
@delete="handleDeleteTodo"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useTodosStore } from '@/stores/todos'
import TodoItem from '@/components/TodoItem.vue'
const authStore = useAuthStore()
const todosStore = useTodosStore()
const recentTodos = computed(() =>
todosStore.todos.slice(0, 5).sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
)
const handleToggleTodo = async (id) => {
await todosStore.toggleTodo(id)
}
const handleDeleteTodo = async (id) => {
if (confirm('确定删除这个任务吗?')) {
await todosStore.deleteTodo(id)
}
}
onMounted(() => {
todosStore.fetchTodos()
})
</script>
<style scoped>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #4f46e5;
margin: 0.5rem 0 0 0;
}
.recent-todos {
margin-top: 2rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.empty-state a {
color: #4f46e5;
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
.todos-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
10. 待办事项页面 (views/TodoView.vue)
<template>
<div class="todo-view">
<div class="container">
<div class="todo-header">
<h1>待办事项管理</h1>
<button @click="showForm = !showForm" class="btn btn-primary">
{{ showForm ? '取消' : '添加任务' }}
</button>
</div>
<TodoForm
v-if="showForm"
@submit="handleAddTodo"
@cancel="showForm = false"
/>
<div class="todo-filters">
<button
@click="filter = 'all'"
:class="['btn', 'btn-filter', { active: filter === 'all' }]"
>
全部 ({{ todosStore.todos.length }})
</button>
<button
@click="filter = 'pending'"
:class="['btn', 'btn-filter', { active: filter === 'pending' }]"
>
待完成 ({{ todosStore.pendingTodos.length }})
</button>
<button
@click="filter = 'completed'"
:class="['btn', 'btn-filter', { active: filter === 'completed' }]"
>
已完成 ({{ todosStore.completedTodos.length }})
</button>
</div>
<div v-if="todosStore.loading" class="loading">加载中...</div>
<div v-else-if="filteredTodos.length === 0" class="empty-state">
<p>暂无任务</p>
</div>
<div v-else class="todos-list">
<TodoItem
v-for="todo in filteredTodos"
:key="todo.id"
:todo="todo"
@toggle="handleToggleTodo"
@update="handleUpdateTodo"
@delete="handleDeleteTodo"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useTodosStore } from '@/stores/todos'
import TodoForm from '@/components/TodoForm.vue'
import TodoItem from '@/components/TodoItem.vue'
const todosStore = useTodosStore()
const showForm = ref(false)
const filter = ref('all')
const filteredTodos = computed(() => {
switch (filter.value) {
case 'pending':
return todosStore.pendingTodos
case 'completed':
return todosStore.completedTodos
default:
return todosStore.todos
}
})
const handleAddTodo = async (todoData) => {
await todosStore.addTodo(todoData)
showForm.value = false
}
const handleToggleTodo = async (id) => {
await todosStore.toggleTodo(id)
}
const handleUpdateTodo = async (id, updates) => {
await todosStore.updateTodo(id, updates)
}
const handleDeleteTodo = async (id) => {
if (confirm('确定删除这个任务吗?')) {
await todosStore.deleteTodo(id)
}
}
onMounted(() => {
todosStore.fetchTodos()
})
</script>
<style scoped>
.todo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.todo-filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.btn-filter {
background: transparent;
border: 1px solid #d1d5db;
color: #6b7280;
}
.btn-filter.active {
background-color: #4f46e5;
color: white;
border-color: #4f46e5;
}
.todos-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
11. 待办事项表单组件 (components/TodoForm.vue)
<template>
<form @submit.prevent="handleSubmit" class="todo-form card">
<h3>{{ editingTodo ? '编辑任务' : '添加新任务' }}</h3>
<div class="form-group">
<label for="title" class="form-label">标题 *</label>
<input
id="title"
v-model="form.title"
type="text"
class="form-input"
required
placeholder="请输入任务标题"
/>
</div>
<div class="form-group">
<label for="description" class="form-label">描述</label>
<textarea
id="description"
v-model="form.description"
class="form-input"
rows="3"
placeholder="请输入任务描述(可选)"
></textarea>
</div>
<div class="form-actions">
<button type="button" @click="handleCancel" class="btn btn-outline">
取消
</button>
<button type="submit" class="btn btn-primary" :disabled="!form.title.trim()">
{{ editingTodo ? '更新' : '创建' }}
</button>
</div>
</form>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
const props = defineProps({
editingTodo: {
type: Object,
default: null
}
})
const emit = defineEmits(['submit', 'cancel'])
const form = reactive({
title: '',
description: ''
})
watch(() => props.editingTodo, (newTodo) => {
if (newTodo) {
form.title = newTodo.title
form.description = newTodo.description || ''
} else {
resetForm()
}
})
const resetForm = () => {
form.title = ''
form.description = ''
}
const handleSubmit = () => {
emit('submit', { ...form })
if (!props.editingTodo) {
resetForm()
}
}
const handleCancel = () => {
resetForm()
emit('cancel')
}
</script>
<style scoped>
.todo-form {
margin-bottom: 2rem;
}
.todo-form h3 {
margin-bottom: 1rem;
color: #1f2937;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-outline {
background: transparent;
border: 1px solid #d1d5db;
color: #374151;
}
</style>
12. 待办事项项组件 (components/TodoItem.vue)
<template>
<div class="todo-item card" :class="{ completed: todo.completed }">
<div class="todo-content">
<div class="todo-checkbox">
<input
type="checkbox"
:checked="todo.completed"
@change="$emit('toggle', todo.id)"
class="checkbox"
/>
</div>
<div class="todo-details">
<h4 class="todo-title" :class="{ completed: todo.completed }">
{{ todo.title }}
</h4>
<p v-if="todo.description" class="todo-description">
{{ todo.description }}
</p>
<div class="todo-meta">
<span class="todo-date">
创建: {{ formatDate(todo.created_at) }}
</span>
<span v-if="todo.updated_at !== todo.created_at" class="todo-date">
更新: {{ formatDate(todo.updated_at) }}
</span>
</div>
</div>
</div>
<div class="todo-actions">
<button @click="handleEdit" class="btn btn-sm btn-outline">
编辑
</button>
<button @click="$emit('delete', todo.id)" class="btn btn-sm btn-danger">
删除
</button>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
defineProps({
todo: {
type: Object,
required: true
}
})
defineEmits(['toggle', 'update', 'delete'])
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('zh-CN')
}
const handleEdit = () => {
// 编辑逻辑可以在父组件中实现
// 这里可以打开一个编辑模态框或者直接进入编辑模式
}
</script>
<style scoped>
.todo-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: all 0.3s ease;
}
.todo-item.completed {
opacity: 0.7;
background-color: #f9fafb;
}
.todo-content {
display: flex;
align-items: flex-start;
gap: 1rem;
flex: 1;
}
.todo-checkbox {
margin-top: 0.25rem;
}
.checkbox {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.todo-details {
flex: 1;
}
.todo-title {
margin: 0 0 0.5rem 0;
color: #1f2937;
font-weight: 600;
}
.todo-title.completed {
text-decoration: line-through;
color: #6b7280;
}
.todo-description {
margin: 0 0 0.5rem 0;
color: #6b7280;
line-height: 1.4;
}
.todo-meta {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: #9ca3af;
}
.todo-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
</style>
运行应用
# 开发模式
npm run dev
# 构建生产版本
npm run build
# 预览生产构建
npm run preview
功能特点
- 现代化技术栈:使用 Vue 3 + Composition API + Pinia + Vue Router
- 响应式设计:适配各种屏幕尺寸
- 状态管理:使用 Pinia 进行集中状态管理
- 路由守卫:实现认证保护路由
- 错误处理:完善的错误处理和用户反馈
- 加载状态:友好的加载状态提示
- 组件化:高度可复用的组件设计
扩展建议
- 实时更新:集成 WebSocket 实现实时同步
- 离线支持:添加 PWA 支持
- 数据持久化:集成本地存储
- 主题切换:实现暗黑模式
- 国际化:添加多语言支持
- 单元测试:添加完整的测试套件
- TypeScript:迁移到 TypeScript 获得更好的类型安全