Appearance
第11章:基础实战项目(新手必练)
实战 1:个人博客网站
1. 需求分析与项目初始化
需求分析:
- 首页:展示文章列表,支持分页
- 文章列表页:展示分类文章,支持分页
- 文章详情页:展示文章内容,支持评论
- 关于页:展示博主信息
- 搜索功能:支持关键词检索
- 分类筛选:支持按分类筛选文章
项目初始化:
bash
# 使用 npx nuxi init 命令创建项目
npx nuxi init blog
# 进入项目目录
cd blog
# 安装依赖
npm install
# 启动开发服务器
npm run dev2. 路由与页面搭建(首页、列表页、详情页、关于页)
创建路由:
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. 完整代码讲解与优化
代码优化建议:
- 使用 Layout:创建统一的布局组件,避免在每个页面重复导航栏。
- 添加 Loading 状态:在数据加载时显示加载状态,提升用户体验。
- 添加错误处理:处理 API 请求失败的情况,显示错误信息。
- 使用 Composables:将重复的逻辑封装成 Composables,提高代码复用性。
- 添加动画效果:使用 Vue 的 transition 组件添加页面过渡动画。
- 优化 SEO:使用 useHead 钩子添加页面标题、描述等元标签。
创建布局组件:
vue
<!-- layouts/default.vue -->
<template>
<div>
<Navbar />
<main>
<slot />
</main>
<footer class="footer">
<div class="container">
<p>© 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 构建实际应用,包括:
个人博客网站:
- 项目初始化和路由搭建
- 数据获取与展示
- 搜索和分类功能
- 样式优化和响应式适配
待办事项应用:
- 组件拆分和状态管理
- 使用 useState 管理状态
- 使用 useCookie 持久化数据
- 自定义 Composables 封装逻辑
这些实战项目涵盖了 Nuxt.js 的核心功能,如路由系统、数据获取、状态管理、组件化开发等。通过实践,你应该能够更深入地理解和使用 Nuxt.js 构建各种类型的应用。
在接下来的章节中,我们将学习企业级进阶实战,帮助你进一步提升 Nuxt.js 开发技能。
