Skip to content

第11章:基础实战项目(新手必练)

实战 1:个人博客网站

1. 需求分析与项目初始化

需求分析

  • 首页:展示文章列表,支持分页
  • 文章列表页:展示分类文章,支持分页
  • 文章详情页:展示文章内容,支持评论
  • 关于页:展示博主信息
  • 搜索功能:支持关键词检索
  • 分类筛选:支持按分类筛选文章

项目初始化

bash
# 使用 npx nuxi init 命令创建项目
npx nuxi init blog

# 进入项目目录
cd blog

# 安装依赖
npm install

# 启动开发服务器
npm run dev

2. 路由与页面搭建(首页、列表页、详情页、关于页)

创建路由

bash
# 创建页面目录
mkdir -p pages posts

# 创建首页
mkdir -p pages
# 创建文章列表页
mkdir -p pages/posts
# 创建文章详情页
mkdir -p pages/posts/[id]
# 创建关于页
mkdir -p pages/about

创建首页

vue
<!-- pages/index.vue -->
<template>
  <div class="home">
    <h1>My Blog</h1>
    <div class="posts">
      <div v-for="post in posts" :key="post.id" class="post-card">
        <h2>{{ post.title }}</h2>
        <p>{{ post.excerpt }}</p>
        <NuxtLink :to="`/posts/${post.id}`">Read More</NuxtLink>
      </div>
    </div>
    <div class="pagination">
      <button @click="prevPage" :disabled="page === 1">Previous</button>
      <span>Page {{ page }} of {{ totalPages }}</span>
      <button @click="nextPage" :disabled="page === totalPages">Next</button>
    </div>
  </div>
</template>

<script setup>
const page = ref(1)
const posts = ref([])
const totalPages = ref(1)

async function fetchPosts() {
  const { data } = await useAsyncData(`posts-${page.value}`, () => {
    return $fetch(`/api/posts?page=${page.value}&limit=10`)
  })
  posts.value = data.value.posts
  totalPages.value = data.value.totalPages
}

function prevPage() {
  if (page.value > 1) {
    page.value--
  }
}

function nextPage() {
  if (page.value < totalPages.value) {
    page.value++
  }
}

watch(page, fetchPosts)
onMounted(fetchPosts)
</script>

<style scoped>
.home {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.posts {
  margin: 2rem 0;
}

.post-card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1.5rem;
  margin-bottom: 1.5rem;
  transition: all 0.3s;
}

