05-Flask表单处理与文件上传

Flask表单处理与文件上传

本文是 Flask 服务器专栏的第五期,全面深入地讲解 Flask 中的表单处理与文件上传技术。内容涵盖原生表单处理、Flask-WTF 与 WTForms 表单库、字段类型、验证器、表单渲染、处理流程、文件上传、云存储集成,以及完整的实战案例。全文超过三万字,配有大量可运行的代码示例,面向有一定 Flask 基础的开发者,帮助你从"能写出表单"进阶到"精通表单处理与文件上传全流程"。


引言

在 Web 开发中,表单是用户与应用交互的最重要桥梁。无论是用户注册登录、文章发布、商品搜索,还是文件上传,几乎所有需要用户输入数据的场景都离不开表单。可以说,掌握了表单处理,就掌握了 Web 应用开发的半壁江山。

Flask 作为一个轻量级框架,本身并不强制要求你使用某个特定的表单库。你可以用最原始的方式,通过 request.form 手动获取数据、手动验证、手动返回错误信息。这种方式在简单场景下足够用,但随着表单数量增多、字段类型变复杂、验证规则变严格,手动处理的代码会迅速膨胀,变得难以维护,还容易留下安全漏洞。

这就是 Flask-WTF 和 WTForms 登场的舞台。WTForms 是一个独立的 Python 表单库,提供了字段类型、验证器、错误处理等完整能力;Flask-WTF 则是它在 Flask 中的集成层,负责 CSRF 保护、文件上传处理、与 Flask 请求上下文的对接。两者配合,让你可以用声明式的方式定义表单,用一行代码完成验证,用模板宏统一渲染风格,大幅提升开发效率和代码质量。

与此同时,文件上传是表单处理中最复杂、最容易出问题的部分。文件上传涉及 multipart/form-data 编码、文件名安全处理、文件类型与大小验证、存储路径管理、大文件分片上传等一系列问题。处理不当,轻则用户体验差,重则导致服务器被恶意文件攻击、磁盘被撑爆、敏感数据泄露。

很多 Flask 开发者在处理表单时,常常遇到以下困惑:

  • request.formrequest.args 到底什么时候用哪个?
  • CSRF 攻击是什么?为什么表单里一定要加那个 csrf_token?
  • WTForms 的字段类型这么多,分别什么时候用?SelectFieldchoices 怎么动态生成?
  • 验证器怎么组合使用?怎么写自定义验证器?
  • 表单验证失败后,如何优雅地把用户已填的数据回显,并显示错误信息?
  • 文件上传时,secure_filename 到底做了什么?为什么不能直接用用户传来的文件名?
  • 如何限制上传文件的大小和类型?如何防止上传可执行文件?
  • 如何把文件上传到 AWS S3、阿里云 OSS 等云存储?
  • 如何用 AJAX 异步提交表单,实现无刷新体验?
  • 如何实现多步骤向导式表单?如何动态增减表单字段?

本文将从以上问题出发,系统而深入地讲解 Flask 表单处理与文件上传的方方面面。全文共十章,从表单基础概念讲起,逐步深入到 WTForms 字段与验证器、表单渲染与处理流程、文件上传与云存储,最后通过大量完整实战案例将所学知识融会贯通。每一章都配有完整的可运行代码示例,确保你看完就能上手。让我们开始这段进阶之旅。

在阅读本文之前,建议你已经具备以下基础:了解 Flask 的基本路由和视图函数写法、了解 Jinja2 模板的基本语法、了解 HTTP 协议中 GET 和 POST 请求的区别、掌握 Python 面向对象编程的基本概念。如果你对这些基础知识还不熟悉,建议先阅读本系列的前几期文章,打好基础后再来学习表单处理。

本文的代码示例基于 Flask 3.x、Flask-WTF 1.x、WTForms 3.x 版本,所有代码均在 Python 3.10+ 环境下测试通过。文中涉及的第三方库包括 Flask-WTF、WTForms、Flask-SQLAlchemy、Flask-Uploads、Flask-Reuploaded、Bootstrap-Flask、Flask-Babel、boto3、Pillow、bleach 等,建议在虚拟环境中安装使用。

此外,本文不仅关注功能的实现,更注重安全性、可维护性和用户体验。每个代码示例都遵循安全编码的最佳实践,避免常见的安全漏洞。每章末尾都有实践建议和注意事项,帮助你在实际项目中少走弯路。


第一章 表单处理基础

1.1 Web表单的概念与组成

Web 表单是 HTML 页面中用于收集用户输入数据的一组控件集合。当用户在表单中填写信息并点击提交按钮后,浏览器会将这些数据按照特定的编码格式打包,通过 HTTP 请求发送给服务器。服务器接收数据后进行验证、处理,并返回响应结果。

一个完整的 Web 表单由以下几个核心部分组成:

  1. <form> 标签 :表单的容器,定义数据提交的目标 URL(action)和提交方式(method)。
  2. 输入控件:如文本框、密码框、下拉选择、单选/复选按钮、文本域、文件选择框等,用于接收用户输入。
  3. <label> 标签:为输入控件提供语义化的标签说明,提升可访问性。
  4. 提交按钮:用户点击后触发表单提交。
  5. 隐藏字段:用于传递不需要用户感知的数据,如 CSRF 令牌、记录 ID 等。

从数据流的角度看,表单处理的完整链路如下:

复制代码
用户填写表单 → 浏览器打包数据 → HTTP请求(POST/GET) → Flask接收(request) → 验证数据 → 处理业务 → 返回响应

下面是一个最简单的 HTML 表单示例:

html 复制代码
<form action="/submit" method="post">
    <label for="username">用户名:</label>
    <input type="text" id="username" name="username" required>

    <label for="email">邮箱:</label>
    <input type="email" id="email" name="email" required>

    <button type="submit">提交</button>
</form>

在这个表单中,action="/submit" 表示数据将提交到 /submit 这个 URL,method="post" 表示使用 POST 方式提交。两个 <input> 控件的 name 属性非常关键------它就是服务器端获取数据时的键名。

1.1.1 表单数据传输机制

理解表单数据的传输机制,有助于我们在后端正确地接收和处理数据。当用户点击提交按钮时,浏览器会执行以下步骤:

第一步,浏览器收集表单中所有带有 name 属性的控件值,构建一个键值对集合。没有 name 属性的控件值不会被提交。禁用的控件(disabled)也不会被提交,但只读控件(readonly)的值会被提交。

第二步,浏览器根据 enctype 属性对数据进行编码。对于默认的 application/x-www-form-urlencoded 编码,键值对会被拼接成 key1=value1&key2=value2 的格式,其中空格被替换为 +,特殊字符被 URL 编码。例如,用户名 张三 会被编码为 %E5%BC%A0%E4%B8%89

第三步,浏览器将编码后的数据放入 HTTP 请求中。GET 请求将数据附加在 URL 的查询字符串中(如 /submit?username=张三&email=test@test.com),POST 请求将数据放在请求体中。

第四步,Flask 接收到请求后,根据请求方法和 Content-Type 自动解析数据。request.form 包含 POST 请求体中的表单数据,request.args 包含 URL 查询字符串中的参数,request.files 包含上传的文件,request.values 则是 formargs 的合并。

1.1.2 表单控件的 name 属性规则

name 属性是表单控件与服务端交互的关键标识,有以下重要规则需要注意:

对于同名的多个控件(如一组复选框),浏览器会将所有值都提交。在 Flask 中,request.form.getlist('hobbies') 可以获取所有值,而 request.form.get('hobbies') 只返回第一个值。

html 复制代码
<!-- 同名复选框 -->
<input type="checkbox" name="hobbies" value="reading"> 阅读
<input type="checkbox" name="hobbies" value="music"> 音乐
<input type="checkbox" name="hobbies" value="sports"> 运动

<!-- 提交后:request.form.getlist('hobbies') => ['reading', 'music'] -->

对于没有选中任何选项的单选/复选框,浏览器不会提交任何值。这意味着服务端需要处理字段不存在的情况,而不是假设值为空字符串。

python 复制代码
# 正确处理可能不存在的字段
hobbies = request.form.getlist('hobbies')  # 返回 [],而不是 None
agree = request.form.get('agree')  # 未勾选时返回 None,不是 'off'
1.1.3 表单设计中的常见概念

在深入代码之前,我们需要了解几个表单设计中的核心概念:

表单状态(Form State):表单在不同时刻有不同的状态------初始状态(空表单)、填写中状态(用户正在输入)、提交状态(已提交等待响应)、验证失败状态(显示错误并回显数据)、验证成功状态(处理完成并重定向)。理解这些状态的转换是设计良好表单处理流程的基础。

表单上下文(Form Context):表单总是在特定的业务上下文中使用。一个注册表单的上下文是"用户尚未登录,想要创建账号";一个编辑表单的上下文是"用户已登录,想要修改已有数据"。上下文决定了表单的字段构成、验证规则和处理逻辑。

表单生命周期(Form Lifecycle):从表单定义(编写 Form 类)到表单渲染(模板输出 HTML)到表单提交(用户填写并提交)到表单验证(服务端检查)到表单处理(业务逻辑)再到表单响应(成功重定向或失败回显),这就是表单的完整生命周期。后续章节将逐一讲解生命周期中的每个环节。

1.2 HTML表单元素回顾

在深入 Flask 表单处理之前,我们先系统回顾一下 HTML 中与表单相关的核心元素。虽然这些是前端知识,但理解它们对于后端正确处理表单数据至关重要。

1.2.1 <form> 元素

<form> 是表单的根容器,常用属性如下:

属性 说明 示例
action 提交目标 URL action="/login"
method 提交方式,GET 或 POST method="post"
enctype 编码类型 enctype="multipart/form-data"
target 响应展示窗口 target="_blank"
autocomplete 自动填充 autocomplete="off"
novalidate 禁用浏览器原生验证 novalidate

其中 enctype 属性尤其重要,它决定了表单数据的编码方式:

  • application/x-www-form-urlencoded(默认):所有字符都会被编码,空格变为 +,特殊字符转为十六进制。适用于普通文本数据。
  • multipart/form-data:不对字符编码,以二进制流传输。文件上传时必须使用此编码
  • text/plain:空格变为 +,但不编码特殊字符。极少使用。
html 复制代码
<!-- 普通文本表单 -->
<form action="/register" method="post">
    ...
</form>

<!-- 文件上传表单,必须设置 enctype -->
<form action="/upload" method="post" enctype="multipart/form-data">
    ...
</form>
1.2.2 <input> 元素

<input> 是最灵活的表单控件,通过 type 属性可以变身为各种输入框:

html 复制代码
<!-- 文本框 -->
<input type="text" name="username">

<!-- 密码框,输入内容显示为圆点 -->
<input type="password" name="password">

<!-- 邮箱框,移动端会弹出邮箱键盘 -->
<input type="email" name="email">

<!-- 数字框 -->
<input type="number" name="age" min="0" max="150">

<!-- 日期选择器 -->
<input type="date" name="birthday">

<!-- 时间选择器 -->
<input type="time" name="appointment">

<!-- 日期时间选择器 -->
<input type="datetime-local" name="schedule">

<!-- 文件选择框 -->
<input type="file" name="avatar">

<!-- 多文件选择 -->
<input type="file" name="photos" multiple>

<!-- 隐藏字段 -->
<input type="hidden" name="csrf_token" value="abc123">

<!-- 复选框 -->
<input type="checkbox" name="agree" value="yes">

<!-- 单选按钮 -->
<input type="radio" name="gender" value="male"> 男
<input type="radio" name="gender" value="female"> 女

<!-- 范围滑块 -->
<input type="range" name="volume" min="0" max="100">

<!-- 颜色选择器 -->
<input type="color" name="theme_color">

<!-- 提交按钮 -->
<input type="submit" value="提交">

<!-- 重置按钮 -->
<input type="reset" value="重置">

<!-- 普通按钮 -->
<input type="button" value="点击" onclick="alert('hello')">

<!-- URL 输入框 -->
<input type="url" name="website">

<!-- 电话号码输入框 -->
<input type="tel" name="phone">

<!-- 搜索框 -->
<input type="search" name="keyword">
1.2.3 <select> 下拉选择
html 复制代码
<select name="city">
    <option value="">请选择城市</option>
    <option value="beijing">北京</option>
    <option value="shanghai" selected>上海</option>
    <option value="guangzhou">广州</option>
</select>

<!-- 多选下拉,size指定可见行数,multiple允许多选 -->
<select name="hobbies" size="4" multiple>
    <option value="reading">阅读</option>
    <option value="music">音乐</option>
    <option value="sports">运动</option>
    <option value="travel">旅行</option>
</select>
1.2.4 <textarea> 文本域
html 复制代码
<textarea name="content" rows="10" cols="50" placeholder="请输入内容..."></textarea>
1.2.5 <button> 按钮
html 复制代码
<button type="submit">提交</button>
<button type="reset">重置</button>
<button type="button" onclick="doSomething()">点击</button>
1.2.6 <label> 标签

<label> 用于将文字与表单控件关联,点击文字也能聚焦控件:

html 复制代码
<!-- 方式一:使用 for 属性指向控件的 id -->
<label for="username">用户名:</label>
<input type="text" id="username" name="username">

<!-- 方式二:将控件包裹在 label 内部 -->
<label>
    用户名: <input type="text" name="username">
</label>

1.3 表单提交方式(GET vs POST)

表单的提交方式由 <form>method 属性决定,主要有 GET 和 POST 两种。理解它们的区别对于正确设计表单至关重要。

1.3.1 GET 提交

GET 方式将表单数据以查询字符串的形式附加在 URL 后面:

复制代码
/search?keyword=flask&page=1

特点:

  • 数据出现在 URL 中,对用户可见
  • 数据长度受 URL 长度限制(浏览器限制,通常 2KB~8KB)
  • 不适合传输敏感信息(密码等)
  • 适合幂等操作(查询、过滤、排序)
  • 可以被收藏为书签
  • 会被浏览器历史记录缓存
  • 请求可以被重试而不会产生副作用
html 复制代码
<form action="/search" method="get">
    <input type="search" name="keyword">
    <button type="submit">搜索</button>
</form>
1.3.2 POST 提交

POST 方式将表单数据放在 HTTP 请求体中:

复制代码
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin&password=123456

特点:

  • 数据在请求体中,不会出现在 URL
  • 数据长度理论上无限制(受服务器配置约束)
  • 适合传输敏感信息和大量数据
  • 适合非幂等操作(创建、修改、删除)
  • 不能被收藏为书签
  • 浏览器重试提交时会弹出确认对话框(防止重复提交)
  • 文件上传必须使用 POST
html 复制代码
<form action="/login" method="post">
    <input type="text" name="username">
    <input type="password" name="password">
    <button type="submit">登录</button>
</form>
1.3.3 GET 与 POST 对比
对比项 GET POST
数据位置 URL 查询字符串 请求体
数据可见性 可见 不可见
数据长度 有限制(2~8KB) 理论上无限制
安全性 低(历史记录可见) 较高
幂等性 幂等 非幂等
缓存 可被缓存 不缓存
书签 可收藏 不可收藏
编码类型 application/x-www-form-urlencoded 多种(multipart/form-data 等)
适用场景 搜索、过滤、分页 登录、注册、提交数据、文件上传
后退按钮 无害 会提示重新提交
1.3.4 其他 HTTP 方法

虽然 HTML 表单原生只支持 GET 和 POST,但在 Flask 中可以通过隐藏字段模拟 PUT、DELETE 等方法:

html 复制代码
<form action="/article/1" method="post">
    <input type="hidden" name="_method" value="DELETE">
    <button type="submit">删除</button>
</form>

在 Flask 中配合 MethodOverrideMiddleware 或 Flask-WTF 的一些扩展即可识别这些方法。

1.4 Flask中获取表单数据

Flask 提供了 request 对象来获取客户端提交的表单数据。根据提交方式的不同,数据存放的位置也不同。

1.4.1 request.form(POST 数据)

当表单以 POST 方式提交时,数据存放在 request.form 中,它是一个类似字典的 ImmutableMultiDict 对象:

python 复制代码
from flask import Flask, request, render_template

app = Flask(__name__)

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        # 处理登录逻辑
        return f'用户名: {username}, 密码: {password}'
    return render_template('login.html')

request.form 的常用操作:

python 复制代码
# 获取单个值
username = request.form.get('username')
username = request.form['username']  # 键不存在会抛出 KeyError

# 获取多个同名值(如多选框)
hobbies = request.form.getlist('hobbies')

# 遍历所有数据
for key, value in request.form.items():
    print(f'{key}: {value}')

# 转为普通字典
data = request.form.to_dict()

# 转为多值字典(键对应值列表)
data_multi = request.form.to_dict(flat=False)

# 检查键是否存在
if 'username' in request.form:
    pass
1.4.2 request.args(GET 数据)

当表单以 GET 方式提交时,数据以查询字符串附加在 URL 上,通过 request.args 获取:

python 复制代码
@app.route('/search')
def search():
    keyword = request.args.get('keyword', '')
    page = request.args.get('page', 1, type=int)
    return f'搜索关键词: {keyword}, 页码: {page}'

访问 /search?keyword=flask&page=2 时,request.args 的内容为 {'keyword': 'flask', 'page': '2'}

注意 request.args.get('page', 1, type=int) 的第三个参数 type,它可以让 Flask 自动将字符串转为指定类型,转换失败时返回默认值。

1.4.3 request.values(合并数据)

request.values 合并了 request.formrequest.args 的数据,优先级是 form > args:

python 复制代码
@app.route('/submit', methods=['GET', 'POST'])
def submit():
    # 无论 GET 还是 POST 都能获取到数据
    data = request.values.get('data')
    return f'数据: {data}'
1.4.4 request.files(文件数据)

文件上传的数据存放在 request.files 中:

python 复制代码
@app.route('/upload', methods=['POST'])
def upload():
    file = request.files.get('file')
    if file:
        filename = file.filename
        file.save(f'uploads/{filename}')
        return '上传成功'
    return '未选择文件'
1.4.5 request.json(JSON 数据)

当前端以 AJAX 方式提交 JSON 数据时:

python 复制代码
@app.route('/api/submit', methods=['POST'])
def api_submit():
    data = request.get_json()
    username = data.get('username')
    return {'status': 'ok', 'username': username}
1.4.6 各数据源对比
属性 数据来源 提交方式 典型场景
request.form 请求体 POST 表单提交
request.args URL 查询字符串 GET 搜索、分页
request.values form + args 任意 通用获取
request.files 请求体(multipart) POST 文件上传
request.json 请求体(JSON) POST API 接口
request.data 请求体(原始) POST 原始数据

1.5 手动处理表单(原生Flask方式)

在没有使用任何表单库的情况下,我们可以用纯 Flask 手动处理表单。下面通过一个完整的用户注册示例来演示原生表单处理的全流程。

1.5.1 项目结构
复制代码
manual_form/
├── app.py
├── templates/
│   ├── register.html
│   └── success.html
1.5.2 后端代码
python 复制代码
# app.py
import re
import os
from flask import Flask, render_template, request, redirect, url_for, flash

app = Flask(__name__)
app.secret_key = 'your-secret-key-here'

# 模拟数据库
users_db = {}

def validate_email(email):
    """简单的邮箱格式验证"""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return re.match(pattern, email) is not None

@app.route('/register', methods=['GET', 'POST'])
def register():
    errors = {}
    form_data = {}

    if request.method == 'POST':
        # 获取表单数据
        username = request.form.get('username', '').strip()
        email = request.form.get('email', '').strip()
        password = request.form.get('password', '')
        confirm_password = request.form.get('confirm_password', '')
        agree = request.form.get('agree')

        # 保存用户已输入的数据,用于回显
        form_data = {
            'username': username,
            'email': email,
        }

        # 验证用户名
        if not username:
            errors['username'] = '用户名不能为空'
        elif len(username) < 3:
            errors['username'] = '用户名至少3个字符'
        elif len(username) > 20:
            errors['username'] = '用户名最多20个字符'
        elif username in users_db:
            errors['username'] = '用户名已被注册'

        # 验证邮箱
        if not email:
            errors['email'] = '邮箱不能为空'
        elif not validate_email(email):
            errors['email'] = '邮箱格式不正确'
        elif any(u['email'] == email for u in users_db.values()):
            errors['email'] = '邮箱已被注册'

        # 验证密码
        if not password:
            errors['password'] = '密码不能为空'
        elif len(password) < 6:
            errors['password'] = '密码至少6个字符'

        # 验证确认密码
        if password != confirm_password:
            errors['confirm_password'] = '两次密码不一致'

        # 验证协议同意
        if not agree:
            errors['agree'] = '请同意用户协议'

        # 验证通过,保存用户
        if not errors:
            users_db[username] = {
                'email': email,
                'password': password  # 实际项目中应使用 hash
            }
            flash('注册成功!请登录。', 'success')
            return redirect(url_for('success'))

    return render_template('register.html', errors=errors, form_data=form_data)

@app.route('/success')
def success():
    return render_template('success.html')

if __name__ == '__main__':
    os.makedirs('templates', exist_ok=True)
    app.run(debug=True)
