第一章:为什么需要专业表单验证?
在第3篇中,我们通过 request.form.get() 简单获取数据,并用 .strip() 去除空格。这种方式存在明显缺陷:
- 无结构化校验:无法统一管理验证逻辑
- 错误反馈粗糙 :仅靠前端
required,后端无兜底 - 安全风险:未过滤特殊字符、超长输入等
- 扩展困难:新增字段需重复编写验证代码
解决方案 :引入 WTForms ------ Python 最流行的表单处理库,Flask 官方推荐搭配 Flask-WTF 使用。
第二章:集成 Flask-WTF 实现健壮表单
2.1 安装与配置
pip install Flask-WTF
更新 requirements.txt:
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1 # 新增
SQLAlchemy==2.0.30
Werkzeug==3.0.3
注意 :
Flask-WTF内置 CSRF(跨站请求伪造)保护,需配置密钥。
在 config.py 中强化密钥设置:
import os
import secrets
class Config:
# 优先从环境变量读取,否则生成随机值
SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(16)
SQLALCHEMY_TRACK_MODIFICATIONS = False
2.2 创建任务表单类
新建 forms.py(与 models.py 同级):
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length, ValidationError
from models import Todo
class TodoForm(FlaskForm):
"""任务添加/编辑表单"""
title = StringField(
'任务标题',
validators=[
DataRequired(message='任务内容不能为空'),
Length(min=1, max=100, message='任务长度需在1-100字符之间')
],
render_kw={ # 传递给 HTML input 的属性
"placeholder": "请输入任务内容(1-100字符)",
"class": "layui-input"
}
)
submit = Submit(field="添加任务", render_kw={"class": "layui-btn"})
def validate_title(self, field):
"""自定义验证:禁止纯空白字符"""
if not field.data.strip():
raise ValidationError('任务内容不能全为空格')
# 可选:敏感词过滤(示例)
# forbidden_words = ['垃圾', '广告']
# for word in forbidden_words:
# if word in field.data:
# raise ValidationError(f'任务内容不能包含敏感词: {word}')
关键说明:
DataRequired:非空验证(比InputRequired更严格)Length:长度限制render_kw:自动为<input>添加 class 和 placeholdervalidate_title:自定义验证方法(方法名必须为validate_<字段名>)
2.3 在视图函数中使用表单
修改 routes/main.py:
from flask import Blueprint, render_template, request, redirect, url_for, flash
from models import db, Todo
from forms import TodoForm # 新增导入
main = Blueprint('main', __name__)
@main.route('/', methods=['GET', 'POST']) # 支持 POST
def index():
form = TodoForm() # 实例化表单
# 处理表单提交
if form.validate_on_submit():
title = form.title.data.strip()
new_todo = Todo(title=title)
db.session.add(new_todo)
db.session.commit()
flash('任务添加成功!', 'success') # 消息闪现
return redirect(url_for('main.index'))
# 处理搜索
query = request.args.get('q', '').strip()
if query:
todos = Todo.query.filter(Todo.title.contains(query)).all()
else:
todos = Todo.query.order_by(Todo.created_at.desc()).all()
return render_template('index.html', form=form, todos=todos, search_query=query)
重要变更:
- 路由支持
POST方法 - 使用
form.validate_on_submit()自动处理 GET/POST 判断和验证 - 验证成功后使用
flash()发送成功消息
2.4 在模板中渲染表单
更新 templates/index.html 的表单部分:
<!-- 替换原表单 -->
<form class="layui-form" method="POST">
{{ form.hidden_tag() }} <!-- 必须:输出 CSRF 令牌 -->
<div class="layui-form-item">
<div class="layui-input-block" style="margin-left: 0;">
<div class="layui-input-inline" style="width: 500px;">
{{ form.title(class="layui-input") }}
<!-- 显示字段错误 -->
{% if form.title.errors %}
<ul class="layui-form-mid layui-text" style="color:#FF5722; margin-top:5px;">
{% for error in form.title.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{{ form.submit(class="layui-btn") }}
</div>
</div>
</form>
<!-- 显示全局消息(如 flash) -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="layui-alert layui-alert-{{ 'success' if category=='success' else 'error' }}"
style="margin: 15px 0; padding: 10px; background: #f6f8fa; border-left: 4px solid {{ '#52C41A' if category=='success' else '#F5222D' }};">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
Layui 样式适配技巧:
layui-form-mid:用于表单中间提示文本- 自定义 alert 样式模拟 Layui 风格(Layui 无内置 alert 组件)
form.hidden_tag():输出隐藏的 CSRF token 字段
✅ 效果:当输入空格或超长时,页面会显示红色错误提示;成功添加后显示绿色成功消息。
第三章:全局错误处理与友好提示
3.1 为什么需要自定义错误页?
默认的 Flask 错误页面(如 404 Not Found)对用户不友好,且暴露技术细节。我们需要:
- 统一错误页面风格(继承 Layui)
- 隐藏敏感信息
- 提供返回首页的链接
3.2 创建错误模板
新建 templates/errors/404.html:
{% extends "base.html" %}
{% block title %}页面未找到 - 404{% endblock %}
{% block header %}哎呀,页面走丢了!{% endblock %}
{% block content %}
<div style="text-align: center; padding: 40px 0; color: #999;">
<i class="layui-icon" style="font-size: 60px;"></i>
<h2 style="margin: 20px 0;">404 - 您访问的页面不存在</h2>
<p>可能是地址输入错误,或页面已被移除</p>
<a href="{{ url_for('main.index') }}" class="layui-btn layui-btn-primary" style="margin-top: 20px;">
返回首页
</a>
</div>
{% endblock %}
新建 templates/errors/500.html:
{% extends "base.html" %}
{% block title %}服务器内部错误 - 500{% endblock %}
{% block header %}服务器开小差了...{% endblock %}
{% block content %}
<div style="text-align: center; padding: 40px 0; color: #999;">
<i class="layui-icon" style="font-size: 60px; color: #F5222D;"></i>
<h2 style="margin: 20px 0;">500 - 服务器内部错误</h2>
<p>我们的工程师已收到通知,正在紧急修复</p>
<a href="{{ url_for('main.index') }}" class="layui-btn" style="margin-top: 20px;">
返回首页
</a>
</div>
{% endblock %}
3.3 注册错误处理器
在 app.py 的 create_app 函数中注册:
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
db.init_app(app)
# 注册蓝图
from routes.main import main as main_blueprint
app.register_blueprint(main_blueprint)
# === 新增:错误处理器 ===
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_server_error(e):
# 记录错误日志(见下一节)
app.logger.error(f"Server Error: {e}")
return render_template('errors/500.html'), 500
with app.app_context():
db.create_all()
return app
测试方法 :访问
http://127.0.0.1:5000/nonexistent应显示自定义 404 页。
第四章:记录应用日志
4.1 配置日志
在 create_app 中添加日志配置:
import logging
from logging.handlers import RotatingFileHandler
import os
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# === 日志配置 ===
if not app.debug:
# 生产环境:写入文件
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler(
'logs/todo.log',
maxBytes=10240, # 10KB
backupCount=10
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Todo application startup')
else:
# 开发环境:输出到控制台
logging.basicConfig(level=logging.DEBUG)
# ... 其他初始化代码 ...
4.2 在关键位置添加日志
例如在删除操作中记录:
# routes/main.py
@main.route('/delete/<int:todo_id>', methods=['POST']) # 注意:改为 POST(见下节)
def delete_todo(todo_id):
todo = Todo.query.get_or_404(todo_id)
title = todo.title # 保存标题用于日志
db.session.delete(todo)
db.session.commit()
current_app.logger.info(f"Task deleted: ID={todo_id}, Title='{title}'")
flash('任务已删除', 'info')
return redirect(url_for('main.index'))
注意 :需从
flask导入current_app。
第五章:前端交互体验优化
5.1 回车快速提交任务
在 base.html 的 <script> 中添加:
// 监听回车提交
document.addEventListener('DOMContentLoaded', function() {
const input = document.querySelector('input[name="title"]');
if (input) {
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault(); // 阻止默认换行
this.form.submit(); // 提交表单
}
});
}
});
5.2 删除操作改用 POST + CSRF 保护
为什么?
GET 请求不应修改数据(RESTful 原则),且易被恶意链接利用。
步骤1:修改删除路由为 POST
# routes/main.py
from flask_wtf.csrf import validate_csrf # 用于手动验证 CSRF
@main.route('/delete/<int:todo_id>', methods=['POST'])
def delete_todo(todo_id):
# 手动验证 CSRF(因未使用 WTForms 表单)
try:
validate_csrf(request.form.get('csrf_token'))
except:
flash('无效请求,请重试', 'error')
return redirect(url_for('main.index'))
todo = Todo.query.get_or_404(todo_id)
db.session.delete(todo)
db.session.commit()
flash('任务已删除', 'info')
return redirect(url_for('main.index'))
步骤2:更新删除按钮为表单提交
修改 templates/index.html 中的删除按钮:
<!-- 替换原删除链接 -->
<form method="POST" action="{{ url_for('main.delete_todo', todo_id=todo.id) }}"
style="display:inline;"
onsubmit="return confirm('确定要删除「{{ todo.title }}」吗?')">
{{ form.hidden_tag() }} <!-- 复用表单的 CSRF token -->
<button type="submit" class="layui-btn layui-btn-xs layui-btn-danger">删除</button>
</form>
优势:符合安全规范,防止 CSRF 攻击。
5.3 添加"标记全部完成"功能
后端路由
@main.route('/complete_all', methods=['POST'])
def complete_all():
try:
validate_csrf(request.form.get('csrf_token'))
except:
flash('操作失败', 'error')
return redirect(url_for('main.index'))
Todo.query.update({Todo.done: True})
db.session.commit()
flash('所有任务已标记为完成', 'success')
return redirect(url_for('main.index'))
前端按钮
在任务列表上方添加:
<!-- 在搜索表单下方添加 -->
{% if todos %}
<form method="POST" action="{{ url_for('main.complete_all') }}" style="margin: 10px 0;">
{{ form.hidden_tag() }}
<button type="submit" class="layui-btn layui-btn-sm layui-btn-warm">全部标记完成</button>
</form>
{% endif %}
Layui 颜色扩展 :
.layui-btn-warm需自定义 CSS(橙色):
.layui-btn-warm { background-color: #ff9700 !important; }
第六章:数据库查询优化与分页
6.1 为什么需要分页?
当任务数量超过 1000 条时,一次性加载会导致:
- 页面渲染卡顿
- 内存占用过高
- 数据库查询缓慢
6.2 使用 SQLAlchemy 分页
修改首页路由:
@main.route('/')
def index():
form = TodoForm()
page = request.args.get('page', 1, type=int) # 获取页码
query_str = request.args.get('q', '').strip()
# 构建查询
query = Todo.query
if query_str:
query = query.filter(Todo.title.contains(query_str))
query = query.order_by(Todo.created_at.desc())
# 执行分页(每页10条)
pagination = query.paginate(
page=page,
per_page=10,
error_out=False # 页码超出范围时不报错
)
todos = pagination.items
return render_template(
'index.html',
form=form,
todos=todos,
search_query=query_str,
pagination=pagination # 传递分页对象
)
6.3 在模板中渲染分页
在 templates/index.html 任务列表下方添加:
<!-- 分页组件 -->
{% if pagination.pages > 1 %}
<div style="margin: 20px 0; text-align: center;">
<div class="layui-btn-group">
{% if pagination.has_prev %}
<a href="{{ url_for('main.index', page=pagination.prev_num, q=search_query) }}"
class="layui-btn layui-btn-primary layui-btn-sm">上一页</a>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
{% if p == pagination.page %}
<a class="layui-btn layui-btn-sm" style="background:#1E9FFF;">{{ p }}</a>
{% else %}
<a href="{{ url_for('main.index', page=p, q=search_query) }}"
class="layui-btn layui-btn-primary layui-btn-sm">{{ p }}</a>
{% endif %}
{% else %}
<span class="layui-btn layui-btn-disabled layui-btn-sm">...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('main.index', page=pagination.next_num, q=search_query) }}"
class="layui-btn layui-btn-primary layui-btn-sm">下一页</a>
{% endif %}
</div>
</div>
{% endif %}
效果:自动显示页码导航,支持搜索结果分页。
6.4 为搜索字段添加数据库索引
加速 LIKE '%关键词%' 查询:
# models.py
class Todo(db.Model):
# ... 其他字段 ...
title = db.Column(db.String(100), nullable=False)
# 添加索引
__table_args__ = (db.Index('idx_title', 'title'),)
注意 :SQLite 的
LIKE查询对索引利用有限,但在 MySQL/PostgreSQL 中效果显著。
第七章:代码结构终极优化
7.1 完善应用工厂模式
当前 app.py 已具备工厂雏形,进一步标准化:
# app.py
from flask import Flask
from config import config
from models import db
from forms import csrf # 稍后创建
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# 初始化扩展
db.init_app(app)
csrf.init_app(app) # 初始化 CSRF 保护
# 注册蓝图
from routes.main import main as main_blueprint
app.register_blueprint(main_blueprint)
# 错误处理器
@app.errorhandler(404)
def page_not_found(e):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_server_error(e):
app.logger.error(f"Server Error: {e}")
return render_template('errors/500.html'), 500
# 初始化数据库
with app.app_context():
db.create_all()
return app
7.2 独立 CSRF 保护配置
新建 extensions.py(可选,但更清晰):
# extensions.py
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect()
然后在 forms.py 中:
# forms.py
from flask_wtf import FlaskForm
from extensions import csrf # 改为从这里导入
# ... 其他代码不变 ...
并在 app.py 中:
from extensions import csrf
# ...
csrf.init_app(app)
7.3 最终项目结构
flask-todo-layui/
├── app.py
├── config.py
├── extensions.py # 新增:扩展实例
├── forms.py
├── models.py
├── requirements.txt
├── logs/ # 运行后生成
├── templates/
│ ├── base.html
│ ├── index.html
│ └── errors/
│ ├── 404.html
│ └── 500.html
└── routes/
├── __init__.py
└── main.py