.post-card:hover {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

.post-card h2 {
  margin-top: 0;
  color: #333;
}

.post-card p {
  color: #666;
  margin-bottom: 1rem;
}

.pagination {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 2rem;
}

button {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

button:hover {
  background-color: #e9ecef;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}
</style>

创建文章详情页

vue
<!-- pages/posts/[id].vue -->
<template>
  <div class="post-detail">
    <h1>{{ post.title }}</h1>
    <div class="meta">
      <span>{{ post.date }}</span>
      <span>{{ post.category }}</span>
    </div>
    <div class="content" v-html="post.content"></div>
    <div class="comments">
      <h2>Comments</h2>
      <div v-for="comment in comments" :key="comment.id" class="comment">
        <h3>{{ comment.author }}</h3>
        <p>{{ comment.content }}</p>
        <span>{{ comment.date }}</span>
      </div>
      <form @submit.prevent="submitComment">
        <div>
          <label for="author">Name</label>
          <input type="text" id="author" v-model="commentForm.author" />
        </div>
        <div>
          <label for="content">Comment</label>
          <textarea id="content" v-model="commentForm.content"></textarea>
        </div>
        <button type="submit">Submit Comment</button>
      </form>
    </div>
  </div>
</template>

<script setup>
const route = useRoute()
const post = ref({})
const comments = ref([])
const commentForm = ref({ author: '', content: '' })

async function fetchPost() {
  const { data } = await useAsyncData(`post-${route.params.id}`, () => {
    return $fetch(`/api/posts/${route.params.id}`)
  })
  post.value = data.value
}

async function fetchComments() {
  const { data } = await useAsyncData(`comments-${route.params.id}`, () => {
    return $fetch(`/api/posts/${route.params.id}/comments`)
  })
  comments.value = data.value
}

async function submitComment() {
  await $fetch(`/api/posts/${route.params.id}/comments`, {
    method: 'POST',
    body: commentForm.value
  })
  commentForm.value = { author: '', content: '' }
  await fetchComments()
}

onMounted(() => {
  fetchPost()
  fetchComments()
})
</script>

<style scoped>
.post-detail {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.meta {
  display: flex;
  gap: 1rem;
  margin: 1rem 0;
  color: #666;
  font-size: 0.9rem;
}

.content {
  margin: 2rem 0;
  line-height: 1.6;
  color: #333;
}

.comments {
  margin-top: 3rem;
}

.comment {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1rem;
  margin-bottom: 1rem;
}

.comment h3 {
  margin-top: 0;
  color: #333;
}

.comment p {
  color: #666;
  margin: 0.5rem 0;
}

.comment span {
  font-size: 0.8rem;
  color: #999;
}

form {
  margin-top: 2rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

input, textarea {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

textarea {
  resize: vertical;
  min-height: 100px;
}

button {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

button:hover {
  background-color: #e9ecef;
}
</style>

创建关于页

vue
<!-- pages/about.vue -->
<template>
  <div class="about">
    <h1>About Me</h1>
    <div class="profile">
      <img src="/avatar.jpg" alt="Avatar" class="avatar" />
      <div class="info">
        <h2>John Doe</h2>
        <p>Web Developer & Blogger</p>
        <p>I'm a web developer with a passion for creating beautiful and functional websites. I love sharing my knowledge and experiences through blogging.</p>
        <div class="social">
          <a href="#">Twitter</a>
          <a href="#">GitHub</a>
          <a href="#">LinkedIn</a>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
// 内容
</script>

<style scoped>
.about {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}

.profile {
  display: flex;
  gap: 2rem;
  margin: 2rem 0;
}

.avatar {
  width: 200px;
  height: 200px;
  border-radius: 50%;
  object-fit: cover;
}

.info h2 {
  margin-top: 0;
  color: #333;
}

.info p {
  color: #666;
  margin: 1rem 0;
  line-height: 1.6;
}

.social {
  display: flex;
  gap: 1rem;
  margin-top: 1rem;
}

.social a {
  color: #007bff;
  text-decoration: none;
  transition: color 0.3s;
}

.social a:hover {
  color: #0056b3;
}

@media (max-width: 768px) {
  .profile {
    flex-direction: column;
    align-items: center;
    text-align: center;
  }
}
</style>

3. 数据获取与展示(文章列表、分页、详情渲染)

创建 API 接口

typescript
// server/api/posts.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = parseInt(query.page as string) || 1
  const limit = parseInt(query.limit as string) || 10
  
  // 模拟数据
  const posts = [
    {
      id: '1',
      title: 'Getting Started with Nuxt.js',
      excerpt: 'Learn the basics of Nuxt.js and how to create your first project.',
      content: '<p>Nuxt.js is a powerful framework for building Vue.js applications...</p>',
      date: '2024-01-01',
      category: 'Nuxt.js'
    },
    {
      id: '2',
      title: 'Understanding Vue 3 Composition API',
      excerpt: 'Explore the new Composition API in Vue 3 and how to use it effectively.',
      content: '<p>The Composition API is a new feature in Vue 3 that allows you to...</p>',
      date: '2024-01-02',
      category: 'Vue.js'
    },
    {
      id: '3',
      title: 'Building Responsive Websites with Tailwind CSS',
      excerpt: 'Learn how to create responsive websites using Tailwind CSS.',
      content: '<p>Tailwind CSS is a utility-first CSS framework that makes it easy to...</p>',
      date: '2024-01-03',
      category: 'CSS'
    }
  ]
  
  const totalPosts = posts.length
  const totalPages = Math.ceil(totalPosts / limit)
  const startIndex = (page - 1) * limit
  const endIndex = startIndex + limit
  const paginatedPosts = posts.slice(startIndex, endIndex)
  
  return {
    posts: paginatedPosts,
    totalPages
  }
})
typescript
// server/api/posts/[id].ts
export default defineEventHandler(async (event) => {
  const id = event.context.params?.id
  
  // 模拟数据
  const posts = [
    {
      id: '1',
      title: 'Getting Started with Nuxt.js',
      excerpt: 'Learn the basics of Nuxt.js and how to create your first project.',
      content: '<p>Nuxt.js is a powerful framework for building Vue.js applications. It provides a lot of features out of the box, such as server-side rendering, static site generation, and automatic routing.</p><p>In this tutorial, we will learn how to create a new Nuxt.js project, understand the project structure, and build a simple application.</p>',
      date: '2024-01-01',
      category: 'Nuxt.js'
    },
    {
      id: '2',
      title: 'Understanding Vue 3 Composition API',
      excerpt: 'Explore the new Composition API in Vue 3 and how to use it effectively.',
      content: '<p>The Composition API is a new feature in Vue 3 that allows you to organize your component logic in a more flexible way. It replaces the Options API and provides a more functional approach to building components.</p><p>In this tutorial, we will learn how to use the Composition API to create components, manage state, and handle side effects.</p>',
      date: '2024-01-02',
      category: 'Vue.js'
    },
    {
      id: '3',
      title: 'Building Responsive Websites with Tailwind CSS',
      excerpt: 'Learn how to create responsive websites using Tailwind CSS.',
      content: '<p>Tailwind CSS is a utility-first CSS framework that makes it easy to create responsive websites. It provides a set of utility classes that you can use to style your components without writing custom CSS.</p><p>In this tutorial, we will learn how to install and configure Tailwind CSS, and how to use it to create a responsive website.</p>',
      date: '2024-01-03',
      category: 'CSS'
    }
  ]
  
  const post = posts.find(p => p.id === id)
  
  if (!post) {
    throw createError({
      statusCode: 404,
      message: 'Post not found'
    })
  }
  
  return post
})
typescript
// server/api/posts/[id]/comments.ts
export default defineEventHandler(async (event) => {
  const id = event.context.params?.id
  
  if (event.method === 'GET') {
    // 模拟数据
    const comments = [
      {
        id: '1',
        postId: id,
        author: 'Jane Doe',
        content: 'Great post! Very informative.',
        date: '2024-01-01'
      },
      {
        id: '2',
        postId: id,
        author: 'John Smith',
        content: 'Thanks for sharing this knowledge.',
        date: '2024-01-02'
      }
    ]
    
    return comments
  } else if (event.method === 'POST') {
    const body = await readBody(event)
    const newComment = {
      id: Date.now().toString(),
      postId: id,
      author: body.author,
      content: body.content,
      date: new Date().toISOString().split('T')[0]
    }
    
    // 这里应该保存到数据库,现在只是返回模拟数据
    return newComment
  }
})

4. 搜索功能实现(关键词检索、分类筛选)

创建搜索组件

vue
<!-- components/SearchBar.vue -->
<template>
  <div class="search-bar">
    <input 
      type="text" 
      v-model="searchQuery" 
      placeholder="Search posts..."
      @keyup.enter="handleSearch"
    />
    <button @click="handleSearch">Search</button>
  </div>
</template>

<script setup>
const searchQuery = ref('')
const emit = defineEmits(['search'])

function handleSearch() {
  emit('search', searchQuery.value)
}
</script>

<style scoped>
.search-bar {
  display: flex;
  gap: 0.5rem;
  margin: 2rem 0;
}

input {
  flex: 1;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

button:hover {
  background-color: #e9ecef;
}
</style>

在首页使用搜索组件

vue
<!-- pages/index.vue -->
<template>
  <div class="home">
    <h1>My Blog</h1>
    <SearchBar @search="handleSearch" />
    <div class="categories">
      <button 
        v-for="category in categories" 
        :key="category"
        :class="{ active: selectedCategory === category }"
        @click="selectCategory(category)"
      >
        {{ category }}
      </button>
    </div>
    <div class="posts">
      <div v-for="post in posts" :key="post.id" class="post-card">
        <h2>{{ post.title }}</h2>
        <p>{{ post.excerpt }}</p>
        <NuxtLink :to="`/posts/${post.id}`">Read More</NuxtLink>
      </div>
    </div>
    <div class="pagination">
      <button @click="prevPage" :disabled="page === 1">Previous</button>
      <span>Page {{ page }} of {{ totalPages }}</span>
      <button @click="nextPage" :disabled="page === totalPages">Next</button>
    </div>
  </div>
</template>

<script setup>
const page = ref(1)
const posts = ref([])
const totalPages = ref(1)
const searchQuery = ref('')
const selectedCategory = ref('All')
const categories = ['All', 'Nuxt.js', 'Vue.js', 'CSS']

async function fetchPosts() {
  const { data } = await useAsyncData(`posts-${page.value}-${searchQuery.value}-${selectedCategory.value}`, () => {
    return $fetch(`/api/posts?page=${page.value}&limit=10&search=${searchQuery.value}&category=${selectedCategory.value}`)
  })
  posts.value = data.value.posts
  totalPages.value = data.value.totalPages
}

function handleSearch(query) {
  searchQuery.value = query
  page.value = 1
  fetchPosts()
}

function selectCategory(category) {
  selectedCategory.value = category
  page.value = 1
  fetchPosts()
}

function prevPage() {
  if (page.value > 1) {
    page.value--
  }
}

function nextPage() {
  if (page.value < totalPages.value) {
    page.value++
  }
}

watch(page, fetchPosts)
onMounted(fetchPosts)
</script>

<style scoped>
/* 样式与之前相同,添加以下样式 */
.categories {
  display: flex;
  gap: 0.5rem;
  margin: 1rem 0;
  flex-wrap: wrap;
}

.categories button {
  padding: 0.3rem 0.8rem;
  border: 1px solid #ddd;
  border-radius: 20px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

.categories button:hover {
  background-color: #e9ecef;
}

.categories button.active {
  background-color: #007bff;
  color: white;
  border-color: #007bff;
}
</style>

更新 API 接口支持搜索和分类

typescript
// server/api/posts.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = parseInt(query.page as string) || 1
  const limit = parseInt(query.limit as string) || 10
  const search = query.search as string || ''
  const category = query.category as string || 'All'
  
  // 模拟数据
  const posts = [
    {
      id: '1',
      title: 'Getting Started with Nuxt.js',
      excerpt: 'Learn the basics of Nuxt.js and how to create your first project.',
      content: '<p>Nuxt.js is a powerful framework for building Vue.js applications...</p>',
      date: '2024-01-01',
      category: 'Nuxt.js'
    },
    {
      id: '2',
      title: 'Understanding Vue 3 Composition API',
      excerpt: 'Explore the new Composition API in Vue 3 and how to use it effectively.',
      content: '<p>The Composition API is a new feature in Vue 3 that allows you to...</p>',
      date: '2024-01-02',
      category: 'Vue.js'
    },
    {
      id: '3',
      title: 'Building Responsive Websites with Tailwind CSS',
      excerpt: 'Learn how to create responsive websites using Tailwind CSS.',
      content: '<p>Tailwind CSS is a utility-first CSS framework that makes it easy to...</p>',
      date: '2024-01-03',
      category: 'CSS'
    }
  ]
  
  // 过滤搜索和分类
  let filteredPosts = posts
  if (search) {
    filteredPosts = filteredPosts.filter(post => 
      post.title.toLowerCase().includes(search.toLowerCase()) || 
      post.excerpt.toLowerCase().includes(search.toLowerCase())
    )
  }
  if (category !== 'All') {
    filteredPosts = filteredPosts.filter(post => post.category === category)
  }
  
  const totalPosts = filteredPosts.length
  const totalPages = Math.ceil(totalPosts / limit)
  const startIndex = (page - 1) * limit
  const endIndex = startIndex + limit
  const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
  
  return {
    posts: paginatedPosts,
    totalPages
  }
})

5. 样式优化与响应式适配

添加全局样式

css
/* assets/css/global.css */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f5f5f5;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}

a {
  color: #007bff;
  text-decoration: none;
  transition: color 0.3s;
}

a:hover {
  color: #0056b3;
  text-decoration: underline;
}

button {
  cursor: pointer;
  font-family: inherit;
}

/* 响应式断点 */
@media (max-width: 768px) {
  .container {
    padding: 0 0.5rem;
  }
}

nuxt.config.ts 中配置全局样式

typescript
export default defineNuxtConfig({
  css: ['~/assets/css/global.css']
})

添加导航栏组件

vue
<!-- components/Navbar.vue -->
<template>
  <nav class="navbar">
    <div class="container">
      <NuxtLink to="/" class="logo">My Blog</NuxtLink>
      <div class="nav-links">
        <NuxtLink to="/" class="nav-link">Home</NuxtLink>
        <NuxtLink to="/about" class="nav-link">About</NuxtLink>
      </div>
    </div>
  </nav>
</template>

<script setup>
// 内容
</script>

<style scoped>
.navbar {
  background-color: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  position: sticky;
  top: 0;
  z-index: 100;
}

.container {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  max-width: 1200px;
  margin: 0 auto;
}

.logo {
  font-size: 1.5rem;
  font-weight: bold;
  color: #333;
  text-decoration: none;
}

.nav-links {
  display: flex;
  gap: 1.5rem;
}

.nav-link {
  color: #333;
  text-decoration: none;
  transition: color 0.3s;
}

.nav-link:hover {
  color: #007bff;
}

@media (max-width: 768px) {
  .container {
    flex-direction: column;
    gap: 1rem;
    align-items: flex-start;
  }
  
  .nav-links {
    width: 100%;
    justify-content: space-between;
  }
}
</style>

在页面中使用导航栏

vue
<!-- pages/index.vue -->
<template>
  <div>
    <Navbar />
    <div class="home">
      <!-- 内容 -->
    </div>
  </div>
</template>

6. 完整代码讲解与优化

代码优化建议

  1. 使用 Layout:创建统一的布局组件,避免在每个页面重复导航栏。
  2. 添加 Loading 状态:在数据加载时显示加载状态,提升用户体验。
  3. 添加错误处理:处理 API 请求失败的情况,显示错误信息。
  4. 使用 Composables:将重复的逻辑封装成 Composables,提高代码复用性。
  5. 添加动画效果:使用 Vue 的 transition 组件添加页面过渡动画。
  6. 优化 SEO:使用 useHead 钩子添加页面标题、描述等元标签。

创建布局组件

vue
<!-- layouts/default.vue -->
<template>
  <div>
    <Navbar />
    <main>
      <slot />
    </main>
    <footer class="footer">
      <div class="container">
        <p>&copy; 2024 My Blog. All rights reserved.</p>
      </div>
    </footer>
  </div>
</template>

<script setup>
// 内容
</script>

<style scoped>
.footer {
  background-color: #333;
  color: white;
  padding: 2rem 0;
  margin-top: 2rem;
}

.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
  text-align: center;
}
</style>

在页面中使用布局

vue
<!-- pages/index.vue -->
<template>
  <div class="home">
    <h1>My Blog</h1>
    <!-- 内容 -->
  </div>
</template>

<script setup>
// 内容
</script>

<script>
export default {
  layout: 'default'
}
</script>

实战 2:待办事项(TodoList)应用

1. 项目搭建与组件拆分

项目初始化

bash
# 使用 npx nuxi init 命令创建项目
npx nuxi init todo-app

# 进入项目目录
cd todo-app

# 安装依赖
npm install

# 启动开发服务器
npm run dev

组件拆分

  • TodoList.vue:待办事项列表组件
  • TodoItem.vue:单个待办事项组件
  • AddTodo.vue:添加待办事项组件
  • Filter.vue:筛选待办事项组件

2. useState 状态管理(新增、删除、修改、勾选任务)

创建主页面

vue
<!-- pages/index.vue -->
<template>
  <div class="todo-app">
    <h1>Todo List</h1>
    <AddTodo @add="addTodo" />
    <Filter 
      :filter="filter" 
      @update:filter="filter = $event"
    />
    <TodoList 
      :todos="filteredTodos" 
      @toggle="toggleTodo" 
      @delete="deleteTodo" 
      @edit="editTodo"
    />
    <div class="stats">
      <span>{{ remaining }} items left</span>
      <button @click="clearCompleted">Clear completed</button>
    </div>
  </div>
</template>

<script setup>
const todos = useState('todos', () => [
  { id: 1, text: 'Learn Nuxt.js', completed: false },
  { id: 2, text: 'Build a Todo app', completed: true },
  { id: 3, text: 'Deploy to production', completed: false }
])

const filter = ref('all')

const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(todo => !todo.completed)
    case 'completed':
      return todos.value.filter(todo => todo.completed)
    default:
      return todos.value
  }
})