1.5.3 前端模板
html 复制代码
<!-- templates/register.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>用户注册</title>
    <style>
        body { font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; }
        .form-group { margin-bottom: 15px; }
        label { display: block; margin-bottom: 5px; font-weight: bold; }
        input[type="text"], input[type="email"], input[type="password"] {
            width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;
            box-sizing: border-box;
        }
        .error { color: red; font-size: 12px; margin-top: 3px; }
        .checkbox-group { display: flex; align-items: center; gap: 8px; }
        button { padding: 10px 20px; background: #007bff; color: white; border: none;
                 border-radius: 4px; cursor: pointer; }
        button:hover { background: #0056b3; }
        .flash { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
        .flash.success { background: #d4edda; color: #155724; }
    </style>
</head>
<body>
    <h1>用户注册</h1>

    {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
            {% for category, message in messages %}
                <div class="flash {{ category }}">{{ message }}</div>
            {% endfor %}
        {% endif %}
    {% endwith %}

    <form action="/register" method="post">
        <div class="form-group">
            <label for="username">用户名</label>
            <input type="text" id="username" name="username"
                   value="{{ form_data.get('username', '') }}">
            {% if errors.get('username') %}
                <div class="error">{{ errors.username }}</div>
            {% endif %}
        </div>

        <div class="form-group">
            <label for="email">邮箱</label>
            <input type="email" id="email" name="email"
                   value="{{ form_data.get('email', '') }}">
            {% if errors.get('email') %}
                <div class="error">{{ errors.email }}</div>
            {% endif %}
        </div>

        <div class="form-group">
            <label for="password">密码</label>
            <input type="password" id="password" name="password">
            {% if errors.get('password') %}
                <div class="error">{{ errors.password }}</div>
            {% endif %}
        </div>

        <div class="form-group">
            <label for="confirm_password">确认密码</label>
            <input type="password" id="confirm_password" name="confirm_password">
            {% if errors.get('confirm_password') %}
                <div class="error">{{ errors.confirm_password }}</div>
            {% endif %}
        </div>

        <div class="form-group">
            <div class="checkbox-group">
                <input type="checkbox" id="agree" name="agree" value="yes">
                <label for="agree" style="font-weight: normal;">我已阅读并同意<a href="#">用户协议</a></label>
            </div>
            {% if errors.get('agree') %}
                <div class="error">{{ errors.agree }}</div>
            {% endif %}
        </div>

        <button type="submit">注册</button>
    </form>
</body>
</html>
html 复制代码
<!-- templates/success.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>注册成功</title>
</head>
<body>
    <h1>注册成功!</h1>
    <p>感谢您的注册。</p>
    <a href="/register">返回注册页</a>
</body>
</html>
1.5.4 手动处理的痛点

上面的代码虽然能工作,但已经暴露出原生处理的诸多问题:

  1. 验证代码冗长:每个字段都需要手动写 if-else 判断,逻辑重复。
  2. 数据回显繁琐:需要手动保存用户已输入的数据,并在模板中逐个回填。
  3. 错误处理分散:错误信息的收集和展示分散在代码各处,容易遗漏。
  4. 没有 CSRF 保护:表单容易被跨站请求伪造攻击。
  5. 难以复用:如果另一个页面也需要注册表单,几乎要复制全部代码。
  6. 类型转换手动:数字、日期等需要手动转换,容易出错。
  7. 扩展性差:增加字段或修改验证规则需要改动大量代码。

这些问题正是表单库要解决的核心痛点。

1.6 表单CSRF防护原理

CSRF(Cross-Site Request Forgery,跨站请求伪造)是一种常见的 Web 安全漏洞。理解它对于安全地处理表单至关重要。

1.6.1 什么是 CSRF 攻击

CSRF 攻击的原理是:攻击者诱导已登录用户在不知情的情况下,向目标网站发送恶意请求,利用用户的登录凭证(Cookie)完成操作。

攻击场景举例:

  1. 用户 A 登录了银行网站 bank.com,浏览器保存了登录 Cookie。
  2. 用户 A 在未退出银行网站的情况下,访问了攻击者的恶意网站 evil.com
  3. evil.com 的页面中包含一个自动提交的表单,指向 bank.com/transfer,收款人是攻击者。
  4. 由于浏览器会自动携带 bank.com 的 Cookie,银行网站误以为是用户 A 的合法操作,执行了转账。
html 复制代码
<!-- evil.com 上的恶意代码 -->
<form action="http://bank.com/transfer" method="post" id="csrf-form">
    <input type="hidden" name="to" value="attacker">
    <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('csrf-form').submit();</script>
1.6.2 CSRF 防护原理

CSRF 防护的核心思路是:在表单中加入一个攻击者无法获取的随机令牌(Token),服务器在处理请求时验证令牌是否有效。

由于攻击者的恶意网站无法读取目标网站的 Cookie(同源策略限制),也就无法获取这个令牌,因此无法伪造合法请求。

1.6.3 Flask 中手动实现 CSRF

在不使用 Flask-WTF 的情况下,可以手动实现 CSRF 保护:

python 复制代码
import secrets
from flask import Flask, render_template, request, session, abort

app = Flask(__name__)
app.secret_key = 'your-secret-key'

@app.before_request
def generate_csrf_token():
    """为每个请求生成 CSRF 令牌"""
    if 'csrf_token' not in session:
        session['csrf_token'] = secrets.token_hex(32)

@app.route('/form', methods=['GET', 'POST'])
def form():
    if request.method == 'POST':
        # 验证 CSRF 令牌
        token_form = request.form.get('csrf_token')
        token_session = session.get('csrf_token')
        if not token_form or token_form != token_session:
            abort(400, 'CSRF token validation failed')
        # 处理表单...
        return '提交成功'

    return render_template('form.html', csrf_token=session['csrf_token'])
html 复制代码
<!-- templates/form.html -->
<form action="/form" method="post">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    <input type="text" name="data">
    <button type="submit">提交</button>
</form>
1.6.4 CSRF 防护要点
  1. 令牌要足够随机 :使用 secrets 模块而非 random
  2. 令牌要与用户会话绑定:存储在 session 中,每个用户不同。
  3. 令牌要有有效期:过期后需要重新生成。
  4. 所有 POST/PUT/DELETE 请求都要验证:GET 请求通常不需要(因为 GET 应该是幂等的)。
  5. 令牌不要出现在 URL 中:避免通过 Referer 泄露。

Flask-WTF 会自动处理所有这些细节,后面会详细讲解。

1.7 表单处理的挑战(验证, 错误处理, 重新填充)

表单处理看似简单------获取数据、验证、保存------但实际开发中会面临诸多挑战。

1.7.1 数据验证的复杂性

一个字段可能需要多重验证,且验证规则各不相同:

python 复制代码
# 用户名:非空 + 长度限制 + 格式限制 + 唯一性检查
username = request.form.get('username', '').strip()
if not username:
    errors['username'] = '用户名不能为空'
elif len(username) < 3 or len(username) > 20:
    errors['username'] = '用户名长度必须为3-20个字符'
elif not re.match(r'^[a-zA-Z0-9_]+$', username):
    errors['username'] = '用户名只能包含字母、数字和下划线'
elif username in users_db:
    errors['username'] = '用户名已被注册'

对于日期、数字等类型,还需要手动做类型转换,转换失败时给出友好提示:

python 复制代码
age_str = request.form.get('age', '')
try:
    age = int(age_str)
    if age < 0 or age > 150:
        errors['age'] = '年龄必须在0-150之间'
except ValueError:
    errors['age'] = '年龄必须是整数'
1.7.2 错误信息的收集与展示

错误信息需要在验证过程中收集,然后在模板中按字段展示。手动处理时,错误信息通常存在一个字典中:

python 复制代码
errors = {}
# ... 各种验证 ...
if not errors:
    # 全部通过,处理数据
    pass
else:
    # 有错误,重新显示表单
    return render_template('form.html', errors=errors, ...)

问题在于,如果某个字段有多个验证规则,通常只显示第一个错误就停止,还是显示所有错误?手动处理很难统一这种策略。

1.7.3 表单数据回显

当验证失败时,用户体验最佳的做法是:保留用户已填写的数据,标记出错误字段,让用户只需修改错误部分即可重新提交。

手动实现回显需要在模板中对每个字段都做处理:

html 复制代码
<input type="text" name="username" value="{{ form_data.username if form_data.username else '' }}">

对于复选框、下拉选择等控件,回显逻辑更加复杂:

html 复制代码
<select name="city">
    <option value="beijing" {% if form_data.city == 'beijing' %}selected{% endif %}>北京</option>
    <option value="shanghai" {% if form_data.city == 'shanghai' %}selected{% endif %}>上海</option>
</select>

<input type="checkbox" name="hobbies" value="reading"
       {% if 'reading' in form_data.hobbies %}checked{% endif %}>
1.7.4 手动处理表单的完整痛点演示

为了更直观地感受手动处理表单的痛苦,下面是一个"纯手工"的用户注册表单处理代码。请注意代码的冗长度和容易出错的地方:

python 复制代码
@app.route('/register', methods=['GET', 'POST'])
def register_manual():
    errors = {}
    form_data = {}
    
    if request.method == 'POST':
        # 1. 逐个获取字段值
        username = request.form.get('username', '').strip()
        email = request.form.get('email', '').strip()
        password = request.form.get('password', '')
        confirm_password = request.form.get('confirm_password', '')
        agree = request.form.get('agree')
        
        # 保留用户已输入的数据(用于回显)
        form_data = {
            'username': username,
            'email': email,
            'agree': agree
        }
        
        # 2. 逐个验证字段(大量 if-else)
        if not username:
            errors['username'] = '用户名不能为空'
        elif len(username) < 3:
            errors['username'] = '用户名至少3个字符'
        elif len(username) > 20:
            errors['username'] = '用户名最多20个字符'
        elif not re.match(r'^[a-zA-Z0-9_]+$', username):
            errors['username'] = '用户名只能包含字母、数字和下划线'
        else:
            # 查数据库检查唯一性
            existing = User.query.filter_by(username=username).first()
            if existing:
                errors['username'] = '该用户名已被注册'
        
        if not email:
            errors['email'] = '邮箱不能为空'
        elif not re.match(r'^[^@]+@[^@]+\.[^@]+$', email):
            errors['email'] = '邮箱格式不正确'
        else:
            existing = User.query.filter_by(email=email).first()
            if existing:
                errors['email'] = '该邮箱已被注册'
        
        if not password:
            errors['password'] = '密码不能为空'
        elif len(password) < 8:
            errors['password'] = '密码至少8位'
        elif not (re.search(r'[a-z]', password) and 
                  re.search(r'[A-Z]', password) and 
                  re.search(r'\d', password)):
            errors['password'] = '密码必须包含大小写字母和数字'
        
        if password != confirm_password:
            errors['confirm_password'] = '两次输入的密码不一致'
        
        if not agree:
            errors['agree'] = '请同意用户协议'
        
        # 3. 验证通过后处理
        if not errors:
            user = User(username=username, email=email)
            user.set_password(password)
            db.session.add(user)
            db.session.commit()
            flash('注册成功', 'success')
            return redirect(url_for('login'))
    
    # 4. 渲染表单(GET请求或验证失败)
    return render_template('register_manual.html', errors=errors, form_data=form_data)

对应的 HTML 模板同样冗长,每个字段都需要手动处理值回显和错误显示:

html 复制代码
<form method="post">
    <!-- 用户名 -->
    <div class="form-group">
        <label>用户名</label>
        <input type="text" name="username" 
               value="{{ form_data.username|default('') }}"
               class="form-control {% if errors.username %}is-invalid{% endif %}">
        {% if errors.username %}
        <div class="invalid-feedback">{{ errors.username }}</div>
        {% endif %}
    </div>
    
    <!-- 邮箱 -->
    <div class="form-group">
        <label>邮箱</label>
        <input type="email" name="email"
               value="{{ form_data.email|default('') }}"
               class="form-control {% if errors.email %}is-invalid{% endif %}">
        {% if errors.email %}
        <div class="invalid-feedback">{{ errors.email }}</div>
        {% endif %}
    </div>
    
    <!-- 密码 -->
    <div class="form-group">
        <label>密码</label>
        <input type="password" name="password"
               class="form-control {% if errors.password %}is-invalid{% endif %}">
        {% if errors.password %}
        <div class="invalid-feedback">{{ errors.password }}</div>
        {% endif %}
    </div>
    
    <!-- 确认密码 -->
    <div class="form-group">
        <label>确认密码</label>
        <input type="password" name="confirm_password"
               class="form-control {% if errors.confirm_password %}is-invalid{% endif %}">
        {% if errors.confirm_password %}
        <div class="invalid-feedback">{{ errors.confirm_password }}</div>
        {% endif %}
    </div>
    
    <!-- 协议复选框 -->
    <div class="form-check">
        <input type="checkbox" name="agree" value="yes" class="form-check-input"
               {% if form_data.agree %}checked{% endif %}>
        <label class="form-check-label">我已阅读并同意用户协议</label>
        {% if errors.agree %}
        <div class="invalid-feedback">{{ errors.agree }}</div>
        {% endif %}
    </div>
    
    <button type="submit" class="btn btn-primary">注册</button>
</form>

上面的代码有几个明显的问题:验证逻辑与视图函数耦合、错误收集完全手动、模板中每个字段都要重复写回显和错误显示逻辑、没有 CSRF 保护、类型转换需要手动处理(如数字字段需要 int() 转换并捕获异常)。这还只是一个注册表单,如果有几十个表单,维护成本将极其高昂。

1.7.5 代码重复与可维护性

当应用中有大量表单时,手动处理会导致大量重复代码。每个表单都需要:获取数据、验证、收集错误、回显数据------这套流程在每个视图函数中都要写一遍。更糟糕的是,如果验证规则需要修改(比如密码最小长度从 8 改为 10),你需要在多个地方查找和修改,极易遗漏。

1.8 为什么需要表单库

综合以上分析,手动处理表单存在以下痛点,而表单库正是为解决这些问题而生:

痛点 手动处理 表单库(WTForms)
字段定义 在 HTML 和 Python 中各写一遍 一次定义,前后端共用
数据验证 大量 if-else 声明式验证器
类型转换 手动 try-except 自动转换
错误收集 手动维护字典 自动收集
数据回显 手动逐字段回填 自动回显
CSRF 保护 手动实现 自动处理
代码复用 几乎无法复用 表单类可继承组合
模板渲染 手写 HTML 可用宏统一渲染

WTForms 的设计理念是:将表单的结构、验证规则、渲染方式以声明式的方式集中定义在一个 Python 类中,实现"一次定义,处处使用"。

下面是同样的注册表单,用 WTForms 改写后的效果(预览,后面会详细讲解):

python 复制代码
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, EqualTo

class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[
        DataRequired(message='用户名不能为空'),
        Length(min=3, max=20, message='用户名长度必须为3-20个字符')
    ])
    email = StringField('邮箱', validators=[
        DataRequired(message='邮箱不能为空'),
        Email(message='邮箱格式不正确')
    ])
    password = PasswordField('密码', validators=[
        DataRequired(message='密码不能为空'),
        Length(min=6, message='密码至少6个字符')
    ])
    confirm_password = PasswordField('确认密码', validators=[
        DataRequired(message='请确认密码'),
        EqualTo('password', message='两次密码不一致')
    ])
    agree = BooleanField('我已阅读并同意用户协议', validators=[
        DataRequired(message='请同意用户协议')
    ])
    submit = SubmitField('注册')

对比之前几十行的手动验证代码,WTForms 的声明式写法简洁了数倍,而且验证规则与字段定义在一起,可读性和可维护性都大幅提升。从下一章开始,我们将深入学习 WTForms 和 Flask-WTF 的方方面面。


第二章 Flask-WTF与WTForms

2.1 WTForms概述与安装

WTForms 是一个灵活的 Python 表单验证和渲染库。它的设计目标是将表单的定义、验证逻辑和渲染方式解耦,让开发者可以用面向对象的方式管理表单。

WTForms 的核心特性包括:

  • 声明式字段定义:通过类属性的方式定义表单字段,类型清晰。
  • 内置验证器:提供常用验证规则,如必填、长度、邮箱、URL 等。
  • 可扩展性:支持自定义字段类型和验证器。
  • 框架无关:WTForms 本身不依赖任何 Web 框架,可以在 Flask、Django、Bottle 等框架中使用。
  • HTML 渲染:内置字段渲染能力,可直接生成 HTML 控件。

安装 WTForms:

bash 复制代码
pip install WTForms

安装后可以在 Python 中直接使用:

python 复制代码
from wtforms import Form, StringField, validators

class ContactForm(Form):
    name = StringField('姓名', [validators.DataRequired()])
    email = StringField('邮箱', [validators.Email()])

上面是 WTForms 原生的用法,使用了 wtforms.Form 基类。在 Flask 中,我们通常使用 Flask-WTF 提供的 FlaskForm 基类,它在此基础上增加了 Flask 集成能力。

2.2 Flask-WTF概述与安装

Flask-WTF 是 WTForms 在 Flask 中的集成层。它的主要职责是:

  1. CSRF 保护:自动为所有 POST 表单生成和验证 CSRF 令牌。
  2. 文件上传支持 :提供 FileFieldMultipleFileField,处理文件上传验证。
  3. Flask 集成 :自动从 request.formrequest.files 中读取数据填充表单。
  4. Recaptcha 支持:集成 Google reCAPTCHA 验证码。
  5. 本地化:支持 Flask-Babel 进行表单消息的国际化。

安装 Flask-WTF:

bash 复制代码
pip install Flask-WTF

Flask-WTF 安装时会自动安装 WTForms 作为依赖。

一个最小化的 Flask-WTF 示例:

python 复制代码
from flask import Flask, render_template, redirect, url_for, flash
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'  # Flask-WTF 需要

class NameForm(FlaskForm):
    name = StringField('你的名字', validators=[DataRequired()])
    submit = SubmitField('提交')

@app.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        flash(f'你好, {form.name.data}!')
        return redirect(url_for('index'))
    return render_template('index.html', form=form)
html 复制代码
<!-- templates/index.html -->
<form method="post">
    {{ form.hidden_tag() }}
    {{ form.name.label }} {{ form.name() }}
    {% for error in form.name.errors %}
        <span style="color: red;">{{ error }}</span>
    {% endfor %}
    {{ form.submit() }}
</form>

2.3 Flask-WTF配置

Flask-WTF 通过 Flask 的配置项来控制其行为。以下是所有相关配置项:

2.3.1 核心配置项
python 复制代码
app = Flask(__name__)

# === 基础配置 ===
app.config['SECRET_KEY'] = 'your-secret-key'  # 必须设置,用于 session 和 CSRF

# === CSRF 配置 ===
app.config['WTF_CSRF_ENABLED'] = True            # 是否启用 CSRF 保护,默认 True
app.config['WTF_CSRF_SECRET_KEY'] = 'csrf-secret'  # CSRF 令牌的加密密钥,默认使用 SECRET_KEY
app.config['WTF_CSRF_TIME_LIMIT'] = 3600          # CSRF 令牌有效期(秒),默认 3600
app.config['WTF_CSRF_SSL_STRICT'] = True          # 是否强制 HTTPS 校验 Referer,默认 True
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']  # 接受 CSRF 令牌的请求头
app.config['WTF_CSRF_FIELD_NAME'] = 'csrf_token'  # CSRF 令牌的字段名,默认 'csrf_token'
app.config['WTF_CSRF_METHODS'] = ['POST', 'PUT', 'PATCH', 'DELETE']  # 需要 CSRF 保护的 HTTP 方法

# === 文件上传配置 ===
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 最大请求体 16MB
app.config['WTF_FIELD_SUFFIX'] = ''  # 字段后缀,一般不修改
2.3.2 配置项详解
配置项 默认值 说明
SECRET_KEY 无(必须设置) Flask session 加密密钥
WTF_CSRF_ENABLED True 是否启用 CSRF 保护
WTF_CSRF_SECRET_KEY SECRET_KEY CSRF 令牌签名密钥
WTF_CSRF_TIME_LIMIT 3600 令牌有效期(秒),None 表示不过期
WTF_CSRF_SSL_STRICT True HTTPS 下是否严格校验 Referer
WTF_CSRF_HEADERS ['X-CSRFToken', 'X-CSRF-Token'] AJAX 请求传递令牌的请求头
WTF_CSRF_FIELD_NAME 'csrf_token' 表单中 CSRF 字段名
WTF_CSRF_METHODS ['POST', 'PUT', 'PATCH', 'DELETE'] 需要 CSRF 保护的 HTTP 方法
MAX_CONTENT_LENGTH None 请求体最大字节数,超出返回 413
2.3.3 在不同环境下的配置

实际项目中,通常使用不同环境不同配置的策略:

python 复制代码
import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key')
    WTF_CSRF_ENABLED = True
    WTF_CSRF_TIME_LIMIT = 3600

class DevelopmentConfig(Config):
    DEBUG = True
    WTF_CSRF_ENABLED = False  # 开发环境可关闭 CSRF 方便测试
    SECRET_KEY = 'dev-secret-key'

class ProductionConfig(Config):
    DEBUG = False
    WTF_CSRF_ENABLED = True
    WTF_CSRF_SSL_STRICT = True
    SECRET_KEY = os.environ.get('SECRET_KEY')  # 生产环境必须从环境变量读取

class TestingConfig(Config):
    TESTING = True
    WTF_CSRF_ENABLED = False  # 测试环境关闭 CSRF
    SECRET_KEY = 'test-secret-key'

config = {
    'development': DevelopmentConfig,
    'production': ProductionConfig,
    'testing': TestingConfig,
    'default': DevelopmentConfig
}

在测试中关闭 CSRF 是常见做法,可以避免在测试代码中手动注入 CSRF 令牌。

2.4 CSRF保护初始化

Flask-WTF 提供了两种方式来初始化 CSRF 保护。

2.4.1 方式一:FlaskForm 自动保护(推荐用于表单)

当你继承 FlaskForm 创建表单类时,CSRF 保护会自动启用。只需在模板中调用 form.hidden_tag() 即可渲染 CSRF 令牌:

python 复制代码
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired

class MyForm(FlaskForm):
    name = StringField('名称', validators=[DataRequired()])
    submit = SubmitField('提交')
html 复制代码
<form method="post">
    {{ form.hidden_tag() }}  <!-- 渲染 CSRF 令牌 -->
    {{ form.name.label }} {{ form.name() }}
    {{ form.submit() }}
</form>

form.hidden_tag() 会生成如下 HTML:

html 复制代码
<input id="csrf_token" name="csrf_token" type="hidden" value="eyJ...">
2.4.2 方式二:CSRFProtect 全局保护(推荐用于 AJAX/API)

如果你需要保护所有 POST/PUT/DELETE 请求(包括不使用 FlaskForm 的 AJAX 请求),可以使用 CSRFProtect:

python 复制代码
from flask import Flask
from flask_wtf.csrf import CSRFProtect

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

csrf = CSRFProtect(app)  # 全局 CSRF 保护

# 现在所有 POST/PUT/PATCH/DELETE 请求都会自动验证 CSRF 令牌

在模板中,即使没有表单类,也可以生成 CSRF 令牌:

html 复制代码
<meta name="csrf-token" content="{{ csrf_token() }}">

<script>
// AJAX 请求时在请求头中携带 CSRF 令牌
fetch('/api/data', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': '{{ csrf_token() }}'
    },
    body: JSON.stringify({ data: 'hello' })
});
</script>
2.4.3 两种方式结合使用

实际项目中,通常同时使用两种方式:

python 复制代码
from flask import Flask
from flask_wtf import FlaskForm, CSRFProtect

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

csrf = CSRFProtect(app)  # 全局保护

# 同时使用 FlaskForm 处理表单
class MyForm(FlaskForm):
    pass
2.4.4 豁免 CSRF 保护

某些视图(如 Webhook 回调)不需要 CSRF 保护,可以使用 @csrf.exempt 装饰器豁免:

python 复制代码
@app.route('/webhook', methods=['POST'])
@csrf.exempt
def webhook():
    # 这个路由不需要 CSRF 保护
    return {'status': 'ok'}

对于蓝图,可以豁免整个蓝图:

python 复制代码
from flask import Blueprint

api_bp = Blueprint('api', __name__, url_prefix='/api')
csrf.exempt(api_bp)  # 整个 API 蓝图豁免 CSRF
2.4.5 在测试中处理 CSRF

在测试中如果开启了 CSRF,需要在请求中携带令牌:

python 复制代码
def test_submit(client):
    # 获取 CSRF 令牌
    response = client.get('/form')
    csrf_token = extract_csrf_token(response.data)  # 从 HTML 中提取令牌

    # 提交表单时携带令牌
    response = client.post('/form', data={
        'name': 'test',
        'csrf_token': csrf_token
    })
    assert response.status_code == 200

或者更简单的方式是直接在测试配置中关闭 CSRF:

python 复制代码
class TestingConfig:
    WTF_CSRF_ENABLED = False

2.5 WTForms架构设计

理解 WTForms 的架构设计,有助于你更灵活地使用它。WTForms 的核心由三个概念组成:Field(字段)、Validator(验证器)和 Form(表单)。

2.5.1 架构总览
复制代码
┌─────────────────────────────────────────┐
│              Form(表单)                  │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐   │
│  │ Field 1 │ │ Field 2 │ │ Field N │   │
│  │         │ │         │ │         │   │
│  │validators│ │validators│ │validators│  │
│  │ [V1, V2]│ │ [V3]    │ │ [V4, V5]│   │
│  │         │ │         │ │         │   │
│  │ Widget  │ │ Widget  │ │ Widget  │   │
│  └─────────┘ └─────────┘ └─────────┘   │
│                                         │
│  validate() → 遍历所有字段执行验证器     │
│  errors → 收集所有验证错误               │
└─────────────────────────────────────────┘
2.5.2 Field(字段)

Field 是表单的基本组成单元,每个字段对应一个用户输入。Field 的核心职责:

  1. 存储数据:接收用户输入的原始数据和经过处理的数据。
  2. 执行验证:调用绑定的验证器列表,收集错误信息。
  3. 渲染 HTML:通过 Widget 生成对应的 HTML 控件。

Field 的核心属性:

python 复制代码
from wtforms import StringField
from wtforms.validators import DataRequired, Length

name = StringField(
    label='用户名',           # 字段标签
    validators=[              # 验证器列表
        DataRequired(),
        Length(min=3, max=20)
    ],
    description='3-20个字符',  # 字段描述
    default='',               # 默认值
    render_kw={               # 渲染时的额外 HTML 属性
        'class': 'form-control',
        'placeholder': '请输入用户名',
        'data-toggle': 'tooltip'
    },
    widget=None,              # 自定义 Widget(通常不直接指定)
    filters=[]                # 数据过滤器
)

Field 的核心方法:

python 复制代码
# 数据相关
field.data          # 处理后的数据(经过类型转换)
field.raw_data      # 原始数据(字符串列表)

# 验证相关
field.validate(form, extra_validators)  # 执行验证
field.errors        # 该字段的错误列表
field.pre_validate(form)   # 验证前的钩子
field.post_validate(form, validation_stopped)  # 验证后的钩子

# 渲染相关
field()             # 调用 widget 渲染 HTML
field.label         # Label 对象,field.label() 渲染 <label>
field.__call__(**kwargs)  # 带参数渲染

# 状态相关
field.flags        # 验证标志(如 required, optional)
2.5.3 Validator(验证器)

Validator 是一个可调用对象,接收表单和字段作为参数,验证失败时抛出 ValidationError:

python 复制代码
class Validator:
    def __init__(self, *args, **kwargs):
        # 初始化验证参数
        pass

    def __call__(self, form, field):
        # 验证逻辑
        if not valid:
            raise ValidationError('错误信息')

验证器可以独立使用,也可以作为字段类的方法定义:

python 复制代码
# 独立验证器(函数式)
def my_validator(form, field):
    if field.data == 'forbidden':
        raise ValidationError('禁止使用此值')

class MyForm(FlaskForm):
    name = StringField('名称', validators=[my_validator])

# 内联验证器(方法式)
class MyForm(FlaskForm):
    name = StringField('名称')

    def validate_name(self, field):
        if field.data == 'forbidden':
            raise ValidationError('禁止使用此值')
2.5.4 Form(表单)

Form 是字段的容器,负责协调所有字段的验证和数据管理:

python 复制代码
from wtforms import Form

class MyForm(Form):
    name = StringField('名称')
    email = StringField('邮箱')

form = MyForm(formdata=request.form)  # 从请求数据创建表单

# 验证
if form.validate():  # 返回 True/False
    # 验证通过
    name = form.name.data
    email = form.email.data

# 获取所有错误
for field_name, errors in form.errors.items():
    for error in errors:
        print(f'{field_name}: {error}')

Form 的核心方法:

方法 说明
form.validate() 验证所有字段,返回 True/False
form.errors 所有字段的错误信息字典
form.data 所有字段的数据字典
form.populate_obj(obj) 将表单数据填充到对象属性
form.process(formdata, obj, data) 处理输入数据填充字段

2.6 WTForms版本特性

WTForms 经历了多个版本的演进,不同版本之间有一些差异。

2.6.1 主要版本对比
版本 发布时间 主要特性
WTForms 1.x 2010-2013 初始版本,基本字段和验证器
WTForms 2.x 2013-2020 新增 FieldList、FormField、JSON 支持,Python 3 支持
WTForms 3.x 2021-至今 全面 Python 3,移除 Python 2 支持,类型注解,新验证器
2.6.2 WTForms 3.x 的重要变化

WTForms 3.0 是一个重大版本更新,主要变化包括:

  1. 移除 Python 2 支持:最低要求 Python 3.7。

  2. 验证器使用方式变化:

    python 复制代码
    # WTForms 2.x - 可以直接传列表
    name = StringField('名称', [DataRequired(), Length(min=3)])
    
    # WTForms 3.x - 推荐使用 validators 关键字
    name = StringField('名称', validators=[DataRequired(), Length(min=3)])

    实际上两种方式在 3.x 中都可以使用,但 validators= 是推荐写法。

  3. email_validator 独立包 :Email 验证器现在依赖 email_validator 包,需要单独安装:

    bash 复制代码
    pip install email_validator
  4. 改进的 JSON 支持:

    python 复制代码
    from wtforms import Form
    from wtforms.fields import StringField
    
    class MyForm(Form):
        name = StringField()
    
    # 从 JSON 数据创建表单
    import json
    data = json.loads('{"name": "test"}')
    form = MyForm(data=data)
  5. 更严格的类型检查:使用类型注解,IDE 支持更好。

2.6.3 Flask-WTF 版本对应
Flask-WTF 版本 WTForms 版本 Python 版本
0.14.x 2.x Python 2.7 / 3.5+
1.0.x 3.x Python 3.7+
1.2.x 3.x Python 3.8+

建议使用最新稳定版本:

bash 复制代码
pip install Flask-WTF WTForms email_validator

2.7 Flask-WTF与WTForms的关系

Flask-WTF 和 WTForms 的关系可以理解为"集成层"和"核心库"的关系。

2.7.1 职责划分
复制代码
┌──────────────────────────────────────────┐
│              你的应用代码                  │
├──────────────────────────────────────────┤
│  Flask-WTF (集成层)                       │
│  - FlaskForm (继承 wtforms.Form)          │
│  - CSRFProtect (CSRF 保护)               │
│  - FileField (文件上传字段)               │
│  - 从 request.form/files 自动填充数据     │
│  - reCAPTCHA 集成                         │
├──────────────────────────────────────────┤
│  WTForms (核心库)                         │
│  - Field (字段类型)                       │
│  - Validator (验证器)                     │
│  - Widget (HTML 渲染)                    │
│  - Form (表单基类)                        │
│  - 错误处理                               │
├──────────────────────────────────────────┤
│  Flask (Web 框架)                         │
│  - request / session / redirect          │
│  - Jinja2 模板                            │
└──────────────────────────────────────────┘
2.7.2 代码层面的关系

Flask-WTF 的 FlaskForm 继承自 WTForms 的 Form,并做了以下增强:

python 复制代码
# WTForms 原生 Form
from wtforms import Form, StringField

class MyForm(Form):
    name = StringField('名称')

# 使用时需要手动传入 formdata
from werkzeug.datastructures import MultiDict
form = MyForm(formdata=MultiDict([('name', 'test')]))

# Flask-WTF 的 FlaskForm
from flask_wtf import FlaskForm
from wtforms import StringField

class MyForm(FlaskForm):
    name = StringField('名称')

# 使用时自动从 request 中读取数据,无需手动传入
form = MyForm()  # 在视图函数中,自动从 request.form 填充
2.7.3 何时用 WTForms,何时用 Flask-WTF
场景 选择
Flask Web 应用 Flask-WTF(FlaskForm)
纯 API 数据验证 WTForms(不依赖 Flask 请求)
非 Flask 框架 WTForms
需要 CSRF 保护 Flask-WTF(CSRFProtect)
需要文件上传验证 Flask-WTF(FileField)
批量数据导入验证 WTForms

一个在非 Flask 环境中使用 WTForms 的例子:

python 复制代码
from wtforms import Form, StringField, IntegerField
from wtforms.validators import DataRequired, NumberRange

class ImportForm(Form):
    name = StringField('名称', validators=[DataRequired()])
    age = IntegerField('年龄', validators=[NumberRange(min=0, max=150)])

# 从字典创建
data = {'name': '张三', 'age': '25'}
form = ImportForm(data=data)

if form.validate():
    print(f'验证通过: {form.name.data}, {form.age.data}')
else:
    print(f'验证失败: {form.errors}')
2.7.4 FlaskForm 相比 Form 的增强
特性 wtforms.Form flask_wtf.FlaskForm
数据来源 手动传入 formdata 自动从 request 读取
CSRF 保护 自动
文件上传 需手动处理 FileField 自动处理
隐藏字段 hidden_tag() 方法
Recaptcha 内置支持
session 访问 可访问 Flask session

理解了 Flask-WTF 和 WTForms 的关系后,我们就可以开始深入学习各种字段类型了。

2.7.5 Flask-WTF 配置实践

在实际项目中,Flask-WTF 的配置需要根据环境(开发/测试/生产)进行差异化设置。以下是一套推荐的生产级配置方案:

python 复制代码
import os
from flask import Flask

app = Flask(__name__)

# 基础配置
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
app.config['WTF_CSRF_ENABLED'] = True
app.config['WTF_CSRF_SECRET_KEY'] = os.environ.get('WTF_CSRF_SECRET_KEY') or app.config['SECRET_KEY']

# CSRF 令牌有效期(秒),默认 3600(1小时)
# 根据表单复杂度调整:简单表单 1800,复杂表单 7200
app.config['WTF_CSRF_TIME_LIMIT'] = 3600

# 仅在 HTTPS 下接受 CSRF 令牌(生产环境必须开启)
app.config['WTF_CSRF_SSL_STRICT'] = os.environ.get('FLASK_ENV') == 'production'

# 需要 CSRF 保护的 HTTP 方法
app.config['WTF_CSRF_METHODS'] = ['POST', 'PUT', 'PATCH', 'DELETE']

# CSRF 令牌的字段名(默认为 csrf_token,一般不需要修改)
app.config['WTF_CSRF_FIELD_NAME'] = 'csrf_token'

# CSRF 令牌的 header 名称(AJAX 请求中使用)
app.config['WTF_CSRF_HEADERS'] = ['X-CSRFToken', 'X-CSRF-Token']

# 文件上传相关配置
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 16MB
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx'}

# 测试环境特殊配置
if app.config['TESTING']:
    app.config['WTF_CSRF_ENABLED'] = False  # 测试时禁用 CSRF
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
2.7.6 Flask-WTF 的 reCAPTCHA 集成

Flask-WTF 内置了 Google reCAPTCHA 支持,可以有效防止机器人注册和垃圾提交:

python 复制代码
from flask_wtf import FlaskForm, RecaptchaField
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length

app.config['RECAPTCHA_PUBLIC_KEY'] = 'your-site-key'
app.config['RECAPTCHA_PRIVATE_KEY'] = 'your-secret-key'
# 可选:主题(dark/light)、语言
app.config['RECAPTCHA_THEME'] = 'light'
app.config['RECAPTCHA_TYPE'] = 'image'
app.config['RECAPTCHA_SIZE'] = 'normal'
# reCAPTCHA v3 (invisible) 的分数阈值
app.config['RECAPTCHA_THRESHOLD'] = 0.5

class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=20)])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
    recaptcha = RecaptchaField()
    submit = SubmitField('注册')
