Skip to content

第 6 章:文章列表功能

约 2987 字大约 10 分钟

2025-12-19

本章学习目标

  • 理解 Nuxt 文件路由系统
  • 创建后端文章列表 API
  • 前端展示文章列表
  • 学习组件拆分与复用
  • 🎉 成果:博客文章列表页!

个人主页做好了,但博客没有文章怎么行?这一章我们来实现文章列表功能!

本章结束后你会得到什么

一个文章列表页,可以看到所有文章的标题、摘要和发布时间——博客的雏形出现了!

6.1 Nuxt 文件路由系统

6.1.1 什么是文件路由?

在传统的 Web 开发中,你需要手动配置路由:

// 传统方式:手动配置每个路由
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/blog', component: Blog },
]

但在 Nuxt 中,文件就是路由!你只需要在 pages 目录下创建文件,Nuxt 会自动生成对应的路由。

pages/
├── index.vue        →  /
├── about.vue        →  /about
└── blog/
    ├── index.vue    →  /blog
    └── [id].vue     →  /blog/123, /blog/456, ...

约定优于配置

这就是 Nuxt 的"约定优于配置"理念:遵循约定,少写配置。

文件名就是路由路径,简单直观!

6.1.2 路由类型速查

文件路径生成的路由说明
pages/index.vue/首页
pages/about.vue/about普通页面
pages/blog/index.vue/blog目录首页
pages/blog/[id].vue/blog/:id动态路由(下章详讲)
pages/blog/[...slug].vue/blog/*捕获所有路由

6.1.3 我们的博客路由规划

pages/
├── index.vue           →  /           首页(个人主页)
├── about.vue           →  /about      关于页面
└── blog/
    ├── index.vue       →  /blog       文章列表 ← 本章要做的!
    └── [id].vue        →  /blog/123   文章详情 ← 下章要做的

6.2 创建博客列表页面

6.2.1 创建 blog 目录和页面

frontend/app/pages/ 目录下,创建 blog 文件夹,然后创建 index.vue

<!-- frontend/app/pages/blog/index.vue -->
<template>
  <div>
    <h1 class="text-3xl font-bold text-gray-800 mb-8">
      📝 博客文章
    </h1>

    <p class="text-gray-600">
      这里将显示文章列表...
    </p>
  </div>
</template>

保存后,访问 http://localhost:3000/blog,你应该能看到这个页面了!

看到页面了吗?

如果看到了"博客文章"标题,说明路由已经自动生成。

如果看到 404,检查一下:

  1. 文件路径是否正确:app/pages/blog/index.vue
  2. 开发服务器是否在运行

6.2.2 更新导航链接

之前我们在 Header 里写了 /blog 链接,现在它终于有页面了!

点击导航栏的"博客",应该能跳转到博客列表页。

6.3 后端文章列表 API

6.3.1 设计文章数据结构

一篇文章需要哪些信息?

{
    "id": 1,                          # 文章 ID
    "title": "我的第一篇博客",          # 标题
    "summary": "这是文章的摘要...",     # 摘要
    "content": "这是文章的正文...",     # 正文(列表页不需要)
    "author": "张三",                  # 作者
    "created_at": "2025-01-15",       # 创建时间
    "tags": ["Vue", "学习笔记"]        # 标签
}

6.3.2 创建文章列表 API

修改 backend/src/backend/__init__.py,添加文章相关的 API:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import random
from datetime import datetime, timedelta 

app = FastAPI()

# 配置跨域
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 问候语列表(之前的代码保留)
greetings = [
    "你好,这是来自后端的问候!",
    "欢迎来到全栈开发的世界!",
    "今天也要加油写代码哦!",
    "休息一下,喝杯水吧~",
    "代码写累了?出去走走!",
    "你已经很棒了,继续保持!",
]

# 模拟文章数据 #
articles = [
    {
        "id": 1,
        "title": "我的第一篇博客",
        "summary": "这是我学习全栈开发的第一篇博客,记录了从零开始搭建项目的过程。",
        "author": "博主",
        "created_at": "2025-01-15",
        "tags": ["入门", "全栈"]
    },
    {
        "id": 2,
        "title": "Vue 3 组合式 API 学习笔记",
        "summary": "深入理解 Vue 3 的 Composition API,包括 ref、reactive、computed 等核心概念。",
        "author": "博主",
        "created_at": "2025-01-18",
        "tags": ["Vue", "前端"]
    },
    {
        "id": 3,
        "title": "FastAPI 快速入门指南",
        "summary": "FastAPI 是一个现代、快速的 Python Web 框架,本文介绍其基本用法和最佳实践。",
        "author": "博主",
        "created_at": "2025-01-20",
        "tags": ["Python", "后端", "FastAPI"]
    },
    {
        "id": 4,
        "title": "Tailwind CSS 实战技巧",
        "summary": "分享一些 Tailwind CSS 的实用技巧,让你的页面开发更高效。",
        "author": "博主",
        "created_at": "2025-01-22",
        "tags": ["CSS", "前端", "Tailwind"]
    },
    {
        "id": 5,
        "title": "全栈项目部署实战",
        "summary": "从本地开发到线上部署,完整记录一个全栈项目的部署过程。",
        "author": "博主",
        "created_at": "2025-01-25",
        "tags": ["部署", "Docker", "DevOps"]
    },
]

@app.get("/")
async def root():
    return {"message": "Hello World!"}

@app.get("/api/greeting")
async def greeting():
    text = random.choice(greetings)
    return {"text": text}

@app.get("/api/hello/{name}")
async def hello(name: str):
    return {"message": f"你好,{name}!欢迎来到全栈世界!"}

@app.get("/api/greet")
async def greet(name: str = "朋友", mood: str = "开心"):
    moods = {
        "开心": "😄",
        "难过": "😢",
        "生气": "😠",
        "惊讶": "😲",
    }
    emoji = moods.get(mood, "😊")
    return {"message": f"{emoji} 你好,{name}!今天{mood}吗?"}

# 获取文章列表 #
@app.get("/api/articles")
async def get_articles():
    return {"articles": articles, "total": len(articles)}

# 获取单篇文章(下章会用到) #
@app.get("/api/articles/{article_id}")
async def get_article(article_id: int):
    for article in articles:
        if article["id"] == article_id:
            return article 
    return {"error": "文章不存在"}

6.3.3 测试 API

保存后,uvicorn 会自动重新加载。打开浏览器测试:

  • http://localhost:8000/api/articles - 应该看到文章列表
  • http://localhost:8000/api/articles/1 - 应该看到第一篇文章
  • http://localhost:8000/api/articles/999 - 应该看到错误信息

使用 FastAPI 文档

访问 http://localhost:8000/docs,你可以在交互式文档中测试这些 API!

6.4 前端对接 API

6.4.1 在博客页面获取数据

现在修改 frontend/app/pages/blog/index.vue,调用后端 API:

<!-- frontend/app/pages/blog/index.vue -->
<template>
  <div>
    <h1 class="text-3xl font-bold text-gray-800 mb-2">
      📝 博客文章
    </h1>
    <p class="text-gray-500 mb-8">
      共 {{ data?.total || 0 }} 篇文章
    </p>

    <!-- 加载状态 -->
    <div v-if="pending" class="text-center py-12">
      <p class="text-gray-500">⏳ 加载中...</p>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="text-center py-12">
      <p class="text-red-500">❌ 加载失败:{{ error.message }}</p>
      <button
        @click="refresh"
        class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
      >
        重试
      </button>
    </div>

    <!-- 文章列表 -->
    <div v-else class="space-y-6">
      <div
        v-for="article in data?.articles"
        :key="article.id"
        class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-shadow"
      >
        <!-- 文章标题 -->
        <h2 class="text-xl font-bold text-gray-800 mb-2 hover:text-indigo-600">
          <NuxtLink :to="`/blog/${article.id}`">
            {{ article.title }}
          </NuxtLink>
        </h2>

        <!-- 文章摘要 -->
        <p class="text-gray-600 mb-4">
          {{ article.summary }}
        </p>

        <!-- 文章元信息 -->
        <div class="flex items-center justify-between text-sm text-gray-500">
          <div class="flex items-center gap-4">
            <span>👤 {{ article.author }}</span>
            <span>📅 {{ article.created_at }}</span>
          </div>

          <!-- 标签 -->
          <div class="flex gap-2">
            <span
              v-for="tag in article.tags"
              :key="tag"
              class="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs"
            >
              {{ tag }}
            </span>
          </div>
        </div>
      </div>
    </div>

    <!-- 空状态 -->
    <div v-if="!pending && !error && data?.articles?.length === 0" class="text-center py-12">
      <p class="text-gray-500">📭 暂无文章</p>
    </div>
  </div>
</template>

<script setup>
// 获取文章列表
const { data, pending, error, refresh } = await useFetch('http://localhost:8000/api/articles')
</script>

6.4.2 查看效果

保存后访问 http://localhost:3000/blog,你应该能看到:

  • 5 篇文章的列表
  • 每篇文章显示标题、摘要、作者、日期和标签
  • 鼠标悬停时有阴影效果
  • 点击标题可以跳转(虽然详情页还没做)

🎉 看到文章列表了吗?

如果你看到了 5 篇文章,恭喜!前后端已经成功对接了!

这就是全栈开发的魅力:后端提供数据,前端展示数据,通过 API 连接起来。

6.5 文章卡片组件

6.5.1 为什么要拆分组件?

现在文章卡片的代码都写在页面里,有几个问题:

  1. 代码太长:页面文件变得臃肿
  2. 无法复用:如果首页也想显示文章,得复制代码
  3. 难以维护:修改卡片样式要在页面里找

让我们把文章卡片拆分成独立组件!

6.5.2 创建 ArticleCard 组件

frontend/app/components/ 目录下创建 ArticleCard.vue

<!-- frontend/app/components/ArticleCard.vue -->
<template>
  <article class="article-card">
    <!-- 文章标题 -->
    <h2 class="card-title">
      <NuxtLink :to="`/blog/${article.id}`">
        {{ article.title }}
      </NuxtLink>
    </h2>

    <!-- 文章摘要 -->
    <p class="card-summary">
      {{ article.summary }}
    </p>

    <!-- 文章元信息 -->
    <div class="card-meta">
      <div class="meta-left">
        <span class="meta-item">👤 {{ article.author }}</span>
        <span class="meta-item">📅 {{ article.created_at }}</span>
      </div>

      <!-- 标签 -->
      <div class="tags">
        <span
          v-for="tag in article.tags"
          :key="tag"
          class="tag"
        >
          {{ tag }}
        </span>
      </div>
    </div>
  </article>
</template>

<script setup>
// 定义组件的 props
defineProps({
  article: {
    type: Object,
    required: true
  }
})
</script>

<style scoped>
.article-card {
  background: white;
  border-radius: 1rem;
  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  padding: 1.5rem;
  transition: box-shadow 0.2s;
}

.article-card:hover {
  box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}

.card-title {
  font-size: 1.25rem;
  font-weight: bold;
  color: #1f2937;
  margin-bottom: 0.5rem;
}

.card-title a {
  text-decoration: none;
  color: inherit;
  transition: color 0.2s;
}

.card-title a:hover {
  color: #4f46e5;
}

.card-summary {
  color: #4b5563;
  margin-bottom: 1rem;
  line-height: 1.6;
}

.card-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 0.875rem;
  color: #6b7280;
}

.meta-left {
  display: flex;
  gap: 1rem;
}

.tags {
  display: flex;
  gap: 0.5rem;
}

.tag {
  padding: 0.25rem 0.5rem;
  background: #f3f4f6;
  color: #4b5563;
  border-radius: 0.25rem;
  font-size: 0.75rem;
}
</style>

为什么这里用原生 CSS?

还记得第 5 章讲的吗?可复用的组件样式用原生 CSS

ArticleCard 是一个会被多处使用的组件,用原生 CSS 更容易维护和统一风格。

6.5.3 使用 ArticleCard 组件

现在简化 frontend/app/pages/blog/index.vue

<!-- frontend/app/pages/blog/index.vue -->
<template>
  <div>
    <h1 class="text-3xl font-bold text-gray-800 mb-2">
      📝 博客文章
    </h1>
    <p class="text-gray-500 mb-8">
      共 {{ data?.total || 0 }} 篇文章
    </p>

    <!-- 加载状态 -->
    <div v-if="pending" class="text-center py-12">
      <p class="text-gray-500">⏳ 加载中...</p>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="text-center py-12">
      <p class="text-red-500">❌ 加载失败:{{ error.message }}</p>
      <button
        @click="refresh"
        class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
      >
        重试
      </button>
    </div>

    <!-- 文章列表 -->
    <div v-else class="space-y-6">
      <ArticleCard  <!-- [!code focus] -->
        v-for="article in data?.articles" 
        :key="article.id" 
        :article="article" 
      /> 
    </div>

    <!-- 空状态 -->
    <div v-if="!pending && !error && data?.articles?.length === 0" class="text-center py-12">
      <p class="text-gray-500">📭 暂无文章</p>
    </div>
  </div>
</template>

<script setup>
// 获取文章列表
const { data, pending, error, refresh } = await useFetch('http://localhost:8000/api/articles')
</script>

看!文章列表部分从几十行变成了 5 行,清爽多了!

6.5.4 在首页展示最新文章

组件化的好处:现在可以轻松在首页也展示文章了!

修改 frontend/app/pages/index.vue,在项目展示,ProjectsSection后面添加最新文章:

<!-- 在 ProjectsSection 后面添加 -->

<!-- 分割线 -->
<hr class="border-gray-200 my-8" />

<!-- 最新文章 -->
<section class="py-12">
  <h2 class="text-2xl font-bold text-gray-800 text-center mb-8">
    📝 最新文章
  </h2>

  <div v-if="articlesPending" class="text-center">
    <p class="text-gray-500">加载中...</p>
  </div>

  <div v-else class="space-y-4">
    <ArticleCard
      v-for="article in latestArticles"
      :key="article.id"
      :article="article"
    />
  </div>

  <div class="text-center mt-8">
    <NuxtLink
      to="/blog"
      class="inline-block px-6 py-2 border-2 border-indigo-600 text-indigo-600 rounded-lg hover:bg-indigo-600 hover:text-white transition"
    >
      查看全部文章 →
    </NuxtLink>
  </div>
</section>

同时在 <script setup> 中添加:

<script setup>
// ... 其他代码 ...

// 获取最新文章(只取前 3 篇)
const { data: articlesData, pending: articlesPending } = await useFetch('http://localhost:8000/api/articles')
const latestArticles = computed(() => articlesData.value?.articles?.slice(0, 3) || [])
</script>

组件复用的力量

同一个 ArticleCard 组件,现在在两个地方使用:

  • /blog 页面:显示所有文章
  • / 首页:显示最新 3 篇

如果要修改卡片样式,只需要改 ArticleCard.vue 一个文件!

6.6 小结

本章回顾

  • ✅ 理解了 Nuxt 文件路由系统(文件即路由)
  • ✅ 创建了 /blog 博客列表页面
  • ✅ 后端实现了 /api/articles 文章列表 API
  • ✅ 前端使用 useFetch 获取并展示文章
  • ✅ 将文章卡片拆分为可复用的 ArticleCard 组件
  • ✅ 在首页复用组件展示最新文章

动手练习

试着扩展一下:

  1. 添加更多文章:在后端 articles 列表中添加几篇新文章
  2. 美化卡片:给 ArticleCard 添加封面图片(可以用占位图)
  3. 添加分类筛选:在博客页面添加按标签筛选的功能
  4. 显示阅读时间:根据文章长度计算预估阅读时间

这些练习能帮你更好地理解组件化和 API 设计。

下一章预告

文章列表有了,但点进去看不到内容?而且数据都是"假的",后台服务器一重启就没了。

下一章我们要:

  • 引入数据库,让文章数据持久化
  • 实现文章详情页面,点击可以查看完整内容

准备好让你的博客"真正"存储数据了吗?🗄️