const remaining = computed(() => {
  return todos.value.filter(todo => !todo.completed).length
})

function addTodo(text) {
  const newTodo = {
    id: Date.now(),
    text,
    completed: false
  }
  todos.value.push(newTodo)
}

function toggleTodo(id) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.completed = !todo.completed
  }
}

function deleteTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function editTodo(id, text) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.text = text
  }
}

function clearCompleted() {
  todos.value = todos.value.filter(todo => !todo.completed)
}
</script>

<style scoped>
.todo-app {
  max-width: 600px;
  margin: 2rem auto;
  padding: 2rem;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 2rem;
}

.stats {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 2rem;
  padding-top: 1rem;
  border-top: 1px solid #eee;
  color: #666;
  font-size: 0.9rem;
}

button {
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
  transition: color 0.3s;
}

button:hover {
  color: #333;
  text-decoration: underline;
}
</style>

创建 AddTodo 组件

vue
<!-- components/AddTodo.vue -->
<template>
  <form @submit.prevent="handleSubmit" class="add-todo">
    <input 
      type="text" 
      v-model="text" 
      placeholder="What needs to be done?"
      class="todo-input"
      @keyup.enter="handleSubmit"
    />
    <button type="submit" class="add-button">Add</button>
  </form>
</template>

<script setup>
const text = ref('')
const emit = defineEmits(['add'])