html 复制代码
<!-- 模板中需要引入 reCAPTCHA JavaScript -->
<head>
    <script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>

<form method="post">
    {{ form.hidden_tag() }}
    {{ form.username.label }} {{ form.username() }}
    {{ form.email.label }} {{ form.email() }}
    {{ form.password.label }} {{ form.password() }}
    {{ form.recaptcha() }}
    {% for error in form.recaptcha.errors %}
        <span class="error">{{ error }}</span>
    {% endfor %}
    {{ form.submit() }}
</form>

注意:在中国大陆使用 reCAPTCHA 可能存在访问问题,可以考虑使用替代方案,如 hCaptcha、腾讯验证码或极验滑块验证。对于这些替代方案,可以通过自定义字段来实现集成。


第三章 表单字段类型

WTForms 提供了丰富的字段类型,几乎覆盖了所有常见的 HTML 表单控件。本章将逐一讲解每种字段类型的使用方法、属性和注意事项。

3.1 字段基类(Field)与属性

所有字段类型都继承自 wtforms.fields.Field 基类。理解基类的属性和参数,是使用所有字段的基础。

3.1.1 Field 构造参数
python 复制代码
from wtforms import StringField

field = StringField(
    label='用户名',               # 1. 标签文本
    validators=[],                # 2. 验证器列表
    filters=[],                   # 3. 数据过滤器
    description='说明文字',        # 4. 字段描述
    id='username',                # 5. HTML id 属性
    default='默认值',              # 6. 默认值
    widget=None,                  # 7. 自定义 Widget
    render_kw={},                 # 8. 渲染时额外 HTML 属性
    name=None,                    # 9. HTML name 属性(默认用字段名)
    _form=None,                   # 内部使用
    _name=None,                   # 内部使用
    _translations=None,           # 内部使用
)

各参数详解:

label(标签) :

字段的显示标签,用于渲染 <label> 标签。可以传字符串,也可以传 Label 对象。

python 复制代码
# 字符串
name = StringField('用户名')

# Label 对象(更灵活)
from wtforms import Label
name = StringField(label=Label('username', '<span>用户名</span>', for_='username'))

validators(验证器) :

验证规则列表,按顺序执行。详见第四章。

python 复制代码
from wtforms.validators import DataRequired, Length

name = StringField('用户名', validators=[
    DataRequired(message='不能为空'),
    Length(min=3, max=20)
])

filters(过滤器) :

在验证之前对数据进行预处理的函数列表。每个过滤器接收原始值,返回处理后的值。

python 复制代码
# 去除首尾空格
def strip_filter(value):
    if value is not None and hasattr(value, 'strip'):
        return value.strip()
    return value

# 转为小写
def lower_filter(value):
    if value:
        return value.lower()
    return value

email = StringField('邮箱', filters=[strip_filter, lower_filter])

description(描述) :

字段的描述信息,常用于渲染帮助文本。

python 复制代码
password = StringField('密码', description='至少8位,包含大小写字母和数字')
html 复制代码
{{ form.password.label }}
{{ form.password() }}
<small class="form-text">{{ form.password.description }}</small>

id :

HTML id 属性。默认使用字段名。

python 复制代码
name = StringField('用户名', id='user-name')

default(默认值) :

当表单没有数据时的默认值。可以是静态值,也可以是可调用对象。

python 复制代码
# 静态默认值
status = SelectField('状态', choices=[('active', '有效')], default='active')

# 可调用默认值(每次创建表单时调用)
import datetime
created_at = DateTimeField('创建时间', default=datetime.datetime.utcnow)

# 使用函数
def generate_code():
    import random
    return random.randint(100000, 999999)

code = IntegerField('验证码', default=generate_code)

render_kw(渲染关键字) :

渲染时添加到 HTML 标签上的额外属性。这是最常用的定制渲染方式。

python 复制代码
name = StringField('用户名', render_kw={
    'class': 'form-control',
    'placeholder': '请输入用户名',
    'maxlength': '20',
    'autocomplete': 'off',
    'data-toggle': 'tooltip',
    'title': '用户名用于登录'
})

渲染结果:

html 复制代码
<input class="form-control" id="name" maxlength="20" name="name"
       placeholder="请输入用户名" autocomplete="off"
       data-toggle="tooltip" title="用户名用于登录" type="text" value="">
3.1.2 Field 实例属性

创建字段实例后,可以访问以下属性:

python 复制代码
form = MyForm()

# 数据
form.name.data         # 处理后的数据(类型转换后)
form.name.raw_data     # 原始数据(字符串元组,如 ('张三',))
form.name.object_data  # 对象数据(从 populate_obj 传入的)

# 标签
form.name.label        # Label 对象
form.name.label.text   # 标签文本
form.name.label()      # 渲染 <label> 标签

# 描述
form.name.description  # 描述文本

# 验证
form.name.errors       # 错误列表,如 ['不能为空']
form.name.flags        # Flags 对象,如 Flags(required=True)
form.name.flags.required  # 是否必填

# 渲染
form.name()            # 渲染 HTML 控件
form.name.type         # 字段类型名称,如 'StringField'
form.name.short_name   # 字段名(不含前缀),如 'name'
form.name.name         # HTML name 属性
form.name.id           # HTML id 属性

3.2 文本字段(StringField, TextAreaField)

3.2.1 StringField

StringField 是最常用的字段类型,渲染为 <input type="text">,数据类型为字符串。

python 复制代码
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length

class ProfileForm(FlaskForm):
    username = StringField('用户名', validators=[
        DataRequired(message='用户名不能为空'),
        Length(min=3, max=20, message='用户名长度3-20个字符')
    ], render_kw={'class': 'form-control', 'placeholder': '请输入用户名'})

    nickname = StringField('昵称', validators=[
        Length(max=50, message='昵称最多50个字符')
    ], render_kw={'class': 'form-control'})

    submit = SubmitField('保存', render_kw={'class': 'btn btn-primary'})

渲染结果:

