5.1 WTForms基础
5.1.1 安装和配置
# 安装WTForms和Flask-WTF
pip install Flask-WTF
pip install WTForms
pip install email-validator # 邮箱验证
# config.py
import os
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or 'your-secret-key-here'
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600 # CSRF令牌有效期(秒)
WTF_CSRF_SSL_STRICT = True # HTTPS环境下启用
# app.py
from flask import Flask
from flask_wtf.csrf import CSRFProtect
app = Flask(__name__)
app.config.from_object('config.Config')
# 启用CSRF保护
csrf = CSRFProtect(app)
5.1.2 基本表单创建
# forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, PasswordField, SubmitField, SelectField, BooleanField
from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError
from wtforms.widgets import TextArea
class LoginForm(FlaskForm):
"""登录表单"""
username = StringField('用户名', validators=[
DataRequired(message='用户名不能为空'),
Length(min=3, max=20, message='用户名长度必须在3-20个字符之间')
])
password = PasswordField('密码', validators=[
DataRequired(message='密码不能为空'),
Length(min=6, message='密码长度至少6个字符')
])
remember_me = BooleanField('记住我')
submit = SubmitField('登录')
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个字符')
])
password_confirm = PasswordField('确认密码', validators=[
DataRequired(message='请确认密码'),
EqualTo('password', message='两次输入的密码不一致')
])
submit = SubmitField('注册')
def validate_username(self, field):
"""自定义用户名验证"""
# 这里可以检查用户名是否已存在
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已存在')
def validate_email(self, field):
"""自定义邮箱验证"""
if User.query.filter_by(email=field.data).first():
raise ValidationError('邮箱已被注册')
class PostForm(FlaskForm):
"""文章表单"""
title = StringField('标题', validators=[
DataRequired(message='标题不能为空'),
Length(max=100, message='标题长度不能超过100个字符')
])
content = TextAreaField('内容', validators=[
DataRequired(message='内容不能为空'),
Length(min=10, message='内容长度至少10个字符')
], widget=TextArea())
category = SelectField('分类', choices=[
('tech', '技术'),
('life', '生活'),
('travel', '旅行'),
('other', '其他')
], validators=[DataRequired()])
tags = StringField('标签', description='多个标签用逗号分隔')
is_published = BooleanField('立即发布')
submit = SubmitField('保存')
5.1.3 视图函数中使用表单
# views.py
from flask import render_template, request, redirect, url_for, flash, session
from forms import LoginForm, RegisterForm, PostForm
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
remember = form.remember_me.data
# 验证用户
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
session['user_id'] = user.id
session['username'] = user.username
flash('登录成功!', 'success')
# 重定向到原来要访问的页面
next_page = request.args.get('next')
return redirect(next_page or url_for('main.index'))
else:
flash('用户名或密码错误', 'error')
return render_template('auth/login.html', form=form)
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
if form.validate_on_submit():
user = User(
username=form.username.data,
email=form.email.data
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册成功!请登录', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@app.route('/post/create', methods=['GET', 'POST'])
def create_post():
form = PostForm()
if form.validate_on_submit():
post = Post(
title=form.title.data,
content=form.content.data,
category=form.category.data,
is_published=form.is_published.data,
author_id=session['user_id']
)
# 处理标签
if form.tags.data:
tag_names = [tag.strip() for tag in form.tags.data.split(',')]
for tag_name in tag_names:
tag = Tag.query.filter_by(name=tag_name).first()
if not tag:
tag = Tag(name=tag_name)
db.session.add(tag)
post.tags.append(tag)
db.session.add(post)
db.session.commit()
flash('文章创建成功!', 'success')
return redirect(url_for('main.show_post', id=post.id))
return render_template('posts/create.html', form=form)
5.2 表单字段类型
5.2.1 基本字段类型
# forms/fields_demo.py
from flask_wtf import FlaskForm
from wtforms import (
StringField, TextAreaField, PasswordField, HiddenField,
IntegerField, FloatField, DecimalField, BooleanField,
SelectField, SelectMultipleField, RadioField,
DateField, DateTimeField, TimeField,
SubmitField, FileField, MultipleFileField
)
from wtforms.validators import DataRequired, NumberRange, Optional
from wtforms.widgets import TextArea, Select, CheckboxInput
from decimal import Decimal
from datetime import date, datetime, time
class ComprehensiveForm(FlaskForm):
"""综合表单示例"""
# 文本字段
name = StringField('姓名', validators=[DataRequired()])
description = TextAreaField('描述', widget=TextArea())
password = PasswordField('密码')
hidden_value = HiddenField('隐藏值')
# 数字字段
age = IntegerField('年龄', validators=[
Optional(),
NumberRange(min=0, max=150, message='年龄必须在0-150之间')
])
height = FloatField('身高(米)', validators=[
Optional(),
NumberRange(min=0.5, max=3.0, message='身高必须在0.5-3.0米之间')
])
salary = DecimalField('薪资', validators=[Optional()], places=2)
# 布尔字段
is_active = BooleanField('是否激活')
newsletter = BooleanField('订阅邮件')
# 选择字段
gender = SelectField('性别', choices=[
('', '请选择'),
('male', '男'),
('female', '女'),
('other', '其他')
])
hobbies = SelectMultipleField('爱好', choices=[
('reading', '阅读'),
('music', '音乐'),
('sports', '运动'),
('travel', '旅行'),
('cooking', '烹饪')
])
education = RadioField('学历', choices=[
('high_school', '高中'),
('bachelor', '本科'),
('master', '硕士'),
('phd', '博士')
])
# 日期时间字段
birth_date = DateField('出生日期', validators=[Optional()])
appointment = DateTimeField('预约时间', validators=[Optional()])
meeting_time = TimeField('会议时间', validators=[Optional()])
# 文件字段
avatar = FileField('头像')
documents = MultipleFileField('文档')
# 提交按钮
submit = SubmitField('提交')
cancel = SubmitField('取消', render_kw={'formnovalidate': True})
5.2.2 自定义字段
# forms/custom_fields.py
from wtforms import Field, StringField
from wtforms.widgets import TextInput, html_params
from wtforms.validators import ValidationError
import re
class TagListField(Field):
"""标签列表字段"""
widget = TextInput()
def _value(self):
if self.data:
return ', '.join(self.data)
return ''
def process_formdata(self, valuelist):
if valuelist:
self.data = [tag.strip() for tag in valuelist[0].split(',') if tag.strip()]
else:
self.data = []
class ColorField(StringField):
"""颜色选择字段"""
def __init__(self, label=None, validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
self.render_kw = kwargs.get('render_kw', {})
self.render_kw.setdefault('type', 'color')
class PhoneField(StringField):
"""手机号字段"""
def __init__(self, label=None, validators=None, **kwargs):
super().__init__(label, validators, **kwargs)
if validators is None:
validators = []
validators.append(self.validate_phone)
def validate_phone(self, field):
if field.data:
pattern = r'^1[3-9]\d{9}$'
if not re.match(pattern, field.data):
raise ValidationError('请输入有效的手机号码')
class RichTextWidget:
"""富文本编辑器组件"""
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
kwargs.setdefault('class', 'rich-text-editor')
html = f'<textarea {html_params(name=field.name, **kwargs)}>{field._value()}</textarea>'
html += '''
<script>
// 初始化富文本编辑器
if (typeof tinymce !== 'undefined') {
tinymce.init({
selector: '#%s',
height: 300,
plugins: 'advlist autolink lists link image charmap print preview anchor',
toolbar: 'undo redo | formatselect | bold italic backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | help'
});
}
</script>
''' % field.id
return html
class RichTextField(TextAreaField):
"""富文本字段"""
widget = RichTextWidget()
# 使用自定义字段
class BlogPostForm(FlaskForm):
title = StringField('标题', validators=[DataRequired()])
content = RichTextField('内容', validators=[DataRequired()])
tags = TagListField('标签')
color = ColorField('主题色')
author_phone = PhoneField('作者手机')
submit = SubmitField('发布')
5.3 验证器详解
5.3.1 内置验证器
# forms/validators_demo.py
from wtforms import StringField, IntegerField, EmailField, URLField, DateField
from wtforms.validators import (
DataRequired, Optional, Length, NumberRange, Email, URL,
Regexp, AnyOf, NoneOf, MacAddress, IPAddress, UUID,
InputRequired, EqualTo, ValidationError
)
from flask_wtf import FlaskForm
import re
class ValidatorsDemo(FlaskForm):
"""验证器演示表单"""
# 基本验证器
required_field = StringField('必填字段', validators=[
DataRequired(message='此字段为必填项')
])
optional_field = StringField('可选字段', validators=[
Optional() # 允许为空
])
input_required = StringField('输入必需', validators=[
InputRequired(message='必须输入内容') # 不允许空字符串
])
# 长度验证
username = StringField('用户名', validators=[
DataRequired(),
Length(min=3, max=20, message='用户名长度必须在%(min)d-%(max)d个字符之间')
])
# 数字范围验证
age = IntegerField('年龄', validators=[
Optional(),
NumberRange(min=0, max=150, message='年龄必须在%(min)d-%(max)d之间')
])
# 邮箱验证
email = EmailField('邮箱', validators=[
DataRequired(),
Email(message='请输入有效的邮箱地址')
])
# URL验证
website = URLField('网站', validators=[
Optional(),
URL(message='请输入有效的URL地址')
])
# 正则表达式验证
phone = StringField('手机号', validators=[
Optional(),
Regexp(r'^1[3-9]\d{9}$', message='请输入有效的手机号码')
])
# 选项验证
status = StringField('状态', validators=[
AnyOf(['active', 'inactive', 'pending'], message='状态必须是active、inactive或pending之一')
])
# 排除验证
username_exclude = StringField('用户名', validators=[
NoneOf(['admin', 'root', 'system'], message='用户名不能是系统保留字')
])
# MAC地址验证
mac_address = StringField('MAC地址', validators=[
Optional(),
MacAddress(message='请输入有效的MAC地址')
])
# IP地址验证
ip_address = StringField('IP地址', validators=[
Optional(),
IPAddress(message='请输入有效的IP地址')
])
# UUID验证
uuid_field = StringField('UUID', validators=[
Optional(),
UUID(message='请输入有效的UUID')
])
# 密码确认
password = StringField('密码', validators=[DataRequired()])
password_confirm = StringField('确认密码', validators=[
DataRequired(),
EqualTo('password', message='两次输入的密码不一致')
])
5.3.2 自定义验证器
# forms/custom_validators.py
from wtforms.validators import ValidationError
import re
from datetime import date, datetime
def unique_username(form, field):
"""验证用户名唯一性"""
from models import User
if User.query.filter_by(username=field.data).first():
raise ValidationError('用户名已存在')
def strong_password(form, field):
"""强密码验证"""
password = field.data
if len(password) < 8:
raise ValidationError('密码长度至少8位')
if not re.search(r'[A-Z]', password):
raise ValidationError('密码必须包含至少一个大写字母')
if not re.search(r'[a-z]', password):
raise ValidationError('密码必须包含至少一个小写字母')
if not re.search(r'\d', password):
raise ValidationError('密码必须包含至少一个数字')
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
raise ValidationError('密码必须包含至少一个特殊字符')
def valid_age(form, field):
"""年龄验证"""
if field.data and (field.data < 0 or field.data > 150):
raise ValidationError('年龄必须在0-150之间')
def future_date(form, field):
"""验证日期必须是未来日期"""
if field.data and field.data <= date.today():
raise ValidationError('日期必须是未来日期')
def business_hours(form, field):
"""验证时间必须在工作时间内"""
if field.data:
hour = field.data.hour
if hour < 9 or hour > 17:
raise ValidationError('时间必须在工作时间内(9:00-17:00)')
class FileSize:
"""文件大小验证器"""
def __init__(self, max_size, message=None):
self.max_size = max_size
if not message:
message = f'文件大小不能超过{self.format_size(max_size)}'
self.message = message
def __call__(self, form, field):
if field.data:
# 获取文件大小
field.data.seek(0, 2) # 移动到文件末尾
size = field.data.tell()
field.data.seek(0) # 重置文件指针
if size > self.max_size:
raise ValidationError(self.message)
def format_size(self, size):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f'{size:.1f}{unit}'
size /= 1024
return f'{size:.1f}TB'
class FileExtension:
"""文件扩展名验证器"""
def __init__(self, allowed_extensions, message=None):
self.allowed_extensions = [ext.lower() for ext in allowed_extensions]
if not message:
message = f'只允许上传{"、".join(allowed_extensions)}格式的文件'
self.message = message
def __call__(self, form, field):
if field.data and field.data.filename:
filename = field.data.filename.lower()
if not any(filename.endswith(ext) for ext in self.allowed_extensions):
raise ValidationError(self.message)
# 使用自定义验证器
class UserRegistrationForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(),
Length(min=3, max=20),
unique_username
])
password = StringField('密码', validators=[
DataRequired(),
strong_password
])
age = IntegerField('年龄', validators=[
Optional(),
valid_age
])
appointment_date = DateField('预约日期', validators=[
Optional(),
future_date
])
meeting_time = TimeField('会议时间', validators=[
Optional(),
business_hours
])
avatar = FileField('头像', validators=[
Optional(),
FileSize(max_size=2*1024*1024), # 2MB
FileExtension(['jpg', 'jpeg', 'png', 'gif'])
])
5.4 文件上传处理
5.4.1 基本文件上传
# forms/upload_forms.py
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import StringField, TextAreaField, SubmitField, MultipleFileField
from wtforms.validators import DataRequired, Length
class SingleFileUploadForm(FlaskForm):
"""单文件上传表单"""
title = StringField('标题', validators=[DataRequired(), Length(max=100)])
description = TextAreaField('描述')
file = FileField('文件', validators=[
FileRequired(message='请选择文件'),
FileAllowed(['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'],
message='只允许上传图片或文档文件')
])
submit = SubmitField('上传')
class MultipleFileUploadForm(FlaskForm):
"""多文件上传表单"""
title = StringField('标题', validators=[DataRequired()])
files = MultipleFileField('文件', validators=[
FileRequired(message='请至少选择一个文件')
])
submit = SubmitField('上传')
class ImageUploadForm(FlaskForm):
"""图片上传表单"""
title = StringField('图片标题', validators=[DataRequired()])
alt_text = StringField('替代文本')
image = FileField('图片', validators=[
FileRequired(message='请选择图片'),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], message='只允许上传图片文件')
])
submit = SubmitField('上传图片')
5.4.2 文件上传处理
# utils/file_handler.py
import os
import uuid
from datetime import datetime
from PIL import Image
from werkzeug.utils import secure_filename
from flask import current_app
class FileUploadHandler:
"""文件上传处理器"""
def __init__(self, upload_folder=None):
self.upload_folder = upload_folder or current_app.config.get('UPLOAD_FOLDER', 'uploads')
self.ensure_upload_folder()
def ensure_upload_folder(self):
"""确保上传目录存在"""
if not os.path.exists(self.upload_folder):
os.makedirs(self.upload_folder)
def generate_filename(self, original_filename):
"""生成安全的文件名"""
# 获取文件扩展名
ext = os.path.splitext(original_filename)[1].lower()
# 生成唯一文件名
filename = f"{uuid.uuid4().hex}{ext}"
return filename
def get_file_path(self, filename, subfolder=None):
"""获取文件完整路径"""
if subfolder:
folder = os.path.join(self.upload_folder, subfolder)
if not os.path.exists(folder):
os.makedirs(folder)
return os.path.join(folder, filename)
return os.path.join(self.upload_folder, filename)
def save_file(self, file, subfolder=None, custom_filename=None):
"""保存文件"""
if not file or not file.filename:
return None
# 生成文件名
if custom_filename:
filename = secure_filename(custom_filename)
else:
filename = self.generate_filename(file.filename)
# 获取保存路径
file_path = self.get_file_path(filename, subfolder)
# 保存文件
file.save(file_path)
return {
'filename': filename,
'original_filename': file.filename,
'file_path': file_path,
'relative_path': os.path.relpath(file_path, self.upload_folder),
'size': os.path.getsize(file_path),
'upload_time': datetime.now()
}
def save_image(self, image_file, subfolder='images', sizes=None):
"""保存图片并生成缩略图"""
if not image_file or not image_file.filename:
return None
# 保存原图
result = self.save_file(image_file, subfolder)
if not result:
return None
# 生成缩略图
if sizes:
result['thumbnails'] = self.generate_thumbnails(
result['file_path'], sizes
)
return result
def generate_thumbnails(self, image_path, sizes):
"""生成缩略图"""
thumbnails = {}
try:
with Image.open(image_path) as img:
# 获取原图信息
original_format = img.format
for size_name, (width, height) in sizes.items():
# 创建缩略图
thumbnail = img.copy()
thumbnail.thumbnail((width, height), Image.Resampling.LANCZOS)
# 生成缩略图文件名
base_name = os.path.splitext(os.path.basename(image_path))[0]
ext = os.path.splitext(image_path)[1]
thumbnail_filename = f"{base_name}_{size_name}{ext}"
thumbnail_path = os.path.join(os.path.dirname(image_path), thumbnail_filename)
# 保存缩略图
thumbnail.save(thumbnail_path, format=original_format)
thumbnails[size_name] = {
'filename': thumbnail_filename,
'path': thumbnail_path,
'size': (thumbnail.width, thumbnail.height)
}
except Exception as e:
current_app.logger.error(f'生成缩略图失败: {e}')
return thumbnails
def delete_file(self, file_path):
"""删除文件"""
try:
if os.path.exists(file_path):
os.remove(file_path)
return True
except Exception as e:
current_app.logger.error(f'删除文件失败: {e}')
return False
def get_file_info(self, file_path):
"""获取文件信息"""
if not os.path.exists(file_path):
return None
stat = os.stat(file_path)
return {
'size': stat.st_size,
'created_time': datetime.fromtimestamp(stat.st_ctime),
'modified_time': datetime.fromtimestamp(stat.st_mtime),
'is_image': self.is_image_file(file_path)
}
def is_image_file(self, file_path):
"""检查是否为图片文件"""
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
ext = os.path.splitext(file_path)[1].lower()
return ext in image_extensions
5.4.3 文件上传视图
# views/upload_views.py
from flask import render_template, request, redirect, url_for, flash, jsonify, current_app
from forms.upload_forms import SingleFileUploadForm, MultipleFileUploadForm, ImageUploadForm
from utils.file_handler import FileUploadHandler
from models import UploadedFile, db
@app.route('/upload/single', methods=['GET', 'POST'])
def single_file_upload():
form = SingleFileUploadForm()
if form.validate_on_submit():
handler = FileUploadHandler()
# 保存文件
result = handler.save_file(form.file.data, subfolder='documents')
if result:
# 保存到数据库
uploaded_file = UploadedFile(
title=form.title.data,
description=form.description.data,
filename=result['filename'],
original_filename=result['original_filename'],
file_path=result['relative_path'],
file_size=result['size'],
upload_time=result['upload_time']
)
db.session.add(uploaded_file)
db.session.commit()
flash('文件上传成功!', 'success')
return redirect(url_for('main.file_list'))
else:
flash('文件上传失败!', 'error')
return render_template('upload/single.html', form=form)
@app.route('/upload/multiple', methods=['GET', 'POST'])
def multiple_file_upload():
form = MultipleFileUploadForm()
if form.validate_on_submit():
handler = FileUploadHandler()
uploaded_files = []
for file in form.files.data:
if file and file.filename:
result = handler.save_file(file, subfolder='documents')
if result:
uploaded_file = UploadedFile(
title=f"{form.title.data} - {result['original_filename']}",
filename=result['filename'],
original_filename=result['original_filename'],
file_path=result['relative_path'],
file_size=result['size'],
upload_time=result['upload_time']
)
uploaded_files.append(uploaded_file)
if uploaded_files:
db.session.add_all(uploaded_files)
db.session.commit()
flash(f'成功上传{len(uploaded_files)}个文件!', 'success')
return redirect(url_for('main.file_list'))
else:
flash('没有文件被上传!', 'error')
return render_template('upload/multiple.html', form=form)
@app.route('/upload/image', methods=['GET', 'POST'])
def image_upload():
form = ImageUploadForm()
if form.validate_on_submit():
handler = FileUploadHandler()
# 定义缩略图尺寸
thumbnail_sizes = {
'small': (150, 150),
'medium': (300, 300),
'large': (800, 600)
}
# 保存图片
result = handler.save_image(
form.image.data,
subfolder='images',
sizes=thumbnail_sizes
)
if result:
# 保存到数据库
uploaded_file = UploadedFile(
title=form.title.data,
alt_text=form.alt_text.data,
filename=result['filename'],
original_filename=result['original_filename'],
file_path=result['relative_path'],
file_size=result['size'],
upload_time=result['upload_time'],
is_image=True,
thumbnails=result.get('thumbnails', {})
)
db.session.add(uploaded_file)
db.session.commit()
flash('图片上传成功!', 'success')
return redirect(url_for('main.image_gallery'))
else:
flash('图片上传失败!', 'error')
return render_template('upload/image.html', form=form)
# AJAX文件上传
@app.route('/upload/ajax', methods=['POST'])
def ajax_file_upload():
if 'file' not in request.files:
return jsonify({'error': '没有文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': '没有选择文件'}), 400
# 验证文件类型
allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'}
if not ('.' in file.filename and
file.filename.rsplit('.', 1)[1].lower() in allowed_extensions):
return jsonify({'error': '不支持的文件类型'}), 400
handler = FileUploadHandler()
result = handler.save_file(file, subfolder='ajax_uploads')
if result:
return jsonify({
'success': True,
'filename': result['filename'],
'original_filename': result['original_filename'],
'size': result['size'],
'url': url_for('static', filename=f"uploads/{result['relative_path']}")
})
else:
return jsonify({'error': '文件上传失败'}), 500
5.5 动态表单
5.5.1 动态字段表单
# forms/dynamic_forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField, IntegerField, FieldList, FormField, SubmitField
from wtforms.validators import DataRequired, NumberRange
class ContactForm(FlaskForm):
"""联系人子表单"""
name = StringField('姓名', validators=[DataRequired()])
phone = StringField('电话')
email = StringField('邮箱')
relationship = SelectField('关系', choices=[
('family', '家人'),
('friend', '朋友'),
('colleague', '同事'),
('other', '其他')
])
class DynamicContactsForm(FlaskForm):
"""动态联系人表单"""
name = StringField('姓名', validators=[DataRequired()])
contacts = FieldList(FormField(ContactForm), min_entries=1, max_entries=10)
submit = SubmitField('保存')
class SkillForm(FlaskForm):
"""技能子表单"""
name = StringField('技能名称', validators=[DataRequired()])
level = SelectField('熟练程度', choices=[
('1', '初级'),
('2', '中级'),
('3', '高级'),
('4', '专家')
], coerce=int)
years = IntegerField('经验年限', validators=[
NumberRange(min=0, max=50)
])
class ResumeForm(FlaskForm):
"""简历表单"""
name = StringField('姓名', validators=[DataRequired()])
title = StringField('职位', validators=[DataRequired()])
skills = FieldList(FormField(SkillForm), min_entries=1)
submit = SubmitField('保存简历')
# 动态生成表单
def create_survey_form(questions):
"""根据问题列表动态创建调查表单"""
class SurveyForm(FlaskForm):
pass
for i, question in enumerate(questions):
field_name = f'question_{i}'
if question['type'] == 'text':
field = StringField(question['title'], validators=[DataRequired()])
elif question['type'] == 'select':
choices = [(opt['value'], opt['label']) for opt in question['options']]
field = SelectField(question['title'], choices=choices, validators=[DataRequired()])
elif question['type'] == 'number':
field = IntegerField(question['title'], validators=[DataRequired()])
else:
field = StringField(question['title'])
setattr(SurveyForm, field_name, field)
# 添加提交按钮
setattr(SurveyForm, 'submit', SubmitField('提交调查'))
return SurveyForm
5.5.2 动态表单视图
# views/dynamic_views.py
from flask import render_template, request, redirect, url_for, flash, jsonify
from forms.dynamic_forms import DynamicContactsForm, ResumeForm, create_survey_form
@app.route('/contacts', methods=['GET', 'POST'])
def manage_contacts():
form = DynamicContactsForm()
if form.validate_on_submit():
# 处理联系人数据
contacts_data = []
for contact_form in form.contacts:
if contact_form.name.data: # 只保存有姓名的联系人
contacts_data.append({
'name': contact_form.name.data,
'phone': contact_form.phone.data,
'email': contact_form.email.data,
'relationship': contact_form.relationship.data
})
# 保存到数据库或处理数据
flash(f'成功保存{len(contacts_data)}个联系人!', 'success')
return redirect(url_for('main.contacts_list'))
return render_template('forms/contacts.html', form=form)
@app.route('/resume', methods=['GET', 'POST'])
def create_resume():
form = ResumeForm()
if form.validate_on_submit():
# 处理简历数据
skills_data = []
for skill_form in form.skills:
if skill_form.name.data:
skills_data.append({
'name': skill_form.name.data,
'level': skill_form.level.data,
'years': skill_form.years.data or 0
})
resume_data = {
'name': form.name.data,
'title': form.title.data,
'skills': skills_data
}
# 保存简历
flash('简历创建成功!', 'success')
return redirect(url_for('main.resume_view'))
return render_template('forms/resume.html', form=form)
@app.route('/survey/<int:survey_id>', methods=['GET', 'POST'])
def take_survey(survey_id):
# 从数据库获取调查问题
survey = Survey.query.get_or_404(survey_id)
questions = survey.questions
# 动态创建表单
SurveyForm = create_survey_form(questions)
form = SurveyForm()
if form.validate_on_submit():
# 处理调查答案
answers = []
for i, question in enumerate(questions):
field_name = f'question_{i}'
answer_value = getattr(form, field_name).data
answers.append({
'question_id': question['id'],
'answer': answer_value
})
# 保存答案
survey_response = SurveyResponse(
survey_id=survey_id,
answers=answers
)
db.session.add(survey_response)
db.session.commit()
flash('调查提交成功!', 'success')
return redirect(url_for('main.survey_thanks'))
return render_template('forms/survey.html', form=form, survey=survey)
# AJAX动态添加字段
@app.route('/add_contact_field')
def add_contact_field():
"""添加联系人字段"""
form = DynamicContactsForm()
# 添加新的联系人字段
form.contacts.append_entry()
# 返回新字段的HTML
field_html = render_template('forms/contact_field.html',
field=form.contacts[-1],
index=len(form.contacts)-1)
return jsonify({'html': field_html})
@app.route('/add_skill_field')
def add_skill_field():
"""添加技能字段"""
form = ResumeForm()
form.skills.append_entry()
field_html = render_template('forms/skill_field.html',
field=form.skills[-1],
index=len(form.skills)-1)
return jsonify({'html': field_html})
5.5.3 动态表单模板
<!-- templates/forms/contacts.html -->
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>联系人管理</h1>
<form method="POST" id="contactsForm">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<div class="text-danger">
{% for error in form.name.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div id="contacts-container">
<h3>联系人列表</h3>
{% for contact_field in form.contacts %}
{% include 'forms/contact_field.html' %}
{% endfor %}
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="addContact">添加联系人</button>
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
<script>
document.getElementById('addContact').addEventListener('click', function() {
fetch('/add_contact_field')
.then(response => response.json())
.then(data => {
document.getElementById('contacts-container').insertAdjacentHTML('beforeend', data.html);
})
.catch(error => console.error('Error:', error));
});
// 删除联系人字段
function removeContact(button) {
button.closest('.contact-field').remove();
}
</script>
{% endblock %}
<!-- templates/forms/contact_field.html -->
<div class="contact-field border p-3 mb-3">
<div class="row">
<div class="col-md-3">
{{ contact_field.name.label(class="form-label") }}
{{ contact_field.name(class="form-control") }}
</div>
<div class="col-md-3">
{{ contact_field.phone.label(class="form-label") }}
{{ contact_field.phone(class="form-control") }}
</div>
<div class="col-md-3">
{{ contact_field.email.label(class="form-label") }}
{{ contact_field.email(class="form-control") }}
</div>
<div class="col-md-2">
{{ contact_field.relationship.label(class="form-label") }}
{{ contact_field.relationship(class="form-control") }}
</div>
<div class="col-md-1">
<button type="button" class="btn btn-danger btn-sm mt-4" onclick="removeContact(this)">
删除
</button>
</div>
</div>
</div>
本章小结
本章详细介绍了Flask的表单处理与验证,包括:
- WTForms基础:安装配置、基本表单创建、CSRF保护
- 表单字段:各种字段类型、自定义字段、字段属性
- 验证器:内置验证器、自定义验证器、验证逻辑
- 文件上传:单文件上传、多文件上传、图片处理
- 动态表单:动态字段、表单生成、AJAX交互
掌握这些技能能够帮助你构建功能完善、用户体验良好的表单系统。
下一章预告
下一章我们将学习数据库集成,包括:
- SQLAlchemy ORM
- 数据模型设计
- 数据库迁移
- 查询优化
- 数据库连接池
练习题
- 表单验证:创建一个用户注册表单,包含复杂的验证规则
- 文件上传:实现一个支持多种文件类型的上传系统
- 动态表单:创建一个可以动态添加/删除字段的表单
- 自定义验证器:实现一个验证身份证号码的验证器
- AJAX表单:使用AJAX实现无刷新表单提交