Vue.js 入门教程:构建待办事项管理应用

概述

从零开始构建一个完整的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

功能特点

  1. 现代化技术栈:使用 Vue 3 + Composition API + Pinia + Vue Router
  2. 响应式设计:适配各种屏幕尺寸
  3. 状态管理:使用 Pinia 进行集中状态管理
  4. 路由守卫:实现认证保护路由
  5. 错误处理:完善的错误处理和用户反馈
  6. 加载状态:友好的加载状态提示
  7. 组件化:高度可复用的组件设计

扩展建议

  1. 实时更新:集成 WebSocket 实现实时同步
  2. 离线支持:添加 PWA 支持
  3. 数据持久化:集成本地存储
  4. 主题切换:实现暗黑模式
  5. 国际化:添加多语言支持
  6. 单元测试:添加完整的测试套件
  7. TypeScript:迁移到 TypeScript 获得更好的类型安全

添加新评论