html 复制代码
<input class="form-control" id="username" name="username"
       placeholder="请输入用户名" type="text" value="">
3.2.2 TextAreaField

TextAreaField 渲染为 <textarea>,用于多行文本输入。数据类型同样是字符串,但支持换行。

python 复制代码
from wtforms import TextAreaField

class ArticleForm(FlaskForm):
    title = StringField('标题', validators=[DataRequired()])
    content = TextAreaField('正文', validators=[
        DataRequired(),
        Length(min=10, max=10000, message='正文长度10-10000字')
    ], render_kw={
        'class': 'form-control',
        'rows': 10,
        'placeholder': '请输入文章正文...'
    })

渲染结果:

html 复制代码
<textarea class="form-control" id="content" name="content"
          rows="10" placeholder="请输入文章正文..."></textarea>

3.3 密码字段(PasswordField)

PasswordField 渲染为 <input type="password">,输入内容显示为圆点。数据类型为字符串。

python 复制代码
from wtforms import PasswordField
from wtforms.validators import DataRequired, Length, EqualTo

class ChangePasswordForm(FlaskForm):
    old_password = PasswordField('当前密码', validators=[
        DataRequired(message='请输入当前密码')
    ], render_kw={'class': 'form-control'})

    new_password = PasswordField('新密码', validators=[
        DataRequired(message='请输入新密码'),
        Length(min=8, max=32, message='密码长度8-32位'),
        Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
               message='密码必须包含大小写字母和数字')
    ], render_kw={'class': 'form-control'})

    confirm_password = PasswordField('确认新密码', validators=[
        DataRequired(message='请确认新密码'),
        EqualTo('new_password', message='两次密码不一致')
    ], render_kw={'class': 'form-control'})

注意:密码字段默认不会回显数据(出于安全考虑),即使用户验证失败后,密码框也会是空的。这是正确的行为。

3.4 数字字段(IntegerField, FloatField, DecimalField)

数字字段会自动将输入的字符串转换为对应的数字类型。

3.4.1 IntegerField

IntegerField 渲染为 <input type="number">,数据类型为 int。转换失败时会产生验证错误。

python 复制代码
from wtforms import IntegerField
from wtforms.validators import DataRequired, NumberRange

class OrderForm(FlaskForm):
    quantity = IntegerField('数量', validators=[
        DataRequired(message='请输入数量'),
        NumberRange(min=1, max=999, message='数量必须在1-999之间')
    ], render_kw={'class': 'form-control', 'min': '1', 'max': '999'})

    # 带默认值
    page = IntegerField('页码', default=1)

    # 可选数字字段(允许为空)
    discount = IntegerField('折扣(%)', validators=[Optional()])

如果用户输入 abc,IntegerField 会自动生成错误 "请输入有效数字"(可通过 render_kw 自定义)。

3.4.2 FloatField

FloatField 数据类型为 float,用于浮点数输入。

python 复制代码
from wtforms import FloatField

class ProductForm(FlaskForm):
    weight = FloatField('重量(kg)', validators=[
        NumberRange(min=0.01, max=1000, message='重量范围0.01-1000kg')
    ], render_kw={'step': '0.01'})

    price = FloatField('价格', validators=[
        NumberRange(min=0, message='价格不能为负')
    ])
3.4.3 DecimalField

DecimalField 使用 Python 的 decimal.Decimal 类型,适合需要精确计算的场景(如金融金额)。

python 复制代码
from wtforms import DecimalField
from decimal import Decimal

class PaymentForm(FlaskForm):
    amount = DecimalField('金额', validators=[
        NumberRange(min=Decimal('0.01'), message='金额至少0.01')
    ], places=2, rounding=None, render_kw={'step': '0.01'})

DecimalField 的特有参数:

参数 说明 默认值
places 小数位数 None(不限制)
rounding 舍入方式 ROUND_HALF_EVEN
python 复制代码
from decimal import ROUND_HALF_UP

# 保留2位小数,四舍五入
amount = DecimalField('金额', places=2, rounding=ROUND_HALF_UP)

3.5 布尔字段(BooleanField)

BooleanField 渲染为 <input type="checkbox">,数据类型为 bool

python 复制代码
from wtforms import BooleanField

class SettingsForm(FlaskForm):
    remember_me = BooleanField('记住我')
    newsletter = BooleanField('订阅邮件通知', default=True)  # 默认勾选
    agree_terms = BooleanField('同意服务条款', validators=[
        DataRequired(message='必须同意服务条款才能注册')
    ])

注意 BooleanFieldDataRequired 配合的特殊行为:复选框未勾选时,提交的值为 FalseDataRequired 验证器对 BooleanField 的处理是"必须为 True",因此常用于强制用户勾选协议。

渲染结果:

html 复制代码
<input id="remember_me" name="remember_me" type="checkbox" value="y">

获取数据:

python 复制代码
if form.validate_on_submit():
    if form.remember_me.data:  # True 或 False
        # 设置持久化 session
        pass

3.6 日期时间字段(DateField, DateTimeField, TimeField)

日期时间字段提供了日期/时间的选择和数据类型转换。

3.6.1 DateField

DateField 渲染为 <input type="date">,数据类型为 datetime.date

python 复制代码
from wtforms import DateField
from wtforms.validators import DataRequired
import datetime

class EventForm(FlaskForm):
    event_date = DateField('活动日期', validators=[
        DataRequired(message='请选择日期')
    ], format='%Y-%m-%d', render_kw={'class': 'form-control'})

    # 设置默认值为今天
    start_date = DateField('开始日期', default=datetime.date.today)

    # 设置默认值为特定日期
    deadline = DateField('截止日期', default=datetime.date(2025, 12, 31))

format 参数指定日期格式,默认为 %Y-%m-%d。如果前端使用的日期格式不同,需要修改:

python 复制代码
# 中文日期格式
date = DateField('日期', format='%Y年%m月%d日')

# 不同分隔符
date = DateField('日期', format='%d/%m/%Y')  # 日/月/年
3.6.2 DateTimeField

DateTimeField 数据类型为 datetime.datetime

python 复制代码
from wtforms import DateTimeField

class ScheduleForm(FlaskForm):
    start_time = DateTimeField('开始时间', format='%Y-%m-%d %H:%M',
                               default=datetime.datetime.utcnow)
    end_time = DateTimeField('结束时间', format='%Y-%m-%d %H:%M')
3.6.3 TimeField

TimeField 数据类型为 datetime.time

python 复制代码
from wtforms import TimeField

class MeetingForm(FlaskForm):
    meeting_time = TimeField('会议时间', format='%H:%M', default=datetime.time(9, 0))
3.6.4 日期验证示例
python 复制代码
from wtforms.validators import ValidationError
import datetime

class BookingForm(FlaskForm):
    check_in = DateField('入住日期', validators=[DataRequired()])
    check_out = DateField('退房日期', validators=[DataRequired()])

    def validate_check_out(self, field):
        if field.data <= self.check_in.data:
            raise ValidationError('退房日期必须晚于入住日期')

        if (field.data - self.check_in.data).days > 30:
            raise ValidationError('最多预订30天')

    def validate_check_in(self, field):
        if field.data < datetime.date.today():
            raise ValidationError('入住日期不能早于今天')

3.7 选择字段(SelectField, SelectMultipleField, RadioField)

选择字段是表单中最常用的字段类型之一,用于从预定义选项中选择。

3.7.1 SelectField(单选下拉)

SelectField 渲染为 <select> 单选下拉框。

python 复制代码
from wtforms import SelectField

class AddressForm(FlaskForm):
    province = SelectField('省份', choices=[
        ('', '请选择省份'),
        ('beijing', '北京'),
        ('shanghai', '上海'),
        ('guangdong', '广东'),
        ('jiangsu', '江苏'),
    ], validators=[DataRequired(message='请选择省份')], render_kw={'class': 'form-control'})

choices 是一个元组列表,每个元组的第一个元素是提交给服务器的值(value),第二个元素是显示给用户的文本(label)。

渲染结果:

html 复制代码
<select class="form-control" id="province" name="province">
    <option value="">请选择省份</option>
    <option value="beijing">北京</option>
    <option value="shanghai">上海</option>
    <option value="guangdong">广东</option>
    <option value="jiangsu">江苏</option>
</select>
3.7.2 动态 choices

实际项目中,choices 往往需要从数据库动态生成。有两种方式:

方式一:在视图函数中动态赋值

python 复制代码
class ArticleForm(FlaskForm):
    category = SelectField('分类', choices=[], coerce=int)

    submit = SubmitField('发布')

@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
    form = ArticleForm()
    # 从数据库获取分类
    categories = Category.query.all()
    form.category.choices = [(c.id, c.name) for c in categories]
    form.category.choices.insert(0, (0, '请选择分类'))

    if form.validate_on_submit():
        category_id = form.category.data
        # ...
    return render_template('new_article.html', form=form)

方式二:使用可调用对象

python 复制代码
def get_category_choices():
    """从数据库获取分类选项"""
    categories = Category.query.all()
    return [(c.id, c.name) for c in categories]

class ArticleForm(FlaskForm):
    category = SelectField('分类', choices=get_category_choices, coerce=int)

注意:使用可调用对象时,choices 在表单实例化时求值。如果数据库内容在请求处理期间发生变化,需要重新实例化表单。

3.7.3 coerce 参数

coerce 参数指定将选择的值强制转换的类型,默认为 str

python 复制代码
# ID 是整数时
category = SelectField('分类', choices=[(1, '技术'), (2, '生活')], coerce=int)

# 转换失败会报错
# 用户篡改提交了 category=abc → 验证错误
3.7.4 SelectMultipleField(多选下拉)

SelectMultipleField 渲染为 <select multiple>,允许选择多个值,数据类型为列表。

python 复制代码
from wtforms import SelectMultipleField

class TagForm(FlaskForm):
    tags = SelectMultipleField('标签', choices=[
        ('python', 'Python'),
        ('flask', 'Flask'),
        ('django', 'Django'),
        ('fastapi', 'FastAPI'),
    ], coerce=str, render_kw={'class': 'form-control', 'size': '5'})

获取数据:

python 复制代码
if form.validate_on_submit():
    selected_tags = form.tags.data  # ['python', 'flask']

渲染时,需要设置 size 属性才能看到多个选项,否则只会显示一行。

3.7.5 RadioField(单选按钮)

RadioField 渲染为一组 <input type="radio">,功能与 SelectField 相同,只是 UI 表现不同。

python 复制代码
from wtforms import RadioField

class SurveyForm(FlaskForm):
    gender = RadioField('性别', choices=[
        ('male', '男'),
        ('female', '女'),
        ('other', '其他'),
    ], validators=[DataRequired(message='请选择性别')])

    rating = RadioField('评分', choices=[
        ('1', '1星'),
        ('2', '2星'),
        ('3', '3星'),
        ('4', '4星'),
        ('5', '5星'),
    ], default='5', coerce=int)

模板中渲染单选按钮:

html 复制代码
{% for choice in form.gender %}
    <div class="form-check">
        {{ choice(class_='form-check-input') }}
        {{ choice.label(class_='form-check-label') }}
    </div>
{% endfor %}

渲染结果:

html 复制代码
<div class="form-check">
    <input class="form-check-input" id="gender-0" name="gender" type="radio" value="male">
    <label class="form-check-label" for="gender-0">男</label>
</div>
<div class="form-check">
    <input class="form-check-input" id="gender-1" name="gender" type="radio" value="female">
    <label class="form-check-label" for="gender-1">女</label>
</div>

3.8 文件字段(FileField, MultipleFileField)

文件字段用于文件上传,在 Flask-WTF 中从 flask_wtf 导入(而非 wtforms)。

3.8.1 FileField(单文件上传)
python 复制代码
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired

class UploadForm(FlaskForm):
    avatar = FileField('头像', validators=[
        FileRequired(message='请选择文件'),
        FileAllowed(['jpg', 'jpeg', 'png', 'gif'], message='只允许图片文件')
    ])
    submit = SubmitField('上传')

模板中渲染文件字段:

html 复制代码
<form method="post" enctype="multipart/form-data">
    {{ form.hidden_tag() }}
    {{ form.avatar.label }}
    {{ form.avatar() }}
    {{ form.submit() }}
</form>

注意:文件上传表单必须设置 enctype="multipart/form-data"

获取文件:

python 复制代码
if form.validate_on_submit():
    file = form.avatar.data  # werkzeug FileStorage 对象
    if file:
        filename = secure_filename(file.filename)
        file.save(os.path.join('uploads', filename))
3.8.2 MultipleFileField(多文件上传)
python 复制代码
from flask_wtf.file import MultipleFileField

class GalleryForm(FlaskForm):
    photos = MultipleFileField('照片', validators=[
        FileAllowed(['jpg', 'jpeg', 'png'], message='只允许图片文件')
    ])

获取多个文件:

python 复制代码
if form.validate_on_submit():
    files = form.photos.data  # FileStorage 对象列表
    for file in files:
        filename = secure_filename(file.filename)
        file.save(os.path.join('uploads', filename))

文件上传的详细处理将在第七章专门讲解。

3.9 隐藏字段(HiddenField)

HiddenField 渲染为 <input type="hidden">,数据类型为字符串。用于传递不需要用户可见的数据。

python 复制代码
from wtforms import HiddenField

class EditForm(FlaskForm):
    id = HiddenField()  # 记录ID
    redirect_to = HiddenField()  # 重定向目标
    name = StringField('名称', validators=[DataRequired()])

隐藏字段常用于:

  • 传递记录 ID(编辑操作)
  • 传递来源 URL(提交后跳回)
  • 存储状态信息
python 复制代码
@app.route('/edit/<int:item_id>', methods=['GET', 'POST'])
def edit(item_id):
    item = Item.query.get_or_404(item_id)
    form = EditForm()
    if request.method == 'GET':
        form.id.data = item.id
        form.name.data = item.name
    if form.validate_on_submit():
        item.name = form.name.data
        db.session.commit()
        return redirect(url_for('list'))
    return render_template('edit.html', form=form)

3.10 提交字段(SubmitField)

SubmitField 渲染为 <input type="submit">,用于表单提交按钮。

python 复制代码
from wtforms import SubmitField

class MyForm(FlaskForm):
    name = StringField('名称')
    submit = SubmitField('提交', render_kw={'class': 'btn btn-primary'})
    # 一个表单中可以有多个提交按钮
    save_draft = SubmitField('保存草稿', render_kw={'class': 'btn btn-secondary'})
    publish = SubmitField('发布', render_kw={'class': 'btn btn-success'})

在视图函数中区分不同按钮:

python 复制代码
if form.validate_on_submit():
    if form.publish.data:
        # 点击了"发布"
        article.status = 'published'
    elif form.save_draft.data:
        # 点击了"保存草稿"
        article.status = 'draft'
    db.session.commit()

3.11 列表字段(FieldList)

FieldList 用于包含多个相同类型字段的列表,适合动态数量的输入。

3.11.1 基本使用
python 复制代码
from wtforms import FieldList, StringField, FormField

class SurveyForm(FlaskForm):
    # 多个文本输入
    emails = FieldList(StringField('邮箱'), min_entries=1, max_entries=10)
    # 多个整数输入
    scores = FieldList(IntegerField('分数'), min_entries=3)

模板渲染:

html 复制代码
{% for email_field in form.emails %}
    {{ email_field.label }} {{ email_field(class_='form-control') }}
{% endfor %}
3.11.2 动态添加字段

前端通过 JavaScript 动态添加字段,FieldList 通过索引访问:

html 复制代码
<div id="email-list">
    {% for email_field in form.emails %}
        <div class="email-item">
            {{ email_field(class_='form-control', placeholder='邮箱地址') }}
        </div>
    {% endfor %}
</div>
<button type="button" onclick="addEmailField()">添加邮箱</button>

<script>
function addEmailField() {
    const list = document.getElementById('email-list');
    const count = list.children.length;
    const html = `<div class="email-item">
        <input class="form-control" id="emails-${count}"
               name="emails-${count}" placeholder="邮箱地址" type="text" value="">
    </div>`;
    list.insertAdjacentHTML('beforeend', html);
}
</script>
3.11.3 min_entries 和 max_entries
python 复制代码
# 至少3个,最多10个
phones = FieldList(StringField('电话'), min_entries=3, max_entries=10)

验证时会检查数量是否在范围内。

3.12 嵌套表单(FormField)

FormField 允许在一个表单中嵌套另一个表单,适合表单复用和复杂结构。

3.12.1 定义嵌套表单
python 复制代码
from wtforms import FormField, Form

class AddressForm(Form):
    """地址子表单(不继承 FlaskForm,因为没有 CSRF)"""
    province = StringField('省份', validators=[DataRequired()])
    city = StringField('城市', validators=[DataRequired()])
    detail = StringField('详细地址', validators=[DataRequired()])
    zip_code = StringField('邮编', validators=[
        Regexp(r'^\d{6}$', message='邮编必须是6位数字')
    ])

class UserForm(FlaskForm):
    """用户表单,嵌套地址表单"""
    name = StringField('姓名', validators=[DataRequired()])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    address = FormField(AddressForm)  # 嵌套表单
    submit = SubmitField('提交')
3.12.2 模板渲染嵌套表单
html 复制代码
<form method="post">
    {{ form.hidden_tag() }}

    {{ form.name.label }} {{ form.name() }}
    {{ form.email.label }} {{ form.email() }}

    <fieldset>
        <legend>地址信息</legend>
        {{ form.address.province.label }} {{ form.address.province() }}
        {{ form.address.city.label }} {{ form.address.city() }}
        {{ form.address.detail.label }} {{ form.address.detail() }}
        {{ form.address.zip_code.label }} {{ form.address.zip_code() }}
    </fieldset>

    {{ form.submit() }}
</form>
3.12.3 获取嵌套表单数据
python 复制代码
if form.validate_on_submit():
    user = User(
        name=form.name.data,
        email=form.email.data,
        province=form.address.province.data,
        city=form.address.city.data,
        detail=form.address.detail.data,
        zip_code=form.address.zip_code.data,
    )
3.12.4 FieldList + FormField 组合

FieldListFormField 组合可以创建重复的嵌套结构:

python 复制代码
class ContactForm(Form):
    name = StringField('联系人姓名', validators=[DataRequired()])
    phone = StringField('电话', validators=[DataRequired()])

class CompanyForm(FlaskForm):
    company_name = StringField('公司名称', validators=[DataRequired()])
    contacts = FieldList(FormField(ContactForm), min_entries=1, max_entries=5)

3.13 搜索字段(SearchField)

SearchField 渲染为 <input type="search">,功能与 StringField 相同,但语义上标识为搜索框,浏览器可能会提供不同的样式。

python 复制代码
from wtforms import SearchField

class SearchForm(FlaskForm):
    keyword = SearchField('关键词', validators=[
        Length(min=1, max=100)
    ], render_kw={'class': 'form-control', 'placeholder': '搜索...'})

3.14 URL/Email/Tel字段

WTForms 提供了语义化的输入字段,它们在数据类型上都是字符串,但渲染为不同的 HTML5 input 类型。

python 复制代码
from wtforms import URLField, EmailField, TelField

class ContactForm(FlaskForm):
    website = URLField('个人网站', validators=[
        URL(message='请输入有效的URL')
    ], render_kw={'placeholder': 'https://example.com'})

    email = EmailField('邮箱', validators=[
        DataRequired(),
        Email(message='邮箱格式不正确')
    ])

    phone = TelField('电话', validators=[
        Regexp(r'^1[3-9]\d{9}$', message='请输入有效的手机号')
    ])

渲染结果:

html 复制代码
<input type="url" name="website" placeholder="https://example.com">
<input type="email" name="email">
<input type="tel" name="phone">

使用 HTML5 类型的好处是移动端浏览器会弹出对应的键盘(如邮箱键盘、数字键盘),并可能有浏览器原生验证。

3.15 字段类型速查表

字段类型 HTML 控件 数据类型 说明
StringField <input type="text"> str 单行文本
TextAreaField <textarea> str 多行文本
PasswordField <input type="password"> str 密码
IntegerField <input type="number"> int 整数
FloatField <input type="number"> float 浮点数
DecimalField <input type="number"> Decimal 高精度小数
BooleanField <input type="checkbox"> bool 布尔值
DateField <input type="date"> date 日期
DateTimeField <input type="datetime-local"> datetime 日期时间
TimeField <input type="time"> time 时间
SelectField <select> str/coerce 单选下拉
SelectMultipleField <select multiple> list 多选下拉
RadioField <input type="radio"> str/coerce 单选按钮组
FileField <input type="file"> FileStorage 文件上传
MultipleFileField <input type="file" multiple> list[FileStorage] 多文件上传
HiddenField <input type="hidden"> str 隐藏字段
SubmitField <input type="submit"> bool 提交按钮
SearchField <input type="search"> str 搜索框
URLField <input type="url"> str URL 输入
EmailField <input type="email"> str 邮箱输入
TelField <input type="tel"> str 电话输入
FieldList (重复字段) list 字段列表
FormField (嵌套表单) Form 嵌套表单

3.16 自定义字段类型

当内置字段无法满足需求时,可以自定义字段类型。

3.16.1 继承现有字段

最简单的自定义方式是继承现有字段并修改默认行为:

python 复制代码
from wtforms import StringField

