第 8 章:发布文章功能
约 3221 字大约 11 分钟
2025-12-19
本章学习目标
- 学习 Vue 表单处理与 v-model
- 理解 POST 请求与数据提交
- 掌握 Pydantic 数据验证
- 实现文章发布功能
- 🎉 成果:能发布新文章了!
文章能看了,但还不能写?这可不行!一个博客系统,最核心的功能就是让用户能够发布自己的文章。这一章我们来实现这个功能!
本章结束后你会得到什么
一个完整的文章发布页面——填写标题、摘要、正文,点击发布,新文章就出现在列表里了!
8.1 理解 HTTP 方法
8.1.1 GET vs POST
到目前为止,我们只用过 GET 请求来获取数据。但要提交数据,我们需要用 POST 请求。
历史趣事:HTTP 方法的设计哲学
HTTP 协议的设计者 Tim Berners-Lee(没错,就是发明万维网的那位)在 1991 年设计 HTTP 时,借鉴了 REST 架构的思想:用不同的动词表示不同的操作。
就像现实生活中:
- "给我看看菜单" → GET(获取)
- "我要点这道菜" → POST(创建)
- "把这道菜换成另一道" → PUT(更新)
- "不要这道菜了" → DELETE(删除)
这种设计让 API 更加语义化,一看就知道是干什么的!
| HTTP 方法 | 用途 | 特点 |
|---|---|---|
| GET | 获取数据 | 参数在 URL 中,可被缓存 |
| POST | 创建数据 | 参数在请求体中,更安全 |
| PUT | 更新数据 | 替换整个资源 |
| DELETE | 删除数据 | 删除指定资源 |
8.1.2 为什么发布文章用 POST?
- 数据量大:文章内容可能很长,不适合放在 URL 里
- 安全性:POST 请求的数据不会显示在浏览器地址栏
- 语义正确:创建新资源就应该用 POST
8.2 后端:创建发布 API
8.2.1 Pydantic 数据验证
在接收用户提交的数据之前,我们需要验证数据是否合法。比如:标题不能为空、内容长度有限制等。
历史趣事:Pydantic 的诞生
Pydantic 的作者 Samuel Colvin 在 2017 年创建了这个库,灵感来自于他对 Python 类型提示的热爱。他想:"既然 Python 3.5 有了类型提示,为什么不用它来做数据验证呢?"
于是 Pydantic 诞生了——用 Python 的类型注解来定义数据结构,自动完成验证和转换。FastAPI 的作者 Sebastián Ramírez 看到后大为惊叹,直接把 Pydantic 集成到了 FastAPI 中,两者成为了最佳拍档!
Pydantic 是 FastAPI 内置的数据验证库,用法非常简单:
from pydantic import BaseModel
class ArticleCreate(BaseModel):
title: str # 必填,字符串
summary: str = "" # 选填,默认空字符串
content: str # 必填,字符串
tags: str = "" # 选填,默认空字符串Pydantic 会自动帮你:
- ✅ 检查必填字段是否存在
- ✅ 检查数据类型是否正确
- ✅ 自动转换兼容的类型(如字符串数字转为整数)
- ✅ 返回友好的错误信息
8.2.2 创建 schemas.py
在 backend/src/backend/ 目录下创建 schemas.py 文件:
# backend/src/backend/schemas.py
from pydantic import BaseModel, Field
from typing import Optional
class ArticleCreate(BaseModel):
"""创建文章的请求模型"""
title: str = Field(..., min_length=1, max_length=200, description="文章标题")
summary: Optional[str] = Field(default="", max_length=500, description="文章摘要")
content: str = Field(..., min_length=1, description="文章内容")
tags: Optional[str] = Field(default="", description="标签,逗号分隔")
class Config:
json_schema_extra = {
"example": {
"title": "我的新文章",
"summary": "这是文章摘要",
"content": "# 标题\n\n这是文章正文...",
"tags": "Python,学习笔记"
}
}
class ArticleResponse(BaseModel):
"""文章响应模型"""
id: int
title: str
summary: str
content: str
author: str
created_at: str
tags: list[str]代码解释
Field(...):...表示必填字段min_length、max_length:字符串长度限制Optional[str]:可选字段default="":默认值json_schema_extra:为 API 文档提供示例
8.2.3 添加发布 API
修改 backend/src/backend/__init__.py,添加发布文章的 API:
# 在文件顶部添加导入
from datetime import datetime
from .schemas import ArticleCreate
# 在其他 API 下面添加发布文章的 API
@app.post("/api/articles")
def create_article(article: ArticleCreate, db: Session = Depends(get_db)):
"""发布新文章"""
# 创建文章对象
db_article = Article(
title=article.title,
summary=article.summary or article.content[:100] + "...", # 没有摘要就截取内容
content=article.content,
author="博主", # 暂时写死,第9章会改成当前登录用户
created_at=datetime.now().strftime("%Y-%m-%d"),
tags=article.tags
)
# 保存到数据库
db.add(db_article)
db.commit()
db.refresh(db_article) # 刷新以获取自动生成的 id
return {
"message": "发布成功",
"article": db_article.to_dict()
}db.refresh() 是干什么的?
当我们创建一条新记录时,id 是由数据库自动生成的。db.refresh(db_article) 会从数据库重新读取这条记录,这样我们就能获取到自动生成的 id 了。
8.2.4 测试发布 API
启动后端服务,访问 http://localhost:8000/docs,你会看到新增了一个 POST /api/articles 接口。
点击 "Try it out",填入测试数据:
{
"title": "测试文章",
"summary": "这是一篇测试文章",
"content": "# 测试\n\n这是测试内容",
"tags": "测试"
}点击 "Execute",如果返回 "发布成功",说明后端 API 没问题!
8.3 前端:创建发布页面
8.3.1 Vue 表单基础
在 Vue 中,表单处理的核心是 v-model——它实现了双向绑定:
<template>
<input v-model="title" />
<p>你输入的是:{{ title }}</p>
</template>
<script setup>
const title = ref('')
</script>v-model 是什么魔法?
v-model 其实是语法糖,等价于:
<input
:value="title"
@input="title = $event.target.value"
/>它同时做了两件事:
- 把
title的值显示在输入框里(:value) - 当用户输入时,更新
title的值(@input)
这就是"双向绑定"!
8.3.2 创建发布页面
在 frontend/app/pages/blog/ 目录下创建 create.vue:
<!-- frontend/app/pages/blog/create.vue -->
<template>
<div class="create-page">
<h1 class="page-title">✍️ 写文章</h1>
<form @submit.prevent="handleSubmit" class="article-form">
<!-- 标题 -->
<div class="form-group">
<label for="title" class="form-label">
文章标题 <span class="required">*</span>
</label>
<input
id="title"
v-model="form.title"
type="text"
class="form-input"
placeholder="起一个吸引人的标题..."
maxlength="200"
/>
<p class="char-count">{{ form.title.length }}/200</p>
</div>
<!-- 摘要 -->
<div class="form-group">
<label for="summary" class="form-label">
文章摘要
</label>
<textarea
id="summary"
v-model="form.summary"
class="form-textarea"
placeholder="简短描述文章内容(不填则自动截取正文)"
rows="2"
maxlength="500"
></textarea>
<p class="char-count">{{ form.summary.length }}/500</p>
</div>
<!-- 标签 -->
<div class="form-group">
<label for="tags" class="form-label">
标签
</label>
<input
id="tags"
v-model="form.tags"
type="text"
class="form-input"
placeholder="多个标签用逗号分隔,如:Vue,学习笔记"
/>
</div>
<!-- 正文 -->
<div class="form-group">
<label for="content" class="form-label">
文章正文 <span class="required">*</span>
</label>
<p class="form-hint">支持 Markdown 格式</p>
<textarea
id="content"
v-model="form.content"
class="form-textarea content-editor"
placeholder="在这里写下你的想法..."
rows="15"
></textarea>
</div>
<!-- 提交按钮 -->
<div class="form-actions">
<NuxtLink to="/blog" class="btn-cancel">
取消
</NuxtLink>
<button
type="submit"
class="btn-submit"
:disabled="isSubmitting || !isFormValid"
>
{{ isSubmitting ? '发布中...' : '发布文章' }}
</button>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="error-message">
❌ {{ errorMessage }}
</div>
</form>
</div>
</template>
<script setup>
// 表单数据
const form = reactive({
title: '',
summary: '',
content: '',
tags: ''
})
// 状态
const isSubmitting = ref(false)
const errorMessage = ref('')
// 表单验证
const isFormValid = computed(() => {
return form.title.trim().length > 0 && form.content.trim().length > 0
})
// 提交表单
async function handleSubmit() {
// 清除之前的错误
errorMessage.value = ''
// 验证表单
if (!form.title.trim()) {
errorMessage.value = '请输入文章标题'
return
}
if (!form.content.trim()) {
errorMessage.value = '请输入文章内容'
return
}
// 开始提交
isSubmitting.value = true
try {
const response = await $fetch('http://localhost:8000/api/articles', {
method: 'POST',
body: {
title: form.title.trim(),
summary: form.summary.trim(),
content: form.content,
tags: form.tags.trim()
}
})
// 发布成功,跳转到文章详情页
if (response.article?.id) {
navigateTo(`/blog/${response.article.id}`)
} else {
navigateTo('/blog')
}
} catch (error) {
console.error('发布失败:', error)
errorMessage.value = error.data?.detail || '发布失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.create-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.page-title {
font-size: 2rem;
font-weight: 700;
color: #1a202c;
margin-bottom: 2rem;
}
.article-form {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.required {
color: #e53e3e;
}
.form-hint {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
resize: vertical;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.content-editor {
font-family: 'Fira Code', 'Consolas', monospace;
line-height: 1.6;
}
.char-count {
text-align: right;
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.btn-cancel {
padding: 0.75rem 1.5rem;
color: #6b7280;
text-decoration: none;
border-radius: 0.5rem;
transition: background 0.2s;
}
.btn-cancel:hover {
background: #f3f4f6;
}
.btn-submit {
padding: 0.75rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 1rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.5rem;
text-align: center;
}
</style>@submit.prevent 是什么?
@submit.prevent 等于 @submit + event.preventDefault()。
默认情况下,表单提交会刷新页面(这是浏览器的默认行为)。.prevent 修饰符阻止这个默认行为,让我们可以用 JavaScript 来处理表单提交。
8.3.3 添加发布入口
在文章列表页添加一个"写文章"按钮。修改 frontend/app/pages/blog/index.vue,在页面顶部添加:
<!-- 在标题旁边添加按钮 -->
<div class="page-header">
<h1 class="text-3xl font-bold text-gray-800">
📝 博客文章
</h1>
<NuxtLink to="/blog/create" class="write-btn">
✍️ 写文章
</NuxtLink>
</div>添加对应的样式:
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.write-btn {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
text-decoration: none;
border-radius: 0.5rem;
font-weight: 600;
transition: transform 0.2s, box-shadow 0.2s;
}
.write-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}8.4 测试发布功能
8.4.1 完整测试流程
- 访问
http://localhost:3000/blog - 点击"写文章"按钮
- 填写标题、摘要(可选)、正文
- 点击"发布文章"
- 自动跳转到新文章的详情页
- 返回文章列表,新文章出现在列表中!
🎉 恭喜!你的博客可以发文章了!
现在你可以:
- ✅ 写新文章并发布
- ✅ 表单有验证,不会提交空内容
- ✅ 发布成功自动跳转
- ✅ 支持 Markdown 格式
再也不用在代码里手动添加文章了!
8.5 进阶:Markdown 实时预览
想让写作体验更好?我们可以添加 Markdown 实时预览功能。
8.5.1 添加预览区域
修改发布页面,在正文编辑器部分改为并排布局:
<!-- 替换原来的正文编辑区域 -->
<div class="form-group">
<label class="form-label">
文章正文 <span class="required">*</span>
</label>
<p class="form-hint">支持 Markdown 格式,右侧实时预览</p>
<div class="editor-container">
<!-- 编辑区 -->
<div class="editor-pane">
<textarea
v-model="form.content"
class="form-textarea content-editor"
placeholder="在这里写下你的想法..."
rows="20"
></textarea>
</div>
<!-- 预览区 -->
<div class="preview-pane">
<div class="preview-label">预览</div>
<div class="preview-content prose" v-html="previewContent"></div>
</div>
</div>
</div>在 <script setup> 中添加预览逻辑:
// 添加在 script setup 中
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt()
const previewContent = computed(() => {
if (!form.content) return '<p class="placeholder">预览区域...</p>'
return md.render(form.content)
})添加样式:
.editor-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-height: 400px;
}
.editor-pane .content-editor {
height: 100%;
min-height: 400px;
}
.preview-pane {
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
background: #f9fafb;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-label {
padding: 0.5rem 1rem;
background: #e5e7eb;
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.preview-content {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.preview-content .placeholder {
color: #9ca3af;
font-style: italic;
}
/* 响应式:小屏幕时上下布局 */
@media (max-width: 768px) {
.editor-container {
grid-template-columns: 1fr;
}
}实时预览的好处
- 写 Markdown 的同时看到渲染效果
- 避免发布后才发现格式问题
- 提升写作体验
当然,这是可选的优化,基础功能已经够用了!
8.6 小结
本章回顾
本章我们完成了:
- 理解了 HTTP 方法(GET/POST/PUT/DELETE)
- 学习了 Pydantic 数据验证
- 创建了发布文章的后端 API
- 实现了完整的发布页面
- 掌握了 Vue 表单处理(v-model、@submit.prevent)
动手练习
试着扩展一下:
- 添加编辑功能:在文章详情页添加"编辑"按钮,跳转到编辑页面(提示:复用发布页面,通过 URL 参数区分)
- 实现删除功能:添加 DELETE API 和前端确认删除对话框
- 草稿功能:在文章模型添加
is_draft字段,支持保存草稿 - 字数统计:在编辑器下方实时显示文章字数
这些练习能帮你更深入理解 CRUD 操作!
常见问题
Q: 发布时报 422 错误?
A: 这是 Pydantic 验证失败。检查请求数据是否符合 ArticleCreate 的要求(标题和内容不能为空)。
Q: 发布成功但跳转报错?
A: 检查返回数据中是否包含 article.id,以及动态路由 [id].vue 是否存在。
Q: 中文乱码?
A: 确保文件保存为 UTF-8 编码,后端返回时设置正确的 Content-Type。
下一章预告
文章可以发布了,但现在谁都能发——这可不行!下一章我们要实现用户登录注册:
- 🔐 用户注册和登录
- 🎫 JWT Token 认证
- 🛡️ 保护发布功能(登录后才能发文章)
准备好给你的博客加上用户系统了吗?