Skip to content

第 7 章:文章详情与数据库

约 5221 字大约 17 分钟

2025-12-19

本章学习目标

  • 理解为什么需要数据库
  • 学习 SQLite 数据库基础
  • 掌握 SQLAlchemy 模型定义
  • 实现动态路由和文章详情页
  • 🎉 成果:完整的博客系统,数据持久化!

文章列表有了,但点击文章标题却看不到内容?而且重启一下后端服务,数据就全没了?这一章我们要解决这两个问题——实现文章详情功能,并把数据存到真正的数据库里!

本章结束后你会得到什么

一个真正的全栈博客系统——文章数据存储在数据库中,重启服务也不会丢失,点击文章能看到完整内容!


7.1 为什么需要数据库

7.1.1 当前数据存储的问题

到目前为止,我们的文章数据都是写死在代码里的:

# backend/src/backend/__init__.py
articles = [
    {"id": 1, "title": "我的第一篇博客", ...},
    {"id": 2, "title": "Vue 3 学习笔记", ...},
    # ...
]

这种方式有什么问题?试试重启后端服务,或者关掉终端再打开——你会发现:

  • 数据丢失:服务器重启后,所有新增的数据都没了
  • 无法持久化:用户发布的文章无法保存
  • 难以管理:数据多了之后代码会变得臃肿
  • 性能瓶颈:数据量大时内存占用过高

这不是真正的应用!

想象一下,你写了一篇博客,结果服务器一重启就没了——这也太坑了吧!

解决方案:数据库!

7.1.2 数据库的优势

使用数据库后,我们的数据将:

优势说明
持久化存储重启服务数据不丢失
高效查询支持复杂的查询操作
数据安全支持事务和备份
易于扩展支持数据迁移和优化

7.2 SQLite 简介

7.2.1 为什么选择 SQLite?

历史趣事:SQLite 的诞生

SQLite 的作者 D. Richard Hipp 在 2000 年为美国海军开发导弹驱逐舰软件时,因为厌烦了 Informix 数据库的复杂配置(每次部署都要专人安装配置数据库服务器),决定自己写一个"不需要配置"的数据库。于是 SQLite 诞生了!

有趣的是,SQLite 如今是世界上部署最广泛的数据库——它存在于每一部智能手机、每一个浏览器、甚至飞机的飞行系统中。据估计,全球有超过一万亿个SQLite 数据库在运行!你的手机里可能就有上百个。

SQLite 是轻量级的文件型数据库,非常适合学习和小型项目:

特点说明优势
🚀 零配置无需安装数据库服务器开箱即用
📁 文件存储数据保存在单个文件中便于备份和迁移
🔧 简单易用标准的 SQL 语法学习成本低
💪 功能完整支持事务、索引等满足项目需求

什么时候用 SQLite?

  • ✅ 学习数据库概念
  • ✅ 个人项目、原型开发
  • ✅ 桌面应用、移动应用
  • ❌ 高并发的大型 Web 应用(这时候用 PostgreSQL 或 MySQL)

对于我们的博客项目,SQLite 是最佳选择!

7.2.2 数据库基本概念

在开始写代码之前,先了解几个核心概念:

概念类比说明
数据库Excel 文件存储数据的容器
表(Table)Excel 工作表存储特定类型的数据
字段(Column)表格的列数据的属性(如标题、作者)
记录(Row)表格的行一条具体的数据
主键(Primary Key)身份证号唯一标识一条记录

用 Excel 来理解:

┌─────────────────────────────────────────────────────────┐
│                    articles 表                          │
├────┬──────────────────┬──────────────┬─────────────────┤
│ id │      title       │    author    │   created_at    │  ← 字段
├────┼──────────────────┼──────────────┼─────────────────┤
│ 1  │ 我的第一篇博客    │     博主      │   2025-01-15   │  ← 记录
│ 2  │ Vue 3 学习笔记    │     博主      │   2025-01-18   │
│ 3  │ FastAPI 入门      │     博主      │   2025-01-20   │
└────┴──────────────────┴──────────────┴─────────────────┘

主键(唯一标识)

为什么需要主键?

就像每个人都有唯一的身份证号一样,数据库中的每条记录也需要一个唯一标识。这样我们才能准确地找到、修改或删除某一条特定的数据。