function handleSubmit() {
  if (text.value.trim()) {
    emit('add', text.value.trim())
    text.value = ''
  }
}
</script>

<style scoped>
.add-todo {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
}

.todo-input {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.add-button {
  padding: 0.75rem 1.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

.add-button:hover {
  background-color: #e9ecef;
}
</style>

创建 TodoList 组件

vue
<!-- components/TodoList.vue -->
<template>
  <ul class="todo-list">
    <TodoItem 
      v-for="todo in todos" 
      :key="todo.id"
      :todo="todo"
      @toggle="$emit('toggle', todo.id)"
      @delete="$emit('delete', todo.id)"
      @edit="$emit('edit', todo.id, $event)"
    />
    <li v-if="todos.length === 0" class="empty">
      No todos yet. Add one above!
    </li>
  </ul>
</template>

<script setup>
defineProps({
  todos: {
    type: Array,
    required: true
  }
})

defineEmits(['toggle', 'delete', 'edit'])
</script>

<style scoped>
.todo-list {
  list-style: none;
  margin: 1.5rem 0;
}

.empty {
  text-align: center;
  padding: 2rem;
  color: #999;
  font-style: italic;
}
</style>

创建 TodoItem 组件

vue
<!-- components/TodoItem.vue -->
<template>
  <li class="todo-item">
    <input 
      type="checkbox" 
      :checked="todo.completed" 
      @change="$emit('toggle')"
      class="checkbox"
    />
    <div v-if="!editing" class="todo-text" @dblclick="startEditing">
      {{ todo.text }}
    </div>
    <input 
      v-else 
      type="text" 
      v-model="editText" 
      @blur="finishEditing" 
      @keyup.enter="finishEditing" 
      @keyup.esc="cancelEditing"
      class="edit-input"
      ref="editInput"
    />
    <button @click="$emit('delete')" class="delete-button">
      ×
    </button>
  </li>
</template>

<script setup>
const props = defineProps({
  todo: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['toggle', 'delete', 'edit'])
const editing = ref(false)
const editText = ref('')
const editInput = ref(null)

function startEditing() {
  editing.value = true
  editText.value = props.todo.text
  // 在下一个 DOM 更新周期聚焦输入框
  setTimeout(() => {
    editInput.value?.focus()
  }, 0)
}

function finishEditing() {
  if (editText.value.trim()) {
    emit('edit', editText.value.trim())
  } else {
    emit('delete')
  }
  editing.value = false
}

function cancelEditing() {
  editing.value = false
  editText.value = props.todo.text
}
</script>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  padding: 1rem;
  border-bottom: 1px solid #eee;
  transition: all 0.3s;
}

.todo-item:hover {
  background-color: #f8f9fa;
}

.checkbox {
  margin-right: 1rem;
  cursor: pointer;
}

.todo-text {
  flex: 1;
  cursor: pointer;
}

.todo-text.completed {
  text-decoration: line-through;
  color: #999;
}

.edit-input {
  flex: 1;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.delete-button {
  background: none;
  border: none;
  font-size: 1.5rem;
  color: #999;
  cursor: pointer;
  transition: color 0.3s;
  padding: 0;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.delete-button:hover {
  color: #dc3545;
}
</style>

创建 Filter 组件

vue
<!-- components/Filter.vue -->
<template>
  <div class="filter">
    <button 
      :class="{ active: filter === 'all' }" 
      @click="$emit('update:filter', 'all')"
    >
      All
    </button>
    <button 
      :class="{ active: filter === 'active' }" 
      @click="$emit('update:filter', 'active')"
    >
      Active
    </button>
    <button 
      :class="{ active: filter === 'completed' }" 
      @click="$emit('update:filter', 'completed')"
    >
      Completed
    </button>
  </div>
</template>

<script setup>
defineProps({
  filter: {
    type: String,
    required: true
  }
})

defineEmits(['update:filter'])
</script>

<style scoped>
.filter {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1.5rem;
  justify-content: center;
}

button {
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  background-color: #f8f9fa;
  cursor: pointer;
  transition: all 0.3s;
}

button:hover {
  background-color: #e9ecef;
}

button.active {
  background-color: #007bff;
  color: white;
  border-color: #007bff;
}
</style>

3. 本地存储(useCookie 持久化数据)

修改主页面使用 useCookie

vue
<!-- pages/index.vue -->
<template>
  <div class="todo-app">
    <h1>Todo List</h1>
    <AddTodo @add="addTodo" />
    <Filter 
      :filter="filter" 
      @update:filter="filter = $event"
    />
    <TodoList 
      :todos="todos.value" 
      @toggle="toggleTodo" 
      @delete="deleteTodo" 
      @edit="editTodo"
    />
    <div class="stats">
      <span>{{ remaining }} items left</span>
      <button @click="clearCompleted">Clear completed</button>
    </div>
  </div>
</template>

<script setup>
const todos = useCookie('todos', {
  default: () => [
    { id: 1, text: 'Learn Nuxt.js', completed: false },
    { id: 2, text: 'Build a Todo app', completed: true },
    { id: 3, text: 'Deploy to production', completed: false }
  ],
  watch: true
})

const filter = ref('all')

const remaining = computed(() => {
  return todos.value.filter(todo => !todo.completed).length
})

function addTodo(text) {
  const newTodo = {
    id: Date.now(),
    text,
    completed: false
  }
  todos.value = [...todos.value, newTodo]
}

function toggleTodo(id) {
  todos.value = todos.value.map(todo => {
    if (todo.id === id) {
      return { ...todo, completed: !todo.completed }
    }
    return todo
  })
}

function deleteTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function editTodo(id, text) {
  todos.value = todos.value.map(todo => {
    if (todo.id === id) {
      return { ...todo, text }
    }
    return todo
  })
}

function clearCompleted() {
  todos.value = todos.value.filter(todo => !todo.completed)
}
</script>

<style scoped>
/* 样式与之前相同 */
</style>

4. 自定义 Composables 封装(任务操作逻辑)

创建 composables/useTodos.ts

typescript
// composables/useTodos.ts
export function useTodos() {
  const todos = useCookie('todos', {
    default: () => [
      { id: 1, text: 'Learn Nuxt.js', completed: false },
      { id: 2, text: 'Build a Todo app', completed: true },
      { id: 3, text: 'Deploy to production', completed: false }
    ],
    watch: true
  })

  const filter = ref('all')

  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'active':
        return todos.value.filter(todo => !todo.completed)
      case 'completed':
        return todos.value.filter(todo => todo.completed)
      default:
        return todos.value
    }
  })

  const remaining = computed(() => {
    return todos.value.filter(todo => !todo.completed).length
  })

  function addTodo(text) {
    const newTodo = {
      id: Date.now(),
      text,
      completed: false
    }
    todos.value = [...todos.value, newTodo]
  }

  function toggleTodo(id) {
    todos.value = todos.value.map(todo => {
      if (todo.id === id) {
        return { ...todo, completed: !todo.completed }
      }
      return todo
    })
  }

  function deleteTodo(id) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  function editTodo(id, text) {
    todos.value = todos.value.map(todo => {
      if (todo.id === id) {
        return { ...todo, text }
      }
      return todo
    })
  }

  function clearCompleted() {
    todos.value = todos.value.filter(todo => !todo.completed)
  }

  return {
    todos,
    filter,
    filteredTodos,
    remaining,
    addTodo,
    toggleTodo,
    deleteTodo,
    editTodo,
    clearCompleted
  }
}

使用自定义 Composables

vue
<!-- pages/index.vue -->
<template>
  <div class="todo-app">
    <h1>Todo List</h1>
    <AddTodo @add="addTodo" />
    <Filter 
      :filter="filter" 
      @update:filter="filter = $event"
    />
    <TodoList 
      :todos="filteredTodos" 
      @toggle="toggleTodo" 
      @delete="deleteTodo" 
      @edit="editTodo"
    />
    <div class="stats">
      <span>{{ remaining }} items left</span>
      <button @click="clearCompleted">Clear completed</button>
    </div>
  </div>
</template>

<script setup>
const { 
  todos, 
  filter, 
  filteredTodos, 
  remaining, 
  addTodo, 
  toggleTodo, 
  deleteTodo, 
  editTodo, 
  clearCompleted 
} = useTodos()
</script>

<style scoped>
/* 样式与之前相同 */
</style>

小结

本章介绍了两个基础实战项目:个人博客网站和待办事项(TodoList)应用。通过这些实战项目,你应该已经掌握了如何使用 Nuxt.js 构建实际应用,包括:

  1. 个人博客网站

    • 项目初始化和路由搭建
    • 数据获取与展示
    • 搜索和分类功能
    • 样式优化和响应式适配
  2. 待办事项应用

    • 组件拆分和状态管理
    • 使用 useState 管理状态
    • 使用 useCookie 持久化数据
    • 自定义 Composables 封装逻辑

这些实战项目涵盖了 Nuxt.js 的核心功能,如路由系统、数据获取、状态管理、组件化开发等。通过实践,你应该能够更深入地理解和使用 Nuxt.js 构建各种类型的应用。

在接下来的章节中,我们将学习企业级进阶实战,帮助你进一步提升 Nuxt.js 开发技能。

© 2026 编程马·菜鸟教程 版权所有