第 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 我们的文章表设计
根据博客的需求,文章表应该包含这些字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | INTEGER | 主键,唯一标识 |
title | TEXT | 文章标题 |
summary | TEXT | 文章摘要 |
content | TEXT | 文章正文(Markdown) |
author | TEXT | 作者 |
created_at | TEXT | 创建时间 |
tags | TEXT | 标签(逗号分隔) |
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 支持同步和异步两种方式。本章我们使用同步方式,因为:
- 学习曲线更平缓
- 对于博客项目性能完全足够
- 代码更简洁易懂
异步的详细讲解会在第 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 理解数据库会话
什么是数据库会话?
想象你去银行办业务:
- 取号排队 → 创建会话(
SessionLocal()) - 到窗口办理 → 执行数据库操作
- 办完离开 → 关闭会话(
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 类型 | 适用场景 |
|---|---|---|
Integer | INTEGER | 整数(如 id、年龄) |
String(n) | VARCHAR(n) | 短文本(如标题、用户名) |
Text | TEXT | 长文本(如文章内容) |
Float | FLOAT | 浮点数(如价格) |
Boolean | BOOLEAN | 布尔值(如是否发布) |
DateTime | DATETIME | 日期时间 |
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- 检查数据库文件:
运行后,backend 目录下应该出现 blog.db 文件——这就是你的数据库!
- 测试 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 | 匹配的文件 | 获取的参数 |
|---|---|---|
/blog | index.vue | 无 |
/blog/1 | [id].vue | id = "1" |
/blog/hello | [id].vue | id = "hello" |
7.7.3 获取动态参数
在页面中,用 useRoute() 获取参数:
const route = useRoute()
const articleId = route.params.id // 获取 URL 中的 id7.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 dev7.9.2 测试流程
- 访问
http://localhost:3000/blog - 看到文章列表
- 点击任意文章标题
- 跳转到
http://localhost:3000/blog/1(或其他 id) - 看到完整的文章内容,Markdown 已正确渲染
- 点击"返回文章列表"回到列表页
🎉 恭喜!你的博客有了数据库!
现在你的博客:
- ✅ 数据存储在 SQLite 数据库中
- ✅ 重启服务数据不会丢失
- ✅ 可以查看文章详情
- ✅ Markdown 内容正确渲染
这就是真正的全栈应用!
7.10 小结
本章回顾
本章我们完成了:
- 理解了为什么需要数据库
- 学习了 SQLite 和 SQLAlchemy 基础
- 创建了 Article 数据模型
- 实现了数据库初始化和示例数据
- 重构了后端 API 使用数据库查询
- 创建了 Nuxt 动态路由
/blog/[id] - 实现了文章详情页面
- 集成了 Markdown 渲染
动手练习
试着扩展一下你的博客:
- 添加阅读量统计:在 Article 模型添加
view_count字段,每次访问详情页时 +1 - 实现文章搜索:添加一个搜索 API,支持按标题关键词搜索文章
- 添加文章分类:给文章添加分类字段,实现分类筛选功能
- 优化 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 请求发布文章
- 🔄 发布成功后跳转到文章详情
准备好让你的博客支持发布新文章了吗?✍️