class LowercaseStringField(StringField):
    """自动转小写的文本字段"""
    def process_formdata(self, valuelist):
        super().process_formdata(valuelist)
        if self.data:
            self.data = self.data.lower()


class TrimmedStringField(StringField):
    """自动去除首尾空格的文本字段"""
    def process_formdata(self, valuelist):
        if valuelist:
            self.data = valuelist[0].strip()
        else:
            self.data = ''

使用:

python 复制代码
class MyForm(FlaskForm):
    email = LowercaseStringField('邮箱', validators=[Email()])
    name = TrimmedStringField('姓名')
3.16.2 自定义 Widget

更深入的自定义是创建新的 Widget,控制 HTML 渲染:

python 复制代码
from wtforms.widgets import Widget, html_params
from markupsafe import Markup

class ToggleWidget(Widget):
    """渲染 Bootstrap 开关样式的复选框"""
    def __call__(self, field, **kwargs):
        kwargs.setdefault('id', field.id)
        kwargs.setdefault('type', 'checkbox')
        kwargs['data-toggle'] = 'toggle'

        if field.data:
            kwargs['checked'] = 'checked'

        return Markup(
            f'<input {html_params(name=field.name, **kwargs)}>'
            f'<label for="{field.id}">{field.label.text}</label>'
        )


class ToggleField(BooleanField):
    """开关样式布尔字段"""
    widget = ToggleWidget()
3.16.3 完全自定义字段

创建一个全新的字段类型,需要实现 process_formdata 方法:

python 复制代码
from wtforms.fields import Field
from wtforms.widgets import TextInput
from wtforms.validators import ValidationError

class ColorField(Field):
    """颜色选择字段,存储为 (R, G, B) 元组"""
    widget = TextInput()

    def _value(self):
        """渲染时返回的值"""
        if self.data:
            r, g, b = self.data
            return f'#{r:02x}{g:02x}{b:02x}'
        return ''

    def process_formdata(self, valuelist):
        """从表单数据中解析值"""
        if valuelist and valuelist[0]:
            value = valuelist[0].lstrip('#')
            if len(value) == 6:
                try:
                    self.data = (
                        int(value[0:2], 16),
                        int(value[2:4], 16),
                        int(value[4:6], 16),
                    )
                except ValueError:
                    self.data = None
                    raise ValidationError('无效的颜色值')
            else:
                self.data = None
        else:
            self.data = None

使用:

python 复制代码
class ThemeForm(FlaskForm):
    primary_color = ColorField('主色调', default=(0, 123, 255))

模板:

html 复制代码
{{ form.primary_color.label }}
{{ form.primary_color(type='color') }}

这个自定义字段在前端显示为颜色选择器,后端解析为 RGB 元组,实现了完整的自定义数据流。

3.16.4 标签输入字段

在实际项目中,文章标签、商品标签等场景需要用户输入多个标签。我们可以创建一个 TagListField,前端以逗号分隔输入,后端自动解析为列表:

python 复制代码
from wtforms.fields import Field
from wtforms.widgets import TextInput

class TagListField(Field):
    """标签输入字段,前端逗号分隔,后端转为列表"""
    widget = TextInput()
    
    def _value(self):
        """渲染时将列表转为逗号分隔的字符串"""
        if self.data:
            return ', '.join(self.data)
        return ''
    
    def process_formdata(self, valuelist):
        """从表单数据解析为列表"""
        if valuelist and valuelist[0]:
            # 按逗号分割,去除空白,过滤空字符串,去重
            tags = [tag.strip() for tag in valuelist[0].split(',')]
            self.data = list(dict.fromkeys(tag for tag in tags if tag))  # 去重保序
        else:
            self.data = []
    
    def pre_validate(self, form):
        """在验证器之前执行的自定义验证"""
        if self.data:
            for tag in self.data:
                if len(tag) > 20:
                    raise ValidationError(f'标签"{tag}"超过20个字符')

使用示例:

python 复制代码
class ArticleForm(FlaskForm):
    title = StringField('标题', validators=[DataRequired(), Length(max=200)])
    content = TextAreaField('正文', validators=[DataRequired()])
    tags = TagListField('标签', description='多个标签用逗号分隔')
    submit = SubmitField('发布')

# 在视图中使用
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
    form = ArticleForm()
    if form.validate_on_submit():
        article = Article(
            title=form.title.data,
            content=form.content.data
        )
        # tags.data 已经是列表
        for tag_name in form.tags.data:
            tag = Tag.query.filter_by(name=tag_name).first()
            if not tag:
                tag = Tag(name=tag_name)
            article.tags.append(tag)
        db.session.add(article)
        db.session.commit()
        return redirect(url_for('article_detail', id=article.id))
    return render_template('article_new.html', form=form)
3.16.5 JSON 数据字段

在需要存储结构化数据(如自定义表单配置、JSON 格式的用户设置)时,可以创建一个 JSONField:

python 复制代码
import json
from wtforms.fields import Field
from wtforms.widgets import TextArea
from wtforms.validators import ValidationError

class JSONField(Field):
    """JSON 数据字段,前端输入 JSON 字符串,后端解析为 Python 对象"""
    widget = TextArea()
    
    def _value(self):
        """渲染时将 Python 对象转为 JSON 字符串"""
        if self.data:
            try:
                return json.dumps(self.data, ensure_ascii=False, indent=2)
            except (TypeError, ValueError):
                return str(self.data)
        return ''
    
    def process_formdata(self, valuelist):
        """从表单数据解析 JSON"""
        if valuelist and valuelist[0].strip():
            try:
                self.data = json.loads(valuelist[0])
            except json.JSONDecodeError as e:
                self.data = None
                raise ValidationError(f'JSON 格式错误: {e}')
        else:
            self.data = None

使用示例:

python 复制代码
class WidgetConfigForm(FlaskForm):
    name = StringField('组件名称', validators=[DataRequired()])
    config = JSONField('配置(JSON)', default={
        "width": 300,
        "height": 200,
        "theme": "light"
    })
    submit = SubmitField('保存配置')
3.16.6 自定义字段设计原则

在创建自定义字段时,需要遵循以下设计原则:

  1. 单一职责:每个自定义字段只负责一种数据类型的处理,不要在一个字段中混合多种逻辑。
  2. 健壮性 :process_formdata 方法要处理各种边界情况------空值、格式错误、类型不匹配等,确保不会因为异常输入导致程序崩溃。
  3. 可测试性 :自定义字段应该易于单元测试,通过构造 valuelist 参数模拟表单提交,验证解析结果。
  4. 与验证器配合:自定义字段负责数据解析(将原始字符串转为 Python 对象),验证器负责业务规则验证(如范围检查、唯一性检查),两者各司其职。
  5. 渲染友好 :_value 方法要确保返回的字符串能正确渲染到 HTML 中,避免 XSS 风险(依赖 Jinja2 的自动转义)。

第四章 验证器(Validators)

验证器是 WTForms 的核心功能之一,负责确保用户输入的数据符合业务规则。本章将系统讲解所有内置验证器的使用方法,以及如何编写自定义验证器。

4.1 验证器概念与使用方式

验证器(Validator)是一个可调用对象,接收两个参数------表单对象 form 和字段对象 field,在数据不符合要求时抛出 ValidationError 异常。

python 复制代码
from wtforms.validators import ValidationError

def my_validator(form, field):
    """一个简单的验证器"""
    if field.data != 'expected_value':
        raise ValidationError('值不符合要求')

验证器的使用方式有三种:

4.1.1 列表方式(最常用)
python 复制代码
name = StringField('用户名', validators=[
    DataRequired(),
    Length(min=3, max=20)
])

验证器按列表顺序执行,如果一个验证器失败,会继续执行后续验证器(除非使用 StopValidation)。

4.1.2 内联方法方式

在表单类中定义 validate_<字段名> 方法:

python 复制代码
class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[DataRequired()])

    def validate_username(self, field):
        """内联验证器,方法名必须是 validate_字段名"""
        if User.query.filter_by(username=field.data).first():
            raise ValidationError('该用户名已被注册')

内联验证器会在所有列表验证器之后执行。

4.1.3 临时额外验证器

在调用 validate() 时传入额外验证器:

python 复制代码
def check_unique(form, field):
    if User.query.filter_by(username=field.data).first():
        raise ValidationError('用户名已被注册')

form = RegisterForm()
if form.validate(extra_validators={'username': [check_unique]}):
    # ...

4.2 DataRequired/InputRequired(必填验证)

4.2.1 DataRequired

DataRequired 验证字段数据不为空。它会检查 field.data 是否为 None、空字符串、空列表等假值。

python 复制代码
from wtforms.validators import DataRequired

name = StringField('用户名', validators=[
    DataRequired(message='用户名不能为空')
])

DataRequired 的行为:

  • 如果数据为空,会设置 field.errors 并停止后续验证器的执行。
  • message 参数自定义错误消息。
  • 它会设置 field.flags.required = True,模板中可以据此渲染必填标记。
4.2.2 InputRequired

InputRequiredDataRequired 类似,但验证的是原始输入(field.raw_data)而非处理后的数据(field.data)。

python 复制代码
from wtforms.validators import InputRequired

name = StringField('用户名', validators=[
    InputRequired(message='请输入用户名')
])

两者的区别:

对比项 DataRequired InputRequired
验证对象 field.data(处理后) field.raw_data(原始输入)
空字符串 不通过 不通过
0(整数) 通过(非假值) 通过(有输入)
False(布尔) 不通过 通过(有输入)
默认值填充 可能通过 不通过

实际使用中,DataRequired 更常用。InputRequired 适用于需要确保用户确实输入了数据(而非使用默认值)的场景。

4.3 Length(长度验证)

Length 验证字符串或列表的长度范围。

python 复制代码
from wtforms.validators import Length

# 同时限制最小和最大长度
username = StringField('用户名', validators=[
    Length(min=3, max=20, message='用户名长度必须为%(min)d-%(max)d个字符')
])

# 只限制最小长度
password = PasswordField('密码', validators=[
    Length(min=8, message='密码至少%(min)d位')
])

# 只限制最大长度
bio = TextAreaField('个人简介', validators=[
    Length(max=500, message='个人简介最多%(max)d字')
])

# 精确长度
code = StringField('验证码', validators=[
    Length(min=6, max=6, message='验证码必须是6位')
])

message 中可以使用 %(min)d%(max)d 占位符,WTForms 会自动替换为实际值。

Length 也可以验证列表类型(如 SelectMultipleField 的选择数量):

python 复制代码
tags = SelectMultipleField('标签', choices=[...], validators=[
    Length(min=1, max=5, message='请选择1-5个标签')
])

4.4 NumberRange(数字范围验证)

NumberRange 验证数字是否在指定范围内。

python 复制代码
from wtforms.validators import NumberRange
from decimal import Decimal

age = IntegerField('年龄', validators=[
    NumberRange(min=0, max=150, message='年龄必须在%(min)d-%(max)d之间')
])

price = DecimalField('价格', validators=[
    NumberRange(min=Decimal('0.01'), message='价格必须大于0')
])

# 只限制最大值
score = IntegerField('分数', validators=[
    NumberRange(max=100, message='分数不能超过%(max)d')
])

NumberRange 会自动处理类型转换,如果字段数据无法转为数字,验证器会跳过(类型转换错误由字段自身处理)。

4.5 Email(邮箱格式验证)

Email 验证输入是否为有效的邮箱地址。

python 复制代码
from wtforms.validators import Email

email = StringField('邮箱', validators=[
    DataRequired(message='邮箱不能为空'),
    Email(message='请输入有效的邮箱地址')
])

Email 验证器依赖 email_validator 包,需要单独安装:

bash 复制代码
pip install email_validator

email_validator 使用 RFC 5322 标准验证邮箱格式,比简单的正则更准确。

Email 验证器的参数:

python 复制代码
email = StringField('邮箱', validators=[
    Email(
        message='邮箱格式不正确',
        granular_message=False,  # 是否返回详细的错误原因
        check_deliverability=False,  # 是否检查域名可投递性(需联网DNS查询)
        allow_smtputf8=True,  # 是否允许 UTF-8 字符
    )
])

4.6 URL(URL格式验证)

URL 验证输入是否为有效的 URL。

python 复制代码
from wtforms.validators import URL

website = StringField('个人网站', validators=[
    URL(message='请输入有效的URL', require_tld=True)
])

参数:

python 复制代码
website = StringField('网站', validators=[
    URL(
        require_tld=True,       # 是否要求顶级域名
        allow_ip=True,          # 是否允许 IP 地址
        allow_userpass=True,    # 是否允许 user:pass@host 格式
        message='请输入有效的URL'
    )
])

4.7 EqualTo(相等验证)

EqualTo 验证当前字段的值是否与另一个字段相等,常用于密码确认。

python 复制代码
from wtforms.validators import EqualTo

class RegisterForm(FlaskForm):
    password = PasswordField('密码', validators=[
        DataRequired(),
        Length(min=8)
    ])
    confirm_password = PasswordField('确认密码', validators=[
        DataRequired(),
        EqualTo('password', message='两次输入的密码不一致')
    ])

注意 EqualTo 的参数是字段名(字符串),不是字段对象。验证时通过 form.<字段名> 获取对应字段的数据。

4.8 Regexp(正则表达式验证)

Regexp 使用正则表达式验证输入格式。

python 复制代码
from wtforms.validators import Regexp

# 验证用户名(字母、数字、下划线)
username = StringField('用户名', validators=[
    Regexp(r'^[a-zA-Z0-9_]+$',
           message='用户名只能包含字母、数字和下划线')
])

# 验证手机号
phone = StringField('手机号', validators=[
    Regexp(r'^1[3-9]\d{9}$',
           message='请输入有效的手机号')
])

# 验证邮政编码
zipcode = StringField('邮编', validators=[
    Regexp(r'^\d{6}$', message='邮编必须是6位数字')
])

# 验证身份证号(简化版)
id_card = StringField('身份证号', validators=[
    Regexp(r'^\d{17}[\dXx]$',
           message='请输入有效的身份证号')
])

# 验证强密码
password = PasswordField('密码', validators=[
    Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
           message='密码至少8位,必须包含大小写字母、数字和特殊字符')
])

Regexp 的参数:

python 复制代码
Regexp(
    regex,          # 正则表达式(字符串或编译后的 Pattern)
    flags=0,        # 正则标志(如 re.IGNORECASE)
    message='格式不正确'
)

4.9 AnyOf/NoneOf(枚举验证)

4.9.1 AnyOf

AnyOf 验证输入值是否在指定的值列表中。

python 复制代码
from wtforms.validators import AnyOf

status = SelectField('状态', choices=[
    ('draft', '草稿'),
    ('published', '已发布'),
    ('archived', '已归档'),
], validators=[
    AnyOf(['draft', 'published', 'archived'],
          message='无效的状态值')
])

AnyOf 常用于验证 SelectField 的值,防止用户篡改提交非法值。不过 SelectField 本身已经会验证值是否在 choices 中,所以 AnyOf 更多用于自由输入字段:

python 复制代码
color = StringField('颜色', validators=[
    AnyOf(['red', 'green', 'blue', 'yellow'],
          message='颜色必须是红、绿、蓝、黄之一')
])

values_formatter 参数自定义错误消息中值的显示格式:

python 复制代码
AnyOf(['red', 'green', 'blue'],
      values_formatter=lambda v: '、'.join(v),
      message='必须是以下颜色之一: %(values)s')
4.9.2 NoneOf

NoneOf 验证输入值不在指定的值列表中(黑名单)。

python 复制代码
from wtforms.validators import NoneOf

username = StringField('用户名', validators=[
    NoneOf(['admin', 'root', 'system', 'administrator'],
           message='该用户名为保留字,请更换')
])

4.10 Optional(可选验证)

Optional 标记字段为可选,当字段为空时跳过后续所有验证器。

python 复制代码
from wtforms.validators import Optional

# 可选的手机号:不填则不验证,填了才验证格式
phone = StringField('手机号(选填)', validators=[
    Optional(),
    Regexp(r'^1[3-9]\d{9}$', message='手机号格式不正确')
])

# 可选的年龄范围
age = IntegerField('年龄(选填)', validators=[
    Optional(),
    NumberRange(min=0, max=150)
])

Optional 的行为:

  • 如果字段为空(空字符串、None),设置 field.flags.optional = True,并停止后续验证器。
  • 如果字段有值,继续执行后续验证器。

Optional 验证器的参数:

python 复制代码
Optional(
    stop_validation=True  # 是否在空值时停止所有验证(默认 True)
)

4.11 StopValidation(停止验证)

StopValidation 是一种特殊的异常,抛出后会立即停止该字段的所有后续验证器。

python 复制代码
from wtforms.validators import StopValidation, ValidationError

def validate_sensitive(form, field):
    """如果字段包含敏感词,停止所有验证"""
    sensitive_words = ['spam', '广告', '违规']
    for word in sensitive_words:
        if word in field.data:
            raise StopValidation(f'内容包含敏感词: {word}')

class CommentForm(FlaskForm):
    content = TextAreaField('评论', validators=[
        DataRequired(),
        validate_sensitive,  # 如果触发,下面的验证器不会执行
        Length(max=500)
    ])

StopValidationValidationError 的区别:

异常类型 行为
ValidationError 记录错误,继续执行后续验证器
StopValidation 记录错误(如果有消息),立即停止所有后续验证器

4.12 ValidationError异常

ValidationError 是所有验证失败时抛出的异常。它的消息会被收集到 field.errors 列表中。

python 复制代码
from wtforms.validators import ValidationError

raise ValidationError('这是一个错误消息')

# 支持格式化
raise ValidationError(f'值 {field.data} 无效')

当字段有多个验证器都失败时,field.errors 会包含所有错误消息:

python 复制代码
field = StringField(validators=[
    DataRequired(),
    Length(min=3),
    Regexp(r'^[a-z]+$')
])

# 如果输入空字符串:
# field.errors = ['该字段是必填项。']
# (DataRequired 失败后,后续验证器可能被跳过)

# 如果输入 "AB":
# field.errors = ['字段长度必须至少为 3。', '字段只能包含小写字母。']
# (两个验证器都失败,错误都被收集)

4.13 自定义验证器

当内置验证器无法满足需求时,可以编写自定义验证器。WTForms 支持两种风格的自定义验证器。

4.13.1 函数式验证器

最简单的自定义验证器是一个普通函数:

python 复制代码
from wtforms.validators import ValidationError
import re

def validate_phone(form, field):
    """验证中国大陆手机号"""
    if not re.match(r'^1[3-9]\d{9}$', field.data):
        raise ValidationError('请输入有效的手机号')

def validate_username(form, field):
    """验证用户名格式"""
    if not re.match(r'^[a-zA-Z0-9_]+$', field.data):
        raise ValidationError('用户名只能包含字母、数字和下划线')
    if len(field.data) < 3:
        raise ValidationError('用户名至少3个字符')

class RegisterForm(FlaskForm):
    phone = StringField('手机号', validators=[validate_phone])
    username = StringField('用户名', validators=[validate_username])
4.13.2 类式验证器(带参数)

如果验证器需要参数,使用类来实现:

python 复制代码
from wtforms.validators import ValidationError
from datetime import date

class DateRange:
    """验证日期在指定范围内"""
    def __init__(self, min_date=None, max_date=None, message=None):
        self.min_date = min_date
        self.max_date = max_date
        self.message = message or '日期不在有效范围内'

    def __call__(self, form, field):
        if field.data is None:
            return

        if self.min_date and field.data < self.min_date:
            raise ValidationError(
                f'{self.message}(最早: {self.min_date})'
            )

        if self.max_date and field.data > self.max_date:
            raise ValidationError(
                f'{self.message}(最晚: {self.max_date})'
            )


class Unique:
    """验证数据库中值唯一"""
    def __init__(self, model, field, message=None):
        self.model = model
        self.field = field
        self.message = message or '该值已存在'

    def __call__(self, form, field):
        if self.model.query.filter_by(**{self.field: field.data}).first():
            raise ValidationError(self.message)

使用:

python 复制代码
class EventForm(FlaskForm):
    event_date = DateField('活动日期', validators=[
        DataRequired(),
        DateRange(
            min_date=date.today(),
            max_date=date.today().replace(year=date.today().year + 1),
            message='活动日期必须在今天到明年今天之间'
        )
    ])

class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[
        DataRequired(),
        Unique(User, 'username', message='该用户名已被注册')
    ])
    email = StringField('邮箱', validators=[
        DataRequired(),
        Email(),
        Unique(User, 'email', message='该邮箱已被注册')
    ])
4.13.3 内联验证器

在表单类中定义 validate_<字段名> 方法,适合需要访问表单其他字段的验证:

python 复制代码
class BookingForm(FlaskForm):
    check_in = DateField('入住日期', validators=[DataRequired()])
    check_out = DateField('退房日期', validators=[DataRequired()])
    guests = IntegerField('人数', validators=[NumberRange(min=1, max=10)])

    def validate_check_in(self, field):
        """入住日期不能早于今天"""
        if field.data < date.today():
            raise ValidationError('入住日期不能早于今天')

    def validate_check_out(self, field):
        """退房日期必须晚于入住日期"""
        if field.data <= self.check_in.data:
            raise ValidationError('退房日期必须晚于入住日期')

    def validate_guests(self, field):
        """人数不能超过日期范围内的可用房间数"""
        days = (self.check_out.data - self.check_in.data).days
        if days <= 0:
            return  # 日期错误由其他验证器处理
        # 检查库存...

4.14 验证器组合使用

实际项目中,一个字段通常需要多个验证器组合使用。

4.14.1 常见组合模式
python 复制代码
class RegisterForm(FlaskForm):
    # 用户名:必填 + 长度 + 格式 + 唯一
    username = StringField('用户名', validators=[
        DataRequired(message='用户名不能为空'),
        Length(min=3, max=20, message='用户名长度3-20个字符'),
        Regexp(r'^[a-zA-Z0-9_]+$', message='用户名只能包含字母、数字和下划线'),
        # Unique 验证器(类式,需要数据库访问)
    ])

    # 邮箱:必填 + 格式 + 唯一
    email = StringField('邮箱', validators=[
        DataRequired(message='邮箱不能为空'),
        Email(message='邮箱格式不正确'),
    ])

    # 密码:必填 + 长度 + 强度
    password = PasswordField('密码', validators=[
        DataRequired(message='密码不能为空'),
        Length(min=8, max=32, message='密码长度8-32位'),
        Regexp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$',
               message='密码必须包含大小写字母和数字')
    ])

    # 确认密码:必填 + 相等
    confirm_password = PasswordField('确认密码', validators=[
        DataRequired(message='请确认密码'),
        EqualTo('password', message='两次密码不一致')
    ])

    # 手机号(选填):可选 + 格式
    phone = StringField('手机号(选填)', validators=[
        Optional(),
        Regexp(r'^1[3-9]\d{9}$', message='手机号格式不正确')
    ])

    # 年龄(选填):可选 + 范围
    age = IntegerField('年龄(选填)', validators=[
        Optional(),
        NumberRange(min=0, max=150, message='年龄必须在0-150之间')
    ])
4.14.2 验证器执行顺序

验证器按以下顺序执行:

  1. 字段自身的类型转换(process_formdata)
  2. validators 列表中的验证器(按顺序)
  3. 内联验证器(validate_<字段名>)
  4. 传入 validate() 的额外验证器
python 复制代码
# 执行顺序示例
class MyForm(FlaskForm):
    name = StringField('名称', validators=[
        DataRequired(),    # 1. 先执行
        Length(min=3),     # 2. 再执行
    ])

    def validate_name(self, field):
        # 3. 最后执行内联验证器
        pass

# 4. 额外验证器(如果传入了 extra_validators)
form.validate(extra_validators={'name': [custom_validator]})

4.15 验证器实战案例集

下面通过几个实际场景展示验证器的综合应用。

4.15.1 密码强度验证器
python 复制代码
import re
from wtforms.validators import ValidationError

class PasswordStrength:
    """密码强度验证器"""
    def __init__(self, min_length=8, require_upper=True, require_lower=True,
                 require_digit=True, require_special=False, message=None):
        self.min_length = min_length
        self.require_upper = require_upper
        self.require_lower = require_lower
        self.require_digit = require_digit
        self.require_special = require_special
        self.message = message

    def __call__(self, form, field):
        password = field.data
        if not password:
            return

        errors = []

        if len(password) < self.min_length:
            errors.append(f'至少{self.min_length}位')

        if self.require_upper and not re.search(r'[A-Z]', password):
            errors.append('包含大写字母')

        if self.require_lower and not re.search(r'[a-z]', password):
            errors.append('包含小写字母')

        if self.require_digit and not re.search(r'\d', password):
            errors.append('包含数字')

        if self.require_special and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
            errors.append('包含特殊字符')

        if errors:
            msg = self.message or f'密码需要: {", ".join(errors)}'
            raise ValidationError(msg)


class RegisterForm(FlaskForm):
    password = PasswordField('密码', validators=[
        DataRequired(),
        PasswordStrength(min_length=8, require_special=True)
    ])
4.15.2 文件大小验证器
python 复制代码
from wtforms.validators import ValidationError

class FileSize:
    """文件大小验证器"""
    def __init__(self, max_size=None, min_size=None):
        self.max_size = max_size  # 字节
        self.min_size = min_size

    def __call__(self, form, field):
        # 处理单个文件
        file = field.data
        if not file:
            return

        # 获取文件大小
        file.seek(0, 2)  # 移到文件末尾
        size = file.tell()
        file.seek(0)  # 重置文件指针

        if self.max_size and size > self.max_size:
            max_mb = self.max_size / (1024 * 1024)
            raise ValidationError(f'文件大小不能超过{max_mb:.1f}MB')

        if self.min_size and size < self.min_size:
            min_kb = self.min_size / 1024
            raise ValidationError(f'文件大小不能小于{min_kb:.1f}KB')


class UploadForm(FlaskForm):
    avatar = FileField('头像', validators=[
        FileRequired(),
        FileAllowed(['jpg', 'png', 'gif']),
        FileSize(max_size=2 * 1024 * 1024)  # 最大2MB
    ])
4.15.3 条件验证器
python 复制代码
class ConditionalRequired:
    """条件必填:当另一个字段满足条件时,当前字段必填"""
    def __init__(self, other_field, other_value, message=None):
        self.other_field = other_field
        self.other_value = other_value
        self.message = message or '此字段为必填项'

    def __call__(self, form, field):
        other = getattr(form, self.other_field, None)
        if other and other.data == self.other_value:
            if not field.data:
                raise ValidationError(self.message)


class OrderForm(FlaskForm):
    delivery_method = SelectField('配送方式', choices=[
        ('pickup', '自提'),
        ('delivery', '快递'),
    ])
    address = StringField('收货地址', validators=[
        ConditionalRequired('delivery_method', 'delivery',
                           message='选择快递配送时必须填写地址')
    ])
    pickup_time = StringField('自提时间', validators=[
        ConditionalRequired('delivery_method', 'pickup',
                           message='选择自提时必须填写自提时间')
    ])
4.15.4 中国身份证验证器
python 复制代码
import re
from datetime import datetime

def validate_id_card(form, field):
    """验证中国大陆身份证号(18位)"""
    value = field.data
    if not value:
        return

    # 基本格式
    if not re.match(r'^\d{17}[\dXx]$', value):
        raise ValidationError('身份证号格式不正确')

    # 验证出生日期
    try:
        birth_date = datetime.strptime(value[6:14], '%Y%m%d')
        if birth_date > datetime.now():
            raise ValidationError('出生日期不能晚于今天')
    except ValueError:
        raise ValidationError('身份证号中的出生日期无效')

    # 验证校验位
    factors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
    check_codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
    total = sum(int(value[i]) * factors[i] for i in range(17))
    check_code = check_codes[total % 11]
    if value[-1].upper() != check_code:
        raise ValidationError('身份证号校验位不正确')


class IDCardForm(FlaskForm):
    id_card = StringField('身份证号', validators=[
        DataRequired(message='请输入身份证号'),
        validate_id_card
    ])
4.15.5 中国手机号验证器
python 复制代码
import re

class ChinesePhone:
    """中国大陆手机号验证器"""
    
    # 运营商号段(会随时间更新,需定期维护)
    PHONE_PATTERN = re.compile(r'^1[3-9]\d{9}$')
    
    def __init__(self, message=None):
        self.message = message or '请输入有效的手机号'
    
    def __call__(self, form, field):
        if not field.data:
            return
        
        if not self.PHONE_PATTERN.match(field.data):
            raise ValidationError(self.message)


class PhoneForm(FlaskForm):
    phone = StringField('手机号', validators=[
        DataRequired(message='手机号不能为空'),
        ChinesePhone()
    ])
4.15.6 日期范围验证器

在预约、活动报名等场景中,需要验证用户选择的日期在允许的范围内:

python 复制代码
from datetime import date, datetime

class DateRange:
    """日期范围验证器
    
    Args:
        min_date: 最小日期(date对象或返回date的callable)
        max_date: 最大日期(date对象或返回date的callable)
    """
    
    def __init__(self, min_date=None, max_date=None, message=None):
        self.min_date = min_date
        self.max_date = max_date
        self.message = message
    
    def __call__(self, form, field):
        if not field.data:
            return
        
        value = field.data
        if isinstance(value, str):
            try:
                value = datetime.strptime(value, '%Y-%m-%d').date()
            except ValueError:
                raise ValidationError('日期格式不正确')
        
        min_d = self.min_date() if callable(self.min_date) else self.min_date
        max_d = self.max_date() if callable(self.max_date) else self.max_date
        
        if min_d and value < min_d:
            msg = self.message or f'日期不能早于 {min_d.strftime("%Y年%m月%d日")}'
            raise ValidationError(msg)
        
        if max_d and value > max_d:
            msg = self.message or f'日期不能晚于 {max_d.strftime("%Y年%m月%d日")}'
            raise ValidationError(msg)


class AppointmentForm(FlaskForm):
    # 预约日期不能是过去日期,不能超过30天后
    appointment_date = DateField('预约日期', validators=[
        DataRequired(message='请选择预约日期'),
        DateRange(
            min_date=date.today,
            max_date=lambda: date.today() + timedelta(days=30),
            message='请选择今天到30天内的日期'
        )
    ])
4.15.7 唯一性验证器(数据库检查)

在注册、修改邮箱等场景中,需要检查某个值在数据库中是否唯一:

python 复制代码
class Unique:
    """数据库唯一性验证器
    
    Args:
        model: SQLAlchemy 模型类
        field: 模型中的字段名(字符串)
        message: 错误消息
        exclude: 排除的记录ID(用于编辑场景)
    """
    
    def __init__(self, model, field, message=None, exclude=None):
        self.model = model
        self.field = field
        self.message = message or '该值已存在'
        self.exclude = exclude
    
    def __call__(self, form, field):
        if not field.data:
            return
        
        query = self.model.query.filter(
            getattr(self.model, self.field) == field.data
        )
        
        # 编辑场景:排除当前记录
        if self.exclude:
            exclude_id = self.exclude() if callable(self.exclude) else self.exclude
            if exclude_id:
                query = query.filter(self.model.id != exclude_id)
        
        if query.first():
            raise ValidationError(self.message)


# 使用示例
class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[
        DataRequired(),
        Length(min=3, max=20),
        Unique(User, 'username', message='该用户名已被注册')
    ])
    email = StringField('邮箱', validators=[
        DataRequired(),
        Email(),
        Unique(User, 'email', message='该邮箱已被注册')
    ])

# 编辑场景(排除当前用户)
class EditProfileForm(FlaskForm):
    def __init__(self, current_user_id, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # 动态设置exclude参数
        self.email.validators.append(
            Unique(User, 'email', 
                   message='该邮箱已被其他用户使用',
                   exclude=current_user_id)
        )
    
    email = StringField('邮箱', validators=[DataRequired(), Email()])
4.15.8 敏感词过滤验证器

在用户生成内容(UGC)场景中,需要过滤敏感词汇:

python 复制代码
class NoForbiddenWords:
    """敏感词过滤验证器"""
    
    # 默认敏感词列表(实际项目中应从数据库或配置文件加载)
    DEFAULT_FORBIDDEN = ['spam', '广告', '违法', '色情']
    
    def __init__(self, forbidden_words=None, message=None, case_sensitive=False):
        self.forbidden_words = forbidden_words or self.DEFAULT_FORBIDDEN
        self.message = message or '内容包含不允许的词汇'
        self.case_sensitive = case_sensitive
    
    def __call__(self, form, field):
        if not field.data:
            return
        
        text = field.data if self.case_sensitive else field.data.lower()
        words = (self.forbidden_words if self.case_sensitive 
                 else [w.lower() for w in self.forbidden_words])
        
        found = [word for word in words if word in text]
        
        if found:
            raise ValidationError(f'{self.message}: {", ".join(found)}')


class CommentForm(FlaskForm):
    content = TextAreaField('评论内容', validators=[
        DataRequired(message='评论不能为空'),
        Length(min=2, max=500, message='评论长度2-500字符'),
        NoForbiddenWords(message='评论包含违规词汇')
    ])
4.15.9 验证器组合实战:完整的用户注册表单

将上述验证器组合使用,构建一个生产级别的注册表单:

python 复制代码
class ProductionRegisterForm(FlaskForm):
    """生产级用户注册表单"""
    
    username = StringField('用户名', validators=[
        DataRequired(message='用户名不能为空'),
        Length(min=3, max=20, message='用户名长度3-20个字符'),
        Regexp(r'^[a-zA-Z0-9_]+$', message='用户名只能包含字母、数字和下划线'),
        NoForbiddenWords(message='用户名包含违规词汇'),
        Unique(User, 'username', message='该用户名已被注册')
    ], render_kw={'class': 'form-control', 'placeholder': '3-20个字符'})
    
    email = StringField('邮箱', validators=[
        DataRequired(message='邮箱不能为空'),
        Email(message='邮箱格式不正确'),
        Unique(User, 'email', message='该邮箱已被注册')
    ], render_kw={'class': 'form-control', 'placeholder': 'example@email.com'})
    
    phone = StringField('手机号', validators=[
        DataRequired(message='手机号不能为空'),
        ChinesePhone()
    ], render_kw={'class': 'form-control', 'placeholder': '11位手机号'})
    
    password = PasswordField('密码', validators=[
        DataRequired(message='密码不能为空'),
        Length(min=8, max=32, message='密码长度8-32位'),
        PasswordStrength(min_length=8, require_upper=True, 
                        require_lower=True, require_digit=True, 
                        require_special=True)
    ], render_kw={'class': 'form-control', 'placeholder': '至少8位,含大小写字母、数字和特殊字符'})
    
    confirm_password = PasswordField('确认密码', validators=[
        DataRequired(message='请确认密码'),
        EqualTo('password', message='两次输入的密码不一致')
    ], render_kw={'class': 'form-control'})
    
    id_card = StringField('身份证号', validators=[
        Optional(),
        validate_id_card
    ], render_kw={'class': 'form-control', 'placeholder': '选填'})
    
    agree = BooleanField('我已阅读并同意', validators=[
        DataRequired(message='请同意用户协议')
    ])
    
    submit = SubmitField('注册', render_kw={'class': 'btn btn-primary w-100'})

第五章 表单渲染与模板

表单定义好后,需要在模板中渲染成 HTML。本章讲解如何在 Jinja2 模板中渲染 WTForms 表单,包括字段渲染、错误显示、CSRF 令牌,以及使用 Bootstrap 美化表单。

5.1 表单类定义完整示例

我们先定义一个完整的表单类,作为本章渲染的示例:

python 复制代码
# forms.py
from flask_wtf import FlaskForm
from wtforms import (StringField, TextAreaField, PasswordField, SelectField,
                     BooleanField, SubmitField, IntegerField, DateField)
from wtforms.validators import DataRequired, Email, Length, NumberRange, EqualTo

class ArticleForm(FlaskForm):
    title = StringField('标题', validators=[
        DataRequired(message='标题不能为空'),
        Length(max=100, message='标题最多100字')
    ], description='文章标题将显示在列表页', render_kw={
        'class': 'form-control',
        'placeholder': '请输入文章标题'
    })

    category = SelectField('分类', choices=[
        ('', '请选择分类'),
        ('tech', '技术'),
        ('life', '生活'),
        ('travel', '旅行'),
    ], validators=[DataRequired(message='请选择分类')], coerce=str,
       render_kw={'class': 'form-control'})

    content = TextAreaField('正文', validators=[
        DataRequired(message='正文不能为空'),
        Length(min=10, message='正文至少10字')
    ], render_kw={
        'class': 'form-control',
        'rows': 10,
        'placeholder': '请输入文章正文...'
    })

    tags = StringField('标签', description='多个标签用逗号分隔', render_kw={
        'class': 'form-control',
        'placeholder': 'Flask, Python, Web'
    })

    is_public = BooleanField('公开', default=True, render_kw={
        'class': 'form-check-input'
    })

    submit = SubmitField('发布文章', render_kw={'class': 'btn btn-primary'})

5.2 在模板中渲染表单

5.2.1 基本渲染

在 Flask 视图函数中将表单传递给模板:

python 复制代码
# app.py
from flask import render_template
from forms import ArticleForm

@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
    form = ArticleForm()
    if form.validate_on_submit():
        # 处理表单...
        return redirect(url_for('article_list'))
    return render_template('new_article.html', form=form)

在模板中渲染:

html 复制代码
<!-- templates/new_article.html -->
<form method="post">
    {{ form.hidden_tag() }}

    <div class="form-group">
        {{ form.title.label(class_='form-label') }}
        {{ form.title() }}
        <small class="form-text text-muted">{{ form.title.description }}</small>
        {% for error in form.title.errors %}
            <div class="text-danger">{{ error }}</div>
        {% endfor %}
    </div>

    <div class="form-group">
        {{ form.category.label(class_='form-label') }}
        {{ form.category() }}
        {% for error in form.category.errors %}
            <div class="text-danger">{{ error }}</div>
        {% endfor %}
    </div>

    <div class="form-group">
        {{ form.content.label(class_='form-label') }}
        {{ form.content() }}
        {% for error in form.content.errors %}
            <div class="text-danger">{{ error }}</div>
        {% endfor %}
    </div>

    <div class="form-group">
        {{ form.tags.label(class_='form-label') }}
        {{ form.tags() }}
        <small class="form-text text-muted">{{ form.tags.description }}</small>
    </div>

    <div class="form-group form-check">
        {{ form.is_public() }}
        {{ form.is_public.label(class_='form-check-label') }}
    </div>

    {{ form.submit() }}
</form>
5.2.2 form.hidden_tag()

form.hidden_tag() 渲染所有隐藏字段,最重要的是 CSRF 令牌。它必须在 <form> 标签之后的第一行调用。

html 复制代码
<form method="post">
    {{ form.hidden_tag() }}
    <!-- 其他字段... -->
</form>

生成的 HTML:

html 复制代码
<input id="csrf_token" name="csrf_token" type="hidden" value="IjY5M...">

如果表单中有其他 HiddenField,它们也会被 hidden_tag() 一起渲染。

5.2.3 字段渲染方法

每个字段对象都可以像函数一样调用来渲染 HTML:

html 复制代码
<!-- 渲染输入控件 -->
{{ form.title() }}

<!-- 渲染标签 -->
{{ form.title.label() }}

<!-- 带参数渲染 -->
{{ form.title(class_='form-control', placeholder='请输入标题') }}
{{ form.title.label(class_='form-label', for_='title') }}

5.3 字段渲染选项

在模板中调用字段时,可以传入参数覆盖 render_kw 中的设置。

5.3.1 常用渲染参数
html 复制代码
<!-- class_ 设置 CSS 类 -->
{{ form.title(class_='form-control form-control-lg') }}

<!-- placeholder 设置占位文本 -->
{{ form.title(placeholder='请输入标题') }}

<!-- id 设置 HTML id -->
{{ form.title(id='article-title') }}

<!-- 任意 HTML 属性 -->
{{ form.title(data_toggle='tooltip', title='提示文字') }}

<!-- disabled 禁用 -->
{{ form.title(disabled='disabled') }}

<!-- readonly 只读 -->
{{ form.title(readonly='readonly') }}

<!-- required 必填(HTML5 原生验证) -->
{{ form.title(required=True) }}

<!-- maxlength 最大长度 -->
{{ form.title(maxlength=100) }}

<!-- 组合多个参数 -->
{{ form.title(class_='form-control', placeholder='标题',
              id='title', required=True, maxlength=100) }}
5.3.2 render_kw 与模板参数的优先级

模板中传入的参数会与 render_kw 合并,模板参数优先:

python 复制代码
# 表单定义
title = StringField('标题', render_kw={
    'class': 'form-control',
    'placeholder': '默认占位符'
})
html 复制代码
<!-- 模板中覆盖 -->
{{ form.title(class_='form-control-lg', placeholder='自定义占位符') }}

渲染结果:

html 复制代码
<input class="form-control-lg" placeholder="自定义占位符" ...>
5.3.3 SelectField 渲染
html 复制代码
<!-- 基本渲染 -->
{{ form.category() }}

<!-- 带 class -->
{{ form.category(class_='form-select') }}
5.3.4 RadioField 渲染

RadioField 需要遍历每个选项:

html 复制代码
{% for option in form.category %}
    <div class="form-check">
        {{ option(class_='form-check-input') }}
        {{ option.label(class_='form-check-label') }}
    </div>
{% endfor %}
5.3.5 BooleanField 渲染
html 复制代码
<div class="form-check">
    {{ form.is_public(class_='form-check-input') }}
    {{ form.is_public.label(class_='form-check-label') }}
</div>

5.4 自定义字段Widget

Widget 控制字段的 HTML 渲染方式。WTForms 内置了多种 Widget,也支持自定义。

5.4.1 内置 Widget
python 复制代码
from wtforms.widgets import (
    Input, TextInput, PasswordInput, TextArea, Select,
    CheckboxInput, RadioInput, SubmitInput, HiddenInput, FileInput
)

每个字段类型默认关联一个 Widget:

字段类型 默认 Widget
StringField TextInput
PasswordField PasswordInput
TextAreaField TextArea
BooleanField CheckboxInput
SelectField Select
HiddenField HiddenInput
FileField FileInput
SubmitField SubmitInput
5.4.2 修改字段的 Widget
python 复制代码
from wtforms import StringField, PasswordField
from wtforms.widgets import TextInput

# 让 StringField 渲染为 email 类型
email = StringField('邮箱', widget=TextInput(input_type='email'))

# 让 PasswordField 显示密码(明文)
password = StringField('密码', widget=TextInput(input_type='password'))
5.4.3 自定义 Widget
python 复制代码
from wtforms.widgets import Widget, html_params
from markupsafe import Markup

class BootstrapTextInput(Widget):
    """自动添加 Bootstrap 样式的文本输入 Widget"""
    def __init__(self, size=None, valid=None):
        self.size = size  # 'sm', 'lg', None
        self.valid = valid  # True, False, None

    def __call__(self, field, **kwargs):
        classes = ['form-control']
        if self.size:
            classes.append(f'form-control-{self.size}')
        if self.valid is True:
            classes.append('is-valid')
        elif self.valid is False:
            classes.append('is-invalid')

        kwargs.setdefault('class', ' '.join(classes))
        kwargs.setdefault('type', 'text')
        kwargs.setdefault('id', field.id)
        kwargs.setdefault('name', field.name)

        if field.data:
            kwargs.setdefault('value', field.data)

        return Markup(f'<input {html_params(**kwargs)}>')


class StyledStringField(StringField):
    widget = BootstrapTextInput()

使用:

python 复制代码
class MyForm(FlaskForm):
    name = StyledStringField('名称')
    email = StyledStringField('邮箱', widget=BootstrapTextInput(input_type='email'))

5.5 使用Bootstrap渲染表单

手动给每个字段添加 Bootstrap class 很繁琐。可以使用 Flask-Bootstrap 或 Bootstrap-Flask 扩展来简化。

5.5.1 Bootstrap-Flask
bash 复制代码
pip install Bootstrap-Flask
python 复制代码
from flask import Flask
from flask_bootstrap import Bootstrap5
# 或 from bootstrap_flask import Bootstrap

app = Flask(__name__)
bootstrap = Bootstrap5(app)

Bootstrap-Flask 提供了 render_formrender_field 等宏:

html 复制代码
<!-- templates/base.html -->
{% from 'bootstrap5/form.html' import render_form, render_field %}

<!-- 快速渲染整个表单 -->
{{ render_form(form) }}

<!-- 渲染单个字段 -->
{{ render_field(form.title) }}
{{ render_field(form.content) }}
{{ render_field(form.category) }}
{{ render_field(form.is_public) }}
{{ render_field(form.submit) }}

render_form 会自动生成完整的表单 HTML,包括 CSRF 令牌、错误显示、Bootstrap 样式等:

html 复制代码
<!-- render_form(form) 生成的 HTML(简化) -->
<form method="post">
    <input id="csrf_token" name="csrf_token" type="hidden" value="...">
    <div class="mb-3">
        <label class="form-label" for="title">标题</label>
        <input class="form-control" id="title" name="title" type="text" value="">
    </div>
    <!-- ... 其他字段 ... -->
    <button class="btn btn-primary" id="submit" name="submit" type="submit">发布文章</button>
</form>

render_form 的参数:

html 复制代码
{{ render_form(
    form,
    action='/article/new',     # 表单提交 URL
    method='post',              # 提交方式
    enctype='multipart/form-data',  # 编码类型(文件上传时需要)
    button_text='保存',         # 按钮文本
    button_class='btn btn-success',  # 按钮样式
    id='article-form',         # 表单 id
    class_='my-form',          # 表单 class
    novalidate=True,           # 禁用浏览器原生验证
) }}
5.5.2 render_field 参数
html 复制代码
{{ render_field(
    form.title,
    label_class='form-label',
    field_class='form-control form-control-lg',
    placeholder='请输入标题',
    description='文章标题',
    show_errors=True,
    error_class='text-danger'
) }}

5.6 表单宏封装

如果不想使用 Bootstrap-Flask,可以自己编写 Jinja2 宏来封装表单渲染逻辑。

5.6.1 创建表单宏文件
html 复制代码
<!-- templates/macros/forms.html -->

{# 渲染单个字段(带标签、错误、描述) #}
{% macro render_field(field, label_class='', field_class='', show_label=True) %}
    <div class="form-group mb-3">
        {% if show_label %}
            {{ field.label(class_=label_class or 'form-label') }}
        {% endif %}
        {{ field(class_=field_class or 'form-control', **kwargs) }}

        {% if field.description %}
            <small class="form-text text-muted">{{ field.description }}</small>
        {% endif %}

        {% if field.errors %}
            {% for error in field.errors %}
                <div class="invalid-feedback d-block text-danger">{{ error }}</div>
            {% endfor %}
        {% endif %}
    </div>
{% endmacro %}

{# 渲染复选框字段 #}
{% macro render_checkbox(field) %}
    <div class="form-group mb-3 form-check">
        {{ field(class_='form-check-input') }}
        {{ field.label(class_='form-check-label') }}
        {% if field.errors %}
            {% for error in field.errors %}
                <div class="invalid-feedback d-block">{{ error }}</div>
            {% endfor %}
        {% endif %}
    </div>
{% endmacro %}

{# 渲染单选按钮组 #}
{% macro render_radio(field) %}
    <div class="form-group mb-3">
        {{ field.label(class_='form-label') }}
        {% for option in field %}
            <div class="form-check">
                {{ option(class_='form-check-input') }}
                {{ option.label(class_='form-check-label') }}
            </div>
        {% endfor %}
        {% if field.errors %}
            {% for error in field.errors %}
                <div class="text-danger">{{ error }}</div>
            {% endfor %}
        {% endif %}
    </div>
{% endmacro %}

{# 渲染整个表单 #}
{% macro render_form(form, action='', method='post', enctype='', submit_text='提交', submit_class='btn btn-primary') %}
    <form action="{{ action }}" method="{{ method }}"
          {% if enctype %}enctype="{{ enctype }}"{% endif %}>
        {{ form.hidden_tag() }}
        {{ caller() if caller }}
        <button type="submit" class="{{ submit_class }}">{{ submit_text }}</button>
    </form>
{% endmacro %}
5.6.2 使用宏
html 复制代码
<!-- templates/new_article.html -->
{% from 'macros/forms.html' import render_field, render_checkbox %}

<form method="post">
    {{ form.hidden_tag() }}

    {{ render_field(form.title) }}
    {{ render_field(form.category) }}
    {{ render_field(form.content, field_class='form-control', rows='10') }}
    {{ render_field(form.tags) }}
    {{ render_checkbox(form.is_public) }}

    {{ form.submit(class_='btn btn-primary') }}
</form>

5.7 表单错误显示(form.errors)

表单验证失败后,错误信息存储在 form.errors 字典中。

5.7.1 errors 结构
python 复制代码
form.errors = {
    'title': ['标题不能为空'],
    'email': ['邮箱不能为空', '邮箱格式不正确'],
    'password': ['密码至少8位'],
}

每个字段可以有多个错误,存储在列表中。

5.7.2 在模板中显示错误
html 复制代码
<!-- 单个字段的错误 -->
{% if form.title.errors %}
    {% for error in form.title.errors %}
        <div class="text-danger">{{ error }}</div>
    {% endfor %}
{% endif %}

<!-- 显示所有错误(汇总) -->
{% if form.errors %}
    <div class="alert alert-danger">
        <ul>
            {% for field_name, errors in form.errors.items() %}
                <li>{{ form[field_name].label.text }}:
                    {{ errors | join(', ') }}
                </li>
            {% endfor %}
        </ul>
    </div>
{% endif %}
5.7.3 Bootstrap 风格的错误显示
html 复制代码
{% macro field_with_errors(field) %}
    <div class="form-group mb-3">
        {{ field.label(class_='form-label') }}
        {{ field(class_='form-control' + (' is-invalid' if field.errors else '')) }}

        {% if field.errors %}
            {% for error in field.errors %}
                <div class="invalid-feedback">{{ error }}</div>
            {% endfor %}
        {% else %}
            {% if field.description %}
                <small class="form-text text-muted">{{ field.description }}</small>
            {% endif %}
        {% endif %}
    </div>
{% endmacro %}

5.8 表单CSRF令牌渲染

5.8.1 FlaskForm 中的 CSRF

使用 FlaskForm 时,通过 form.hidden_tag() 渲染:

html 复制代码
<form method="post">
    {{ form.hidden_tag() }}
    <!-- 字段... -->
</form>
5.8.2 非 FlaskForm 中的 CSRF

使用 CSRFProtect 全局保护时,在模板中使用 csrf_token() 函数:

html 复制代码
<!-- 在普通表单中 -->
<form method="post">
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
    <!-- 字段... -->
</form>

<!-- 在 meta 标签中(AJAX 使用) -->
<meta name="csrf-token" content="{{ csrf_token() }}">
5.8.3 AJAX 请求携带 CSRF
html 复制代码
<meta name="csrf-token" content="{{ csrf_token() }}">

<script>
// 全局设置 AJAX 请求携带 CSRF 令牌
function getCookie(name) {
    // Flask-WTF 默认也将 CSRF 令牌存储在 cookie 中
    ...
}

// 使用 fetch
async function submitForm(data) {
    const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
    const response = await fetch('/api/submit', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': csrfToken
        },
        body: JSON.stringify(data)
    });
    return response.json();
}

// 使用 jQuery
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (settings.type && settings.type.match(/POST|PUT|PATCH|DELETE/)) {
            xhr.setRequestHeader('X-CSRFToken', csrfToken);
        }
    }
});
</script>

5.9 表单HTML完整示例

下面是一个完整的表单 HTML 示例,综合运用了本章所有知识:

html 复制代码
<!-- templates/register.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>用户注册</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h4 class="mb-0">用户注册</h4>
                    </div>
                    <div class="card-body">
                        {% with messages = get_flashed_messages(with_categories=true) %}
                            {% if messages %}
                                {% for category, message in messages %}
                                    <div class="alert alert-{{ category }} alert-dismissible fade show">
                                        {{ message }}
                                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                                    </div>
                                {% endfor %}
                            {% endif %}
                        {% endwith %}

                        <form method="post" novalidate>
                            {{ form.hidden_tag() }}

                            <div class="mb-3">
                                {{ form.username.label(class_='form-label') }}
                                {{ form.username(class_='form-control' + (' is-invalid' if form.username.errors else ''),
                                    placeholder='3-20个字符') }}
                                {% for error in form.username.errors %}
                                    <div class="invalid-feedback">{{ error }}</div>
                                {% endfor %}
                            </div>

                            <div class="mb-3">
                                {{ form.email.label(class_='form-label') }}
                                {{ form.email(class_='form-control' + (' is-invalid' if form.email.errors else ''),
                                    placeholder='example@email.com') }}
                                {% for error in form.email.errors %}
                                    <div class="invalid-feedback">{{ error }}</div>
                                {% endfor %}
                            </div>

                            <div class="mb-3">
                                {{ form.password.label(class_='form-label') }}
                                {{ form.password(class_='form-control' + (' is-invalid' if form.password.errors else ''),
                                    placeholder='至少8位') }}
                                {% for error in form.password.errors %}
                                    <div class="invalid-feedback">{{ error }}</div>
                                {% endfor %}
                            </div>

                            <div class="mb-3">
                                {{ form.confirm_password.label(class_='form-label') }}
                                {{ form.confirm_password(class_='form-control' + (' is-invalid' if form.confirm_password.errors else '')) }}
                                {% for error in form.confirm_password.errors %}
                                    <div class="invalid-feedback">{{ error }}</div>
                                {% endfor %}
                            </div>

                            <div class="mb-3 form-check">
                                {{ form.agree(class_='form-check-input' + (' is-invalid' if form.agree.errors else '')) }}
                                {{ form.agree.label(class_='form-check-label') }}
                                {% for error in form.agree.errors %}
                                    <div class="invalid-feedback">{{ error }}</div>
                                {% endfor %}
                            </div>

                            <div class="d-grid">
                                {{ form.submit(class_='btn btn-primary btn-lg') }}
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

5.10 表单渲染最佳实践

  1. 使用宏封装:将重复的渲染逻辑封装成宏,保持模板简洁。
  2. 统一错误显示:所有字段使用统一的错误显示样式。
  3. 添加 CSRF 令牌:每个 POST 表单都必须包含 CSRF 令牌。
  4. 使用 novalidate:在开发阶段禁用浏览器原生验证,确保服务器端验证生效。
  5. 响应式设计:使用 Bootstrap 等框架确保表单在不同设备上良好显示。
  6. 可访问性 :使用 <label> 关联字段,添加 aria 属性提升可访问性。
  7. 描述信息:为复杂字段添加描述帮助用户理解。
5.10.1 可访问性(Accessibility)详解

表单的可访问性不仅仅是"锦上添花",而是法律要求(在很多国家和地区)和道德义务。以下是提升表单可访问性的具体措施:

html 复制代码
<!-- 1. 正确关联 label 和 input -->
<div class="mb-3">
    <label for="email" class="form-label">邮箱地址</label>
    <input type="email" id="email" name="email" 
           class="form-control" 
           aria-describedby="email-help email-error"
           required>
    <small id="email-help" class="form-text text-muted">
        我们不会泄露您的邮箱地址
    </small>
    <div id="email-error" class="invalid-feedback" role="alert">
        请输入有效的邮箱地址
    </div>
</div>

<!-- 2. 为必填字段添加视觉和语义标记 -->
<div class="mb-3">
    <label for="username" class="form-label">
        用户名 <span class="text-danger" aria-label="必填">*</span>
    </label>
    <input type="text" id="username" name="username"
           class="form-control" required
           aria-required="true">
</div>

<!-- 3. 分组表单控件 -->
<fieldset class="mb-3">
    <legend class="form-label">性别</legend>
    <div class="form-check">
        <input type="radio" id="gender-male" name="gender" value="male" 
               class="form-check-input">
        <label for="gender-male" class="form-check-label">男</label>
    </div>
    <div class="form-check">
        <input type="radio" id="gender-female" name="gender" value="female"
               class="form-check-input">
        <label for="gender-female" class="form-check-label">女</label>
    </div>
</fieldset>

<!-- 4. 错误提示使用 role="alert" 确保屏幕阅读器即时播报 -->
<div class="alert alert-danger" role="alert">
    <h5 class="alert-heading">表单验证失败</h5>
    <p>请修正以下错误后重新提交:</p>
    <ul>
        <li>用户名至少需要3个字符</li>
        <li>邮箱格式不正确</li>
    </ul>
</div>
5.10.2 表单布局最佳实践

不同类型的表单适合不同的布局。以下是三种常见布局的适用场景和实现:

html 复制代码
<!-- 1. 垂直布局(最常用,适合大多数表单) -->
<form method="post">
    {{ form.hidden_tag() }}
    <div class="mb-3">
        {{ form.name.label(class_='form-label') }}
        {{ form.name(class_='form-control') }}
    </div>
    <div class="mb-3">
        {{ form.email.label(class_='form-label') }}
        {{ form.email(class_='form-control') }}
    </div>
    {{ form.submit(class_='btn btn-primary') }}
</form>

<!-- 2. 水平布局(标签和输入框在同一行,适合编辑表单) -->
<form method="post">
    {{ form.hidden_tag() }}
    <div class="row mb-3">
        <label class="col-sm-2 col-form-label">{{ form.name.label.text }}</label>
        <div class="col-sm-10">
            {{ form.name(class_='form-control') }}
        </div>
    </div>
    <div class="row mb-3">
        <label class="col-sm-2 col-form-label">{{ form.email.label.text }}</label>
        <div class="col-sm-10">
            {{ form.email(class_='form-control') }}
        </div>
    </div>
    <div class="row">
        <div class="col-sm-10 offset-sm-2">
            {{ form.submit(class_='btn btn-primary') }}
        </div>
    </div>
</form>

<!-- 3. 内联布局(适合搜索栏、登录条等紧凑场景) -->
<form class="d-flex gap-2" method="post">
    {{ form.hidden_tag() }}
    {{ form.keyword(class_='form-control', placeholder='搜索...') }}
    {{ form.submit(class_='btn btn-outline-primary') }}
</form>
5.10.3 表单渲染性能考虑

在大型应用中,表单渲染的性能也需要关注。以下是一些优化建议:

  1. 缓存静态选项 :如果 SelectFieldchoices 来自数据库且不常变化,应该缓存查询结果,避免每次渲染表单都查询数据库。可以使用 functools.lru_cache 或 Redis 缓存。

  2. 避免在模板中做复杂逻辑:表单的渲染逻辑应该尽量简单,复杂的业务逻辑(如根据用户角色显示不同字段)应该在视图函数中处理,通过传递不同的表单实例来实现。

  3. 使用宏减少模板体积:将重复的表单渲染代码封装成宏,不仅可以减少模板体积,还能确保所有表单的渲染风格一致。

  4. 延迟加载大表单:对于包含大量字段的表单(如复杂的设置页面),可以考虑使用分页或 Tab 标签页的方式拆分,避免一次性渲染过多 HTML。


第六章 表单处理流程

本章讲解表单从展示到提交到处理的完整流程,包括验证、数据获取、错误处理、PRG 模式等核心概念。

6.1 表单处理完整流程

表单处理的标准流程如下:

复制代码
1. GET 请求 → 展示空表单
2. 用户填写 → POST 提交
3. 服务器接收 → 验证数据
4. 验证通过 → 处理业务 → 重定向(PRG 模式)
5. 验证失败 → 回显表单(保留数据,显示错误)

代码实现:

python 复制代码
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_wtf import FlaskForm

app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret-key'

class ContactForm(FlaskForm):
    name = StringField('姓名', validators=[DataRequired()])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    message = TextAreaField('留言', validators=[DataRequired()])
    submit = SubmitField('发送')

@app.route('/contact', methods=['GET', 'POST'])
def contact():
    form = ContactForm()
    if form.validate_on_submit():
        # 验证通过,处理业务
        name = form.name.data
        email = form.email.data
        message = form.message.data
        # 保存到数据库或发送邮件...
        flash('留言发送成功!', 'success')
        return redirect(url_for('contact'))  # PRG 模式:重定向
    # GET 请求或验证失败,都渲染表单
    return render_template('contact.html', form=form)

这个流程的关键点:

  1. GET 和 POST 共用同一个视图函数 :通过 methods=['GET', 'POST'] 声明。
  2. form.validate_on_submit():同时检查是否为 POST 请求和是否验证通过。
  3. 验证失败自动回显:WTForms 会保留用户输入的数据,模板中渲染时自动回填。
  4. PRG 模式:验证通过后重定向,防止刷新导致重复提交。

6.2 form.validate_on_submit()详解

validate_on_submit() 是 FlaskForm 提供的便捷方法,等价于:

python 复制代码
def validate_on_submit(self):
    """检查是否为 POST/PUT/PATCH/DELETE 请求 且 验证通过"""
    if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
        return self.validate()
    return False

它的两个条件:

  1. 请求方法为 POST(或 PUT/PATCH/DELETE)。
  2. form.validate() 返回 True(所有字段验证通过)。
python 复制代码
# 等价于
if request.method == 'POST' and form.validate():
    # ...

# 使用 validate_on_submit
if form.validate_on_submit():
    # ...

6.3 form.validate()手动验证

有时你需要在非标准场景下手动触发验证。

6.3.1 手动验证
python 复制代码
form = ContactForm(request.form)  # 手动传入数据
if form.validate():  # 手动验证
    # 验证通过
    pass
else:
    # 验证失败
    print(form.errors)
6.3.2 部分验证

只验证指定字段:

python 复制代码
# 只验证 name 字段
if form.validate(extra_validators={}) :
    pass

# 在 API 场景中,可能只验证部分字段
form = ContactForm(data=request.get_json(), meta={'csrf': False})
if form.name.validate(form):
    # name 字段验证通过
    pass
6.3.3 跳过 CSRF 验证

在某些场景下(如 API),需要跳过 CSRF:

python 复制代码
class APIForm(FlaskForm):
    class Meta:
        csrf = False  # 禁用此表单的 CSRF

    name = StringField('名称', validators=[DataRequired()])

6.4 获取表单数据

验证通过后,通过 form.字段名.data 获取数据。

python 复制代码
if form.validate_on_submit():
    # 获取各字段数据
    name = form.name.data           # 字符串
    age = form.age.data             # 整数(自动转换)
    birthday = form.birthday.data   # date 对象(自动转换)
    tags = form.tags.data           # 列表(SelectMultipleField)
    is_public = form.is_public.data # 布尔值
    avatar = form.avatar.data       # FileStorage 对象

    # 获取所有字段数据
    all_data = form.data  # {'name': '张三', 'age': 25, ...}
6.4.1 populate_obj 批量赋值

populate_obj 方法可以将表单数据批量赋值到对象属性,非常适合与 ORM 模型配合使用:

python 复制代码
class ArticleForm(FlaskForm):
    title = StringField('标题', validators=[DataRequired()])
    content = TextAreaField('正文', validators=[DataRequired()])
    category_id = SelectField('分类', coerce=int)
    is_public = BooleanField('公开')

@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
    form = ArticleForm()
    if form.validate_on_submit():
        article = Article()           # 创建模型实例
        form.populate_obj(article)    # 批量赋值
        article.author_id = current_user.id
        article.created_at = datetime.utcnow()
        db.session.add(article)
        db.session.commit()
        flash('文章发布成功!')
        return redirect(url_for('article_detail', id=article.id))
    return render_template('new_article.html', form=form)

注意:populate_obj 会将表单中所有字段的数据赋值到对象的同名属性,如果表单中有 submitcsrf_token 等字段,需要确保对象有对应属性,或排除这些字段:

python 复制代码
# 安全的 populate_obj:排除特殊字段
def safe_populate_obj(form, obj, exclude=None):
    exclude = exclude or ['submit', 'csrf_token']
    for name, field in form._fields.items():
        if name in exclude:
            continue
        setattr(obj, name, field.data)

safe_populate_obj(form, article)

6.5 表单错误处理(form.errors字典)

验证失败时,所有错误信息存储在 form.errors 中。

python 复制代码
form = ContactForm()
if not form.validate():
    # form.errors 示例:
    # {
    #     'name': ['姓名不能为空'],
    #     'email': ['邮箱不能为空', '邮箱格式不正确'],
    # }
    for field_name, errors in form.errors.items():
        print(f'{field_name}: {errors}')
6.5.1 在 API 中返回错误
python 复制代码
@app.route('/api/contact', methods=['POST'])
def api_contact():
    form = ContactForm(data=request.get_json(), meta={'csrf': False})
    if form.validate():
        # 处理...
        return {'status': 'success', 'message': '发送成功'}
    return {'status': 'error', 'errors': form.errors}, 400
6.5.2 收集所有错误消息
python 复制代码
# 获取所有错误消息的扁平列表
all_errors = []
for field_name, errors in form.errors.items():
    for error in errors:
        all_errors.append(f'{form[field_name].label.text}: {error}')

# 或使用列表推导
all_errors = [f'{form[f].label.text}: {e}'
              for f, errs in form.errors.items()
              for e in errs]

6.6 PRG模式(Post/Redirect/Get)

PRG(Post/Redirect/Get)是一种 Web 设计模式,用于防止表单重复提交。

6.6.1 问题:重复提交

如果 POST 请求直接返回 HTML 页面,用户刷新页面时浏览器会弹出"确认重新提交"对话框,如果用户确认,表单数据会被重复提交:

复制代码
用户提交表单 → POST /register → 返回"注册成功"页面
用户刷新页面 → 浏览器提示"确认重新提交" → 确认 → 重复注册
6.6.2 解决方案:PRG 模式
复制代码
用户提交表单 → POST /register → 重定向到 GET /success
用户刷新页面 → GET /success → 重新显示"成功"页面(无副作用)
python 复制代码
@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        # 保存用户
        user = User(...)
        db.session.add(user)
        db.session.commit()
        flash('注册成功!')
        return redirect(url_for('register_success'))  # 重定向!
    return render_template('register.html', form=form)

@app.route('/register/success')
def register_success():
    return render_template('register_success.html')
6.6.3 flash 消息在 PRG 中的作用

flash 消息存储在 session 中,在重定向后的 GET 请求中读取。这使得 PRG 模式下可以在目标页面显示操作结果:

python 复制代码
# POST 处理
if form.validate_on_submit():
    # ...
    flash('操作成功!', 'success')
    return redirect(url_for('list'))

# GET 页面
@app.route('/list')
def list():
    return render_template('list.html')
html 复制代码
<!-- templates/list.html -->
{% with messages = get_flashed_messages(with_categories=true) %}
    {% if messages %}
        {% for category, message in messages %}
            <div class="alert alert-{{ category }}">{{ message }}</div>
        {% endfor %}
    {% endif %}
{% endwith %}
6.6.4 PRG 模式的变体与进阶

PRG 模式在实际应用中有多种变体,适用于不同的业务场景:

变体一:PRG 带参数重定向

有时需要在重定向后传递一些数据(如新创建记录的 ID)。由于 flash 消息只支持字符串,不适合传递结构化数据,可以使用查询参数或 session:

python 复制代码
# 方式一:查询参数(适用于公开数据)
@app.route('/article/new', methods=['GET', 'POST'])
def new_article():
    form = ArticleForm()
    if form.validate_on_submit():
        article = Article(title=form.title.data, content=form.content.data)
        db.session.add(article)
        db.session.commit()
        return redirect(url_for('article_detail', id=article.id))
    return render_template('article_new.html', form=form)

# 方式二:session(适用于敏感数据或大量数据)
@app.route('/order/create', methods=['POST'])
def create_order():
    form = OrderForm()
    if form.validate_on_submit():
        order = create_order_from_form(form)
        session['last_order_id'] = order.id  # 临时存储在session中
        flash('订单创建成功', 'success')
        return redirect(url_for('order_confirmation'))
    return render_template('order_new.html', form=form)

@app.route('/order/confirmation')
def order_confirmation():
    order_id = session.pop('last_order_id', None)  # 读取后立即删除
    if not order_id:
        return redirect(url_for('order_list'))
    order = Order.query.get_or_404(order_id)
    return render_template('order_confirmation.html', order=order)

变体二:验证失败时不重定向

PRG 模式仅在验证成功时使用重定向。验证失败时必须直接渲染表单(不重定向),否则表单数据和错误信息会丢失:

python 复制代码
# 正确:验证失败直接渲染
@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        # 成功:重定向(PRG)
        return redirect(url_for('register_success'))
    # 失败:直接渲染(保留数据和错误)
    return render_template('register.html', form=form)

# 错误:验证失败时也重定向
@app.route('/register', methods=['GET', 'POST'])
def register_bad():
    form = RegisterForm()
    if form.validate_on_submit():
        return redirect(url_for('register_success'))
    elif request.method == 'POST':
        # 错误:重定向会导致表单数据和错误丢失
        flash('验证失败', 'danger')
        return redirect(url_for('register'))  # 不要这样做!
    return render_template('register.html', form=form)

变体三:AJAX 表单不需要 PRG

AJAX 表单提交不涉及页面刷新,因此不需要 PRG 模式。服务器直接返回 JSON 响应,前端根据响应结果更新页面:

python 复制代码
@app.route('/api/contact', methods=['POST'])
def api_contact():
    form = ContactForm()
    if form.validate_on_submit():
        # 处理业务逻辑
        send_email(form.email.data, form.message.data)
        return jsonify({'success': True, 'message': '消息已发送'})
    # 验证失败:返回错误信息,不重定向
    return jsonify({'success': False, 'errors': form.errors}), 422
6.6.5 防止重复提交的其他方法

除了 PRG 模式,还有其他防止重复提交的方法,可以与 PRG 配合使用:

python 复制代码
import uuid

# 方法一:一次性令牌(适用于关键操作)
@app.route('/transfer', methods=['GET', 'POST'])
def transfer():
    if request.method == 'GET':
        # 生成一次性令牌
        token = uuid.uuid4().hex
        session['form_token'] = token
        form = TransferForm()
        return render_template('transfer.html', form=form, token=token)
    
    form = TransferForm()
    if form.validate_on_submit():
        # 检查令牌
        submitted_token = request.form.get('form_token')
        stored_token = session.pop('form_token', None)
        
        if not stored_token or submitted_token != stored_token:
            flash('请勿重复提交', 'warning')
            return redirect(url_for('transfer'))
        
        # 执行转账
        execute_transfer(form.amount.data, form.to_account.data)
        flash('转账成功', 'success')
        return redirect(url_for('transfer_success'))

# 方法二:数据库唯一约束(最终防线)
# 即使用户绕过了前端和令牌检查,数据库的唯一约束也能防止重复数据
class Order(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    order_number = db.Column(db.String(32), unique=True, nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
    amount = db.Column(db.Numeric(10, 2))
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

@app.route('/order/submit', methods=['POST'])
def submit_order():
    form = OrderForm()
    if form.validate_on_submit():
        # 使用用户ID+时间窗口生成唯一订单号
        # 同一用户在同一秒内只能创建一个订单
        order_number = f"{current_user.id}_{int(time.time())}"
        order = Order(order_number=order_number, ...)
        try:
            db.session.add(order)
            db.session.commit()
        except IntegrityError:
            db.session.rollback()
            flash('订单已提交,请勿重复操作', 'info')
            return redirect(url_for('order_list'))

6.7 flash消息与表单

flash 消息常用于表单操作的结果反馈。

6.7.1 基本使用
python 复制代码
# 成功消息
flash('注册成功!请登录。', 'success')

# 错误消息
flash('注册失败,请重试。', 'danger')

# 警告消息
flash('您的账号即将到期。', 'warning')

# 信息消息
flash('系统将于今晚维护。', 'info')
6.7.2 模板中显示
html 复制代码
{% with messages = get_flashed_messages(with_categories=true) %}
    {% if messages %}
        <div class="flash-messages">
            {% for category, message in messages %}
                <div class="alert alert-{{ category }} alert-dismissible fade show"
                     role="alert">
                    {{ message }}
                    <button type="button" class="btn-close"
                            data-bs-dismiss="alert"></button>
                </div>
            {% endfor %}
        </div>
    {% endif %}
{% endwith %}
6.7.3 表单验证失败时的 flash
python 复制代码
if form.validate_on_submit():
    # ...
    flash('保存成功!', 'success')
    return redirect(url_for('index'))
else:
    if form.errors:
        flash('表单验证失败,请检查输入。', 'danger')
6.7.4 自定义 flash 消息分类

除了内置的四种分类(success、danger、warning、info),你可以使用自定义分类来更精细地控制消息展示:

python 复制代码
# 自定义分类
flash('您的账户需要邮箱验证才能继续操作。', 'verification')
flash('新功能已上线,点击查看详情。', 'feature')

# 模板中按分类过滤显示
{% with messages = get_flashed_messages(with_categories=true, category_filter=['verification']) %}
    {% if messages %}
        <div class="verification-banner">
            {% for category, message in messages %}
                <div class="alert alert-info verification-alert">
                    <i class="bi bi-envelope"></i> {{ message }}
                </div>
            {% endfor %}
        </div>
    {% endif %}
{% endwith %}

{# 其他分类的消息 #}
{% with messages = get_flashed_messages(with_categories=true, category_filter=['success', 'danger', 'warning', 'info']) %}
    {% if messages %}
        {% for category, message in messages %}
            <div class="alert alert-{{ category }} alert-dismissible fade show">
                {{ message }}
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        {% endfor %}
    {% endif %}
{% endwith %}
6.7.5 flash 消息与表单错误的最佳配合

在实际开发中,flash 消息和表单字段错误应该配合使用:flash 消息用于整体操作结果的提示,字段错误用于具体的输入纠正指引。最佳实践是:验证失败时不使用 flash 重定向(因为重定向会丢失表单数据),而是直接渲染表单页面并显示字段错误;验证成功时使用 flash 配合重定向(PRG 模式):

python 复制代码
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
    article = Article.query.get_or_404(id)
    form = ArticleForm(obj=article) if request.method == 'GET' else ArticleForm()
    
    if form.validate_on_submit():
        form.populate_obj(article)
        db.session.commit()
        flash('文章更新成功!', 'success')
        return redirect(url_for('article_detail', id=id))
    
    # 验证失败时,不使用flash,直接渲染表单(字段错误会自动显示)
    if form.errors:
        # 可选:添加一个整体的提示消息
        flash('请修正下方标记的错误后重新提交。', 'warning')
    
    return render_template('article_edit.html', form=form, article=article)

这种设计确保了:成功操作有明确的成功提示(通过 flash + 重定向),失败操作保留了用户输入和字段级错误(通过直接渲染表单),两者各司其职,用户体验清晰。

6.8 表单数据持久化(保存到数据库)

表单验证通过后,通常需要将数据保存到数据库。

6.8.1 使用 Flask-SQLAlchemy
python 复制代码
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash

db = SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(255), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

class RegisterForm(FlaskForm):
    username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=20)])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    password = PasswordField('密码', validators=[DataRequired(), Length(min=8)])
    confirm_password = PasswordField('确认密码', validators=[EqualTo('password')])
    submit = SubmitField('注册')

@app.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        # 检查用户名是否已存在
        if User.query.filter_by(username=form.username.data).first():
            form.username.errors.append('用户名已被注册')
            return render_template('register.html', form=form)

        # 创建用户
        user = User(
            username=form.username.data,
            email=form.email.data,
            password_hash=generate_password_hash(form.password.data)
        )
        db.session.add(user)
        db.session.commit()

        flash('注册成功!请登录。', 'success')
        return redirect(url_for('login'))
    return render_template('register.html', form=form)
6.8.2 使用 populate_obj
python 复制代码
@app.route('/profile/edit', methods=['GET', 'POST'])
@login_required
def edit_profile():
    form = ProfileForm()
    if form.validate_on_submit():
        # 直接填充到 current_user 对象
        form.populate_obj(current_user)
        db.session.commit()
        flash('资料更新成功!', 'success')
        return redirect(url_for('profile'))
    return render_template('edit_profile.html', form=form)

6.9 编辑表单(预填充数据)

编辑已有数据时,需要在展示表单时预填充数据。

6.9.1 GET 请求时预填充
python 复制代码
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
    article = Article.query.get_or_404(id)
    form = ArticleForm()

    if form.validate_on_submit():
        # POST: 更新数据
        form.populate_obj(article)
        db.session.commit()
        flash('文章更新成功!', 'success')
        return redirect(url_for('article_detail', id=article.id))

    if request.method == 'GET':
        # GET: 预填充数据
        form.title.data = article.title
        form.content.data = article.content
        form.category_id.data = article.category_id
        form.is_public.data = article.is_public

    return render_template('edit_article.html', form=form)
6.9.2 使用 obj 参数预填充

更简洁的方式是在创建表单时传入 obj:

python 复制代码
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
    article = Article.query.get_or_404(id)
    form = ArticleForm(obj=article)  # 从对象预填充

    if form.validate_on_submit():
        form.populate_obj(article)  # 更新对象
        db.session.commit()
        flash('文章更新成功!', 'success')
        return redirect(url_for('article_detail', id=article.id))

    return render_template('edit_article.html', form=form)

obj=article 会从 article 对象的属性中读取初始值。当 POST 请求提交时,表单数据会覆盖对象数据。

6.9.3 动态 choices 的预填充

如果 SelectField 的 choices 是动态的,需要在预填充前设置 choices:

python 复制代码
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id):
    article = Article.query.get_or_404(id)
    form = ArticleForm()

    # 动态设置 choices
    categories = Category.query.all()
    form.category_id.choices = [(c.id, c.name) for c in categories]

    if form.validate_on_submit():
        form.populate_obj(article)
        db.session.commit()
        flash('更新成功!')
        return redirect(url_for('article_detail', id=id))

    if request.method == 'GET':
        form.title.data = article.title
        form.content.data = article.content
        form.category_id.data = article.category_id

    return render_template('edit_article.html', form=form)

6.10 表单处理完整实战案例

下面是一个完整的文章发布表单,综合运用了本章所有知识:

python 复制代码
# app.py
import os
from datetime import datetime
from flask import Flask, render_template, redirect, url_for, flash, request
from flask_wtf import FlaskForm
from flask_sqlalchemy import SQLAlchemy
from wtforms import (StringField, TextAreaField, SelectField, BooleanField,
                     SubmitField, HiddenField)
from wtforms.validators import DataRequired, Length, Optional

app = Flask(__name__)
app.config['SECRET_KEY'] = 'dev-secret-key'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

# === 模型 ===
class Category(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)
    articles = db.relationship('Article', backref='category', lazy='dynamic')

class Article(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(100), nullable=False)
    content = db.Column(db.Text, nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('category.id'))
    tags = db.Column(db.String(200))  # 逗号分隔的标签
    is_public = db.Column(db.Boolean, default=True)
    status = db.Column(db.String(20), default='draft')  # draft, published
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

# === 表单 ===
class ArticleForm(FlaskForm):
    id = HiddenField()
    title = StringField('标题', validators=[
        DataRequired(message='标题不能为空'),
        Length(max=100, message='标题最多100字')
    ], render_kw={'class': 'form-control', 'placeholder': '请输入标题'})

    category_id = SelectField('分类', coerce=int, validators=[
        DataRequired(message='请选择分类')
    ], render_kw={'class': 'form-select'})

    content = TextAreaField('正文', validators=[
        DataRequired(message='正文不能为空'),
        Length(min=10, message='正文至少10字')
    ], render_kw={'class': 'form-control', 'rows': 15, 'placeholder': '请输入正文'})

    tags = StringField('标签', validators=[
        Optional(), Length(max=200)
    ], description='多个标签用英文逗号分隔', render_kw={
        'class': 'form-control', 'placeholder': 'Python, Flask, Web'
    })

    is_public = BooleanField('公开', default=True, render_kw={'class': 'form-check-input'})

    save_draft = SubmitField('保存草稿', render_kw={'class': 'btn btn-secondary'})
    publish = SubmitField('发布', render_kw={'class': 'btn btn-primary'})

# === 视图 ===
@app.route('/article/new', methods=['GET', 'POST'])
@app.route('/article/<int:id>/edit', methods=['GET', 'POST'])
def edit_article(id=None):
    # 新建或编辑
    if id:
        article = Article.query.get_or_404(id)
    else:
        article = Article()

    form = ArticleForm(obj=article)

    # 动态设置分类选项
    form.category_id.choices = [(c.id, c.name) for c in Category.query.order_by(Category.name)]

    if form.validate_on_submit():
        # 填充数据
        article.title = form.title.data
        article.content = form.content.data
        article.category_id = form.category_id.data
        article.tags = form.tags.data
        article.is_public = form.is_public.data

        # 根据按钮决定状态
        if form.publish.data:
            article.status = 'published'
            message = '文章发布成功!'
        else:
            article.status = 'draft'
            message = '草稿保存成功!'

        # 保存到数据库
        if not article.id:
            db.session.add(article)
        db.session.commit()

        flash(message, 'success')
        return redirect(url_for('view_article', id=article.id))

    return render_template('edit_article.html', form=form, article=article)

@app.route('/article/<int:id>')
def view_article(id):
    article = Article.query.get_or_404(id)
    return render_template('view_article.html', article=article)

# 初始化数据库
with app.app_context():
    db.create_all()
    # 添加示例分类
    if not Category.query.first():
        for name in ['技术', '生活', '旅行', '读书']:
            db.session.add(Category(name=name))
        db.session.commit()

if __name__ == '__main__':
    app.run(debug=True)
html 复制代码
<!-- templates/edit_article.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>{{ '编辑文章' if article.id else '新建文章' }}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-4">
        <h2>{{ '编辑文章' if article.id else '新建文章' }}</h2>

        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ category }}">{{ message }}</div>
                {% endfor %}
            {% endif %}
        {% endwith %}

        <form method="post">
            {{ form.hidden_tag() }}

            <div class="mb-3">
                {{ form.title.label(class_='form-label') }}
                {{ form.title() }}
                {% for error in form.title.errors %}
                    <div class="text-danger">{{ error }}</div>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.category_id.label(class_='form-label') }}
                {{ form.category_id() }}
                {% for error in form.category_id.errors %}
                    <div class="text-danger">{{ error }}</div>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.content.label(class_='form-label') }}
                {{ form.content() }}
                {% for error in form.content.errors %}
                    <div class="text-danger">{{ error }}</div>
                {% endfor %}
            </div>

            <div class="mb-3">
                {{ form.tags.label(class_='form-label') }}
                {{ form.tags() }}
                <small class="text-muted">{{ form.tags.description }}</small>
            </div>

            <div class="mb-3 form-check">
                {{ form.is_public() }}
                {{ form.is_public.label(class_='form-check-label') }}
            </div>

            <div class="d-flex gap-2">
                {{ form.save_draft() }}
                {{ form.publish() }}
                <a href="{{ url_for('view_article', id=article.id) if article.id else url_for('edit_article') }}"
                   class="btn btn-outline-secondary">取消</a>
            </div>
        </form>
    </div>
</body>
</html>
相关推荐
一晌小贪欢43 分钟前
Python办公16:PDF 强力缝合——将几十个 PDF 合并为单个文档并添加页码
开发语言·python·excel·数据可视化·python办公
qq_1611112744 分钟前
深入理解Python中的Contextlib库
python·装饰器·函数·上下文管理器·contextlib
Zkaisen1 小时前
GIt从零开始:小白完整学习文档
git·学习
迷迭香yy1 小时前
回测过拟合检测体系从样本内外到组合稳健性评估 IG50免费开源股票数据API接口
服务器·开发语言·数据库·人工智能·python
STQY燊桐启元(深圳)电子科技1 小时前
免涂硅脂新时代,ST‑PCMTC96 与 ST‑PCM180 相变陶瓷片重构功率器件热管理
经验分享·笔记·重构·pcm
Zane1994121 小时前
`ClassName()` 只是一步?拆开看 `__new__` 和 `__init__` 各自在干什么
开发语言·python
LuminousCPP1 小时前
数据结构-二叉树(三):堆复杂度证明与 Top-K 问题|错位相减推导 + 海量数据内存优化
c语言·数据结构·笔记·排序算法
那年窗外下的雪.1 小时前
STM32嵌入式学习 03:TIM2 定时器更新中断
stm32·单片机·学习
问天_观心1 小时前
深入学习Transformer(二)
深度学习·学习·transformer