7.2.3 我们的文章表设计

根据博客的需求,文章表应该包含这些字段:

字段名类型说明
idINTEGER主键,唯一标识
titleTEXT文章标题
summaryTEXT文章摘要
contentTEXT文章正文(Markdown)
authorTEXT作者
created_atTEXT创建时间
tagsTEXT标签(逗号分隔)

7.3 SQLAlchemy 入门

7.3.1 什么是 ORM?

历史趣事:ORM 的由来

早期程序员需要直接写 SQL 语句来操作数据库,代码里到处都是字符串拼接,既容易出错,又有 SQL 注入的安全风险。于是有人想:能不能用编程语言的对象来表示数据库的表,用对象的属性来表示字段?

ORM(Object-Relational Mapping,对象关系映射)就是这样诞生的。它让你可以用 Python/Java/Ruby 等语言的对象来操作数据库,不用写 SQL!

SQLAlchemy 的作者 Mike Bayer 在 2005 年创建了这个项目,如今它已成为 Python 世界最流行的 ORM,被 Reddit、Dropbox、Yelp 等公司使用。

ORM 的核心思想

Python 类  ←→  数据库表
类的属性   ←→  表的字段
类的实例   ←→  表的记录

举个例子:

# 不用 ORM(直接写 SQL)— 容易出错,有安全风险
cursor.execute("INSERT INTO articles (title, author) VALUES ('Hello', 'Bob')")

# 用 ORM(像操作对象一样)— 简洁安全
article = Article(title="Hello", author="Bob")
db.add(article)

是不是清爽多了?

7.3.2 为什么选择 SQLAlchemy?

SQLAlchemy 是 Python 中最流行的 ORM 库:

  • 强大灵活:既可以用 ORM,也可以写原生 SQL
  • 多数据库支持:SQLite、MySQL、PostgreSQL 随意切换
  • 社区活跃:文档完善,问题容易找到答案

关于异步的说明

Q2: 既然异步这么好,为什么我们还选择同步方式

就像餐厅里的服务员一样:

同步方式:服务员接待一个客人后,要等厨师做好菜并端给客人,才能接待下一个客人。如果厨师做菜很慢,后面的客人就得一直等着。

异步方式:服务员接待一个客人并下单后,就去接待下一个客人,等厨师做好菜后,再回来给第一个客人上菜。这样服务员可以同时服务多个客人,效率更高!

在编程中,"异步"就是当一个任务需要等待时(比如从数据库读取数据、发送网络请求),程序不会一直等着,而是继续执行其他任务,等之前的任务完成后再回来处理结果。这样可以充分利用计算机的资源,处理更多的请求。

Q2: 既然异步这么好,为什么我们还选择同步方式

虽然 SQLAlchemy 支持同步和异步两种方式。本章我们使用同步方式,因为:

  1. 学习曲线更平缓
  2. 对于博客项目性能完全足够
  3. 代码更简洁易懂

异步的详细讲解会在第 13 章(性能优化)中介绍。放心,即使用同步方式,FastAPI 的性能也比传统框架强得多!

7.3.3 安装 SQLAlchemy

backend 目录下执行:

cd backend
pdm add sqlalchemy

确保在正确的目录

安装前确认你在 backend 目录下,不然包会装错地方!

7.4 创建数据库配置

7.4.1 创建 database.py

backend/src/backend/ 目录下创建 database.py

# backend/src/backend/database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

# 数据库文件路径(SQLite 使用文件存储)
SQLALCHEMY_DATABASE_URL = "sqlite:///./blog.db"

# 创建数据库引擎
engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False}  # SQLite 特有配置
)

# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# 创建模型基类
Base = declarative_base()


def get_db():
    """
    获取数据库会话的依赖函数
    使用 yield 确保会话在使用后正确关闭
    """
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

这些代码在干什么?

  • create_engine:创建数据库连接引擎
  • SessionLocal:用来创建数据库会话(可以理解为"数据库连接")
  • Base:所有数据模型的基类
  • get_db:FastAPI 的依赖注入函数,自动管理数据库连接的生命周期

7.4.2 理解数据库会话

什么是数据库会话?

想象你去银行办业务:

  1. 取号排队 → 创建会话(SessionLocal()
  2. 到窗口办理 → 执行数据库操作
  3. 办完离开 → 关闭会话(db.close()

get_db 函数就是自动帮你管理这个流程的!

7.5 定义数据模型

7.5.1 创建 models.py

backend/src/backend/ 目录下创建 models.py

# backend/src/backend/models.py
from sqlalchemy import Column, Integer, String, Text
from .database import Base


class Article(Base):
    """文章模型"""
    __tablename__ = "articles"  # 表名

    # 定义字段
    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(200), nullable=False)
    summary = Column(Text)
    content = Column(Text)
    author = Column(String(100), default="博主")
    created_at = Column(String(20))
    tags = Column(String(200))

    def to_dict(self):
        """将模型转换为字典(方便 API 返回)"""
        return {
            "id": self.id,
            "title": self.title,
            "summary": self.summary,
            "content": self.content,
            "author": self.author,
            "created_at": self.created_at,
            "tags": self.tags.split(",") if self.tags else []
        }

代码解释

  • __tablename__:指定数据库表的名字
  • Column:定义表的字段
  • primary_key=True:设置为主键
  • nullable=False:不允许为空
  • default="博主":默认值
  • to_dict():把模型对象转成字典,方便 JSON 序列化

7.5.2 字段类型对照

SQLAlchemy 类型对应 SQL 类型适用场景
IntegerINTEGER整数(如 id、年龄)
String(n)VARCHAR(n)短文本(如标题、用户名)
TextTEXT长文本(如文章内容)
FloatFLOAT浮点数(如价格)
BooleanBOOLEAN布尔值(如是否发布)
DateTimeDATETIME日期时间

7.6 重构后端 API

7.6.1 修改主文件

现在来修改 backend/src/backend/__init__.py,让它使用数据库:

# backend/src/backend/__init__.py
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
import random

from .database import engine, get_db, SessionLocal, Base
from .models import Article

# 创建数据库表(如果不存在)
Base.metadata.create_all(bind=engine)

app = FastAPI()

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

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


@app.on_event("startup")
def init_database():
    """应用启动时初始化示例数据"""
    db = SessionLocal()
    try:
        # 如果数据库为空,添加示例文章
        if db.query(Article).count() == 0:
            sample_articles = [
                Article(
                    title="我的第一篇博客",
                    summary="这是我学习全栈开发的第一篇博客,记录了从零开始搭建项目的过程。",
                    content="""# 我的第一篇博客

欢迎来到我的博客!这是我学习全栈开发的起点。

## 为什么要学全栈?

因为我想成为一个能独立完成项目的开发者!

## 学到了什么?

- 前端:Nuxt.js + Vue 3
- 后端:FastAPI + Python
- 数据库:SQLite + SQLAlchemy

期待后续的学习之旅!
""",
                    author="博主",
                    created_at="2025-01-15",
                    tags="入门,全栈"
                ),
                Article(
                    title="Vue 3 组合式 API 学习笔记",
                    summary="深入理解 Vue 3 的 Composition API,包括 ref、reactive、computed 等核心概念。",
                    content="""# Vue 3 组合式 API 学习笔记

Vue 3 引入了组合式 API,让代码组织更加灵活。

## ref vs reactive

- `ref`:用于基本类型
- `reactive`:用于对象和数组

## 为什么用组合式 API?

1. 更好的代码组织
2. 更好的类型推断
3. 更好的复用性

代码示例见正文...
""",
                    author="博主",
                    created_at="2025-01-18",
                    tags="Vue,前端"
                ),
                Article(
                    title="FastAPI 快速入门指南",
                    summary="FastAPI 是一个现代、快速的 Python Web 框架,本文介绍其基本用法和最佳实践。",
                    content="""# FastAPI 快速入门指南

FastAPI 是目前最热门的 Python Web 框架之一。

## 为什么选择 FastAPI?

- 🚀 性能极高
- 📝 自动生成文档
- ✅ 类型检查支持

## 第一个 API

```python
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"Hello": "World"}

就这么简单! """, author="博主", created_at="2025-01-20", tags="Python,后端,FastAPI" ), ] db.add_all(sample_articles) db.commit() print("✅ 示例数据初始化完成") finally: db.close()

@app.get("/") def root(): return

@app.get("/api/greeting") def greeting(): """随机返回一条问候语""" return

@app.get("/api/articles") def get_articles(db: Session = Depends(get_db)): """获取文章列表""" articles = db.query(Article).all() return { "articles": [article.to_dict() for article in articles], "total": len(articles) }

@app.get("/api/articles/{article_id}") def get_article(article_id: int, db: Session = Depends(get_db)): """获取单篇文章详情""" article = db.query(Article).filter(Article.id == article_id).first() if not article: raise HTTPException(status_code=404, detail="文章不存在") return article.to_dict()


::: tip Depends 是什么?
`Depends(get_db)` 是 FastAPI 的**依赖注入**机制。它会:
1. 自动调用 `get_db()` 获取数据库连接
2. 把连接传给 `db` 参数
3. 请求结束后自动关闭连接

你不需要手动管理数据库连接,FastAPI 帮你搞定了!
:::

### 7.6.2 测试数据库功能

1. **启动后端服务**:

```bash
cd backend
pdm run uvicorn src.backend:app --reload
  1. 检查数据库文件

运行后,backend 目录下应该出现 blog.db 文件——这就是你的数据库!

  1. 测试 API
  • 访问 http://localhost:8000/api/articles - 应该看到文章列表
  • 访问 http://localhost:8000/api/articles/1 - 应该看到第一篇文章
  • 访问 http://localhost:8000/docs - 可以在交互式文档中测试

看到数据了吗?

如果 API 返回了文章数据,恭喜你!数据库配置成功!

现在试试重启后端服务,再访问 API——数据还在!这就是数据库的威力!

7.7 Nuxt 动态路由

7.7.1 什么是动态路由?

在第 6 章,我们创建了 /blog 页面来显示文章列表。现在我们需要 /blog/1/blog/2 这样的页面来显示文章详情。

难道要为每篇文章创建一个文件?当然不用!

动态路由的概念

动态路由就是一个文件,匹配多个路径

  • /blog/1 → 显示 id=1 的文章
  • /blog/2 → 显示 id=2 的文章
  • /blog/999 → 显示 id=999 的文章

全部用同一个文件处理!

7.7.2 Nuxt 动态路由语法

在 Nuxt 中,用方括号 [] 表示动态参数:

pages/
├── blog/
│   ├── index.vue      → /blog          ← 文章列表(第 6 章)
│   └── [id].vue       → /blog/:id      ← 文章详情(本章)

路由匹配规则

URL匹配的文件获取的参数
/blogindex.vue
/blog/1[id].vueid = "1"
/blog/hello[id].vueid = "hello"

7.7.3 获取动态参数

在页面中,用 useRoute() 获取参数:

const route = useRoute()
const articleId = route.params.id  // 获取 URL 中的 id

7.8 创建文章详情页面

7.8.1 安装 Markdown 渲染库

文章内容是 Markdown 格式的,我们需要把它渲染成 HTML。

frontend 目录下安装 markdown-it

cd frontend
pnpm add markdown-it

为什么用 markdown-it?

markdown-it 是一个快速、可扩展的 Markdown 解析器。它可以把 Markdown 文本转换成 HTML,还支持各种插件(代码高亮、数学公式等)。

7.8.2 创建详情页面

frontend/app/pages/blog/ 目录下创建 [id].vue

<!-- frontend/app/pages/blog/[id].vue -->
<template>
  <div class="article-page">
    <!-- 加载状态 -->
    <div v-if="pending" class="loading-state">
      <div class="loading-spinner"></div>
      <p>正在加载文章...</p>
    </div>

    <!-- 错误状态 -->
    <div v-else-if="error" class="error-state">
      <p class="error-icon">😵</p>
      <p class="error-message">{{ error.message || '加载失败' }}</p>
      <button @click="refresh" class="retry-btn">
        🔄 重试
      </button>
    </div>

    <!-- 文章不存在 -->
    <div v-else-if="!article" class="not-found">
      <p class="not-found-icon">📭</p>
      <p>文章不存在或已被删除</p>
      <NuxtLink to="/blog" class="back-link">
        ← 返回文章列表
      </NuxtLink>
    </div>

    <!-- 文章详情 -->
    <article v-else class="article-detail">
      <!-- 返回按钮 -->
      <NuxtLink to="/blog" class="back-btn">
        ← 返回文章列表
      </NuxtLink>

      <!-- 文章标题 -->
      <h1 class="article-title">{{ article.title }}</h1>

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

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

      <!-- 文章内容 -->
      <div class="article-content prose" v-html="renderedContent"></div>
    </article>
  </div>
</template>

<script setup>
import MarkdownIt from 'markdown-it'

// 获取路由参数
const route = useRoute()
const articleId = route.params.id

// 获取文章详情
const { data: article, pending, error, refresh } = await useFetch(
  `http://localhost:8000/api/articles/${articleId}`
)

// 创建 Markdown 渲染器
const md = new MarkdownIt({
  html: true,        // 允许 HTML 标签
  linkify: true,     // 自动转换链接
  typographer: true  // 智能引号等排版优化
})

// 渲染 Markdown 内容
const renderedContent = computed(() => {
  if (!article.value?.content) return ''
  return md.render(article.value.content)
})
</script>

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

/* 加载状态 */
.loading-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 300px;
  color: #666;
}

.loading-spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #e5e7eb;
  border-top-color: #667eea;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin-bottom: 1rem;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* 错误状态 */
.error-state {
  text-align: center;
  padding: 3rem;
}

.error-icon {
  font-size: 4rem;
  margin-bottom: 1rem;
}

.error-message {
  color: #e53e3e;
  margin-bottom: 1.5rem;
}

.retry-btn {
  padding: 0.75rem 1.5rem;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 0.5rem;
  cursor: pointer;
  font-size: 1rem;
  transition: background 0.2s;
}

.retry-btn:hover {
  background: #5a67d8;
}

/* 文章不存在 */
.not-found {
  text-align: center;
  padding: 3rem;
  color: #666;
}

.not-found-icon {
  font-size: 4rem;
  margin-bottom: 1rem;
}

.back-link {
  display: inline-block;
  margin-top: 1.5rem;
  color: #667eea;
  text-decoration: none;
}

.back-link:hover {
  text-decoration: underline;
}

/* 文章详情 */
.article-detail {
  animation: fadeIn 0.3s ease;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

.back-btn {
  display: inline-flex;
  align-items: center;
  color: #667eea;
  text-decoration: none;
  margin-bottom: 2rem;
  font-weight: 500;
  transition: color 0.2s;
}

.back-btn:hover {
  color: #5a67d8;
}

.article-title {
  font-size: 2.5rem;
  font-weight: 800;
  color: #1a202c;
  margin-bottom: 1rem;
  line-height: 1.2;
}

.article-meta {
  display: flex;
  gap: 1.5rem;
  color: #718096;
  margin-bottom: 1rem;
}

.meta-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.meta-icon {
  font-size: 1rem;
}

.article-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 2rem;
}

.tag {
  padding: 0.25rem 0.75rem;
  background: #edf2f7;
  color: #4a5568;
  border-radius: 9999px;
  font-size: 0.875rem;
}

/* Markdown 内容样式 */
.article-content {
  color: #2d3748;
  line-height: 1.8;
  font-size: 1.1rem;
}

.article-content :deep(h1) {
  font-size: 2rem;
  font-weight: 700;
  margin-top: 2.5rem;
  margin-bottom: 1rem;
  color: #1a202c;
}

.article-content :deep(h2) {
  font-size: 1.5rem;
  font-weight: 600;
  margin-top: 2rem;
  margin-bottom: 0.75rem;
  color: #2d3748;
  border-bottom: 1px solid #e2e8f0;
  padding-bottom: 0.5rem;
}

.article-content :deep(h3) {
  font-size: 1.25rem;
  font-weight: 600;
  margin-top: 1.5rem;
  margin-bottom: 0.5rem;
  color: #2d3748;
}

.article-content :deep(p) {
  margin-bottom: 1.25rem;
}

.article-content :deep(ul),
.article-content :deep(ol) {
  margin-bottom: 1.25rem;
  padding-left: 1.5rem;
}

.article-content :deep(li) {
  margin-bottom: 0.5rem;
}

.article-content :deep(code) {
  background: #f1f5f9;
  padding: 0.125rem 0.375rem;
  border-radius: 0.25rem;
  font-size: 0.9em;
  font-family: 'Fira Code', 'Consolas', monospace;
}

.article-content :deep(pre) {
  background: #1e293b;
  color: #e2e8f0;
  padding: 1rem 1.25rem;
  border-radius: 0.5rem;
  overflow-x: auto;
  margin-bottom: 1.5rem;
}

.article-content :deep(pre code) {
  background: transparent;
  padding: 0;
  font-size: 0.9rem;
  color: inherit;
}

.article-content :deep(blockquote) {
  border-left: 4px solid #667eea;
  padding-left: 1rem;
  margin: 1.5rem 0;
  color: #4a5568;
  font-style: italic;
}

.article-content :deep(a) {
  color: #667eea;
  text-decoration: none;
}

.article-content :deep(a:hover) {
  text-decoration: underline;
}

.article-content :deep(img) {
  max-width: 100%;
  border-radius: 0.5rem;
  margin: 1.5rem 0;
}

.article-content :deep(hr) {
  border: none;
  border-top: 1px solid #e2e8f0;
  margin: 2rem 0;
}
</style>

:deep() 选择器是什么?

因为我们用了 scoped 样式,正常情况下样式只对当前组件生效。但 v-html 渲染的内容不属于 Vue 组件,所以需要用 :deep() 来穿透样式作用域。

7.8.3 更新文章列表的链接

回到文章列表页(pages/blog/index.vue),确保文章标题可以点击跳转:

<!-- 在文章卡片中添加链接 -->
<NuxtLink :to="`/blog/${article.id}`" class="article-link">
  <h2 class="article-title">{{ article.title }}</h2>
</NuxtLink>

7.9 测试完整流程

7.9.1 启动服务

确保前后端都在运行:

# 终端 1 - 后端
cd backend
pdm run uvicorn src.backend:app --reload

# 终端 2 - 前端
cd frontend
pnpm run dev

7.9.2 测试流程

  1. 访问 http://localhost:3000/blog
  2. 看到文章列表
  3. 点击任意文章标题
  4. 跳转到 http://localhost:3000/blog/1(或其他 id)
  5. 看到完整的文章内容,Markdown 已正确渲染
  6. 点击"返回文章列表"回到列表页

🎉 恭喜!你的博客有了数据库!

现在你的博客:

  • ✅ 数据存储在 SQLite 数据库中
  • ✅ 重启服务数据不会丢失
  • ✅ 可以查看文章详情
  • ✅ Markdown 内容正确渲染

这就是真正的全栈应用!

7.10 小结

本章回顾

本章我们完成了:

  • 理解了为什么需要数据库
  • 学习了 SQLite 和 SQLAlchemy 基础
  • 创建了 Article 数据模型
  • 实现了数据库初始化和示例数据
  • 重构了后端 API 使用数据库查询
  • 创建了 Nuxt 动态路由 /blog/[id]
  • 实现了文章详情页面
  • 集成了 Markdown 渲染

动手练习

试着扩展一下你的博客:

  1. 添加阅读量统计:在 Article 模型添加 view_count 字段,每次访问详情页时 +1
  2. 实现文章搜索:添加一个搜索 API,支持按标题关键词搜索文章
  3. 添加文章分类:给文章添加分类字段,实现分类筛选功能
  4. 优化 Markdown:给 markdown-it 添加代码高亮插件(提示:markdown-it-highlightjs

这些练习能帮你更好地理解数据库和 API 开发!

常见问题

Q: 数据库文件 blog.db 在哪里?

A: 在 backend 目录下。你可以用 SQLite 可视化工具(如 DB Browser for SQLite)打开查看里面的数据。

Q: 如何清空数据库重新开始?

A: 删除 blog.db 文件,重启后端服务即可。示例数据会重新生成。

Q: 为什么 Markdown 样式没生效?

A: 检查是否正确使用了 :deep() 选择器,以及是否安装了 markdown-it

Q: 访问不存在的文章 id 会怎样?

A: 后端会返回 404 错误,前端会显示"文章不存在"的提示。

下一章预告

文章能看了,但还不能写!下一章我们要实现发布文章功能:

  • 📝 集成 Markdown 编辑器
  • ✅ 学习表单验证(Pydantic)
  • 📤 实现 POST 请求发布文章
  • 🔄 发布成功后跳转到文章详情

准备好让你的博客支持发布新文章了吗?✍️