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的表单处理与验证,包括:

  1. WTForms基础:安装配置、基本表单创建、CSRF保护
  2. 表单字段:各种字段类型、自定义字段、字段属性
  3. 验证器:内置验证器、自定义验证器、验证逻辑
  4. 文件上传:单文件上传、多文件上传、图片处理
  5. 动态表单:动态字段、表单生成、AJAX交互

掌握这些技能能够帮助你构建功能完善、用户体验良好的表单系统。

下一章预告

下一章我们将学习数据库集成,包括:

  • SQLAlchemy ORM
  • 数据模型设计
  • 数据库迁移
  • 查询优化
  • 数据库连接池

练习题

  1. 表单验证:创建一个用户注册表单,包含复杂的验证规则
  2. 文件上传:实现一个支持多种文件类型的上传系统
  3. 动态表单:创建一个可以动态添加/删除字段的表单
  4. 自定义验证器:实现一个验证身份证号码的验证器
  5. AJAX表单:使用AJAX实现无刷新表单提交