Skip to content

第 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?

  1. 数据量大:文章内容可能很长,不适合放在 URL 里
  2. 安全性:POST 请求的数据不会显示在浏览器地址栏
  3. 语义正确:创建新资源就应该用 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_lengthmax_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" 
/>

它同时做了两件事:

  1. title 的值显示在输入框里(:value
  2. 当用户输入时,更新 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 完整测试流程

  1. 访问 http://localhost:3000/blog
  2. 点击"写文章"按钮
  3. 填写标题、摘要(可选)、正文
  4. 点击"发布文章"
  5. 自动跳转到新文章的详情页
  6. 返回文章列表,新文章出现在列表中!

🎉 恭喜!你的博客可以发文章了!

现在你可以:

  • ✅ 写新文章并发布
  • ✅ 表单有验证,不会提交空内容
  • ✅ 发布成功自动跳转
  • ✅ 支持 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)

动手练习

试着扩展一下:

  1. 添加编辑功能:在文章详情页添加"编辑"按钮,跳转到编辑页面(提示:复用发布页面,通过 URL 参数区分)
  2. 实现删除功能:添加 DELETE API 和前端确认删除对话框
  3. 草稿功能:在文章模型添加 is_draft 字段,支持保存草稿
  4. 字数统计:在编辑器下方实时显示文章字数

这些练习能帮你更深入理解 CRUD 操作!

常见问题

Q: 发布时报 422 错误?

A: 这是 Pydantic 验证失败。检查请求数据是否符合 ArticleCreate 的要求(标题和内容不能为空)。

Q: 发布成功但跳转报错?

A: 检查返回数据中是否包含 article.id,以及动态路由 [id].vue 是否存在。

Q: 中文乱码?

A: 确保文件保存为 UTF-8 编码,后端返回时设置正确的 Content-Type。

下一章预告

文章可以发布了,但现在谁都能发——这可不行!下一章我们要实现用户登录注册

  • 🔐 用户注册和登录
  • 🎫 JWT Token 认证
  • 🛡️ 保护发布功能(登录后才能发文章)

准备好给你的博客加上用户系统了吗?