python Web开发从入门到精通(十五)从零到一!手把手教你用Flask开发完整个人博客(上)

4 阅读12分钟

引言:为什么需要完整的项目实战?

“学完了Flask基础、扩展、蓝图、性能优化...但真正要自己从头搭建一个项目时,还是不知道从哪里开始!”

这可能是许多学习者的共同感受。理论知识就像散落的珍珠,需要通过项目实战这根线串起来,才能变成美丽的项链。

在前面的14篇教程中,我们已经系统学习了:

  • Flask基础与路由系统
  • 数据库操作与ORM
  • 模板引擎与前端交互
  • 扩展生态与模块化设计
  • 性能优化与高并发处理

现在是时候把这些知识整合起来,完成一个真正可用的项目了。

我们即将构建的个人博客系统将包含:

  1. 用户系统:注册、登录、权限控制
  2. 文章管理:发布、编辑、删除、分类、标签
  3. 评论系统:用户评论、回复、审核
  4. 部署上线:从开发到生产环境的完整流程

这个项目不仅仅是一个教程示例,而是一个可以直接用于个人博客的完整系统。学完后,你完全可以基于这个代码搭建自己的技术博客!

第一章:项目规划与环境搭建

1.1 需求分析与功能设计

在开始编码之前,我们需要明确系统的功能边界。一个好的项目规划可以避免后期频繁重构。

核心功能模块:

模块

功能点

技术实现

用户认证

注册、登录、登出、个人资料

Flask-Login、WTForms

文章管理

发布、编辑、删除、查看

SQLAlchemy、Jinja2

分类标签

分类管理、标签系统

多对多关系

评论系统

发表评论、回复、嵌套展示

自引用关系

后台管理

文章审核、用户管理

Flask-Admin

部署运维

生产环境配置、监控

Gunicorn、Nginx

技术栈选择:

  • 后端框架:Flask 3.0+
  • 数据库:SQLite(开发)/ PostgreSQL(生产)
  • ORM:Flask-SQLAlchemy
  • 用户认证:Flask-Login + Flask-Bcrypt
  • 表单处理:Flask-WTF
  • 前端模板:Jinja2 + Bootstrap 5
  • 部署服务器:Gunicorn + Nginx
  • 进程管理:Supervisor

1.2 项目结构设计

合理的项目结构是大型应用的基础。我们采用Flask推荐的应用工厂模式蓝图模块化设计。

flask-blog/
│
├── app/                    # 应用主目录
│   ├── __init__.py        # 应用工厂函数
│   ├── models.py          # 数据模型定义
│   ├── forms.py           # 表单类定义
│   ├── routes/            # 路由模块(蓝图)
│   │   ├── __init__.py
│   │   ├── auth.py        # 认证相关路由
│   │   ├── blog.py        # 博客相关路由
│   │   └── admin.py       # 后台管理路由
│   ├── templates/         # 模板文件
│   │   ├── base.html      # 基础模板
│   │   ├── auth/          # 认证相关模板
│   │   ├── blog/          # 博客相关模板
│   │   └── admin/         # 后台管理模板
│   ├── static/            # 静态文件
│   │   ├── css/
│   │   ├── js/
│   │   └── images/
│   └── utils/             # 工具函数
│       ├── __init__.py
│       └── helpers.py
│
├── migrations/            # 数据库迁移脚本
├── tests/                # 测试文件
├── config.py             # 配置文件
├── requirements.txt      # 依赖列表
├── run.py                # 开发启动脚本
└── wsgi.py               # 生产环境WSGI入口

1.3 开发环境配置

让我们从零开始搭建开发环境。

步骤1:创建项目目录

# 创建项目目录
mkdir flask-blog && cd flask-blog

# 创建虚拟环境
python -m venv venv

# 激活虚拟环境
# Windows:
venv\Scripts\activate
# macOS/Linux:
source venv/bin/activate

步骤2:安装基础依赖

创建requirements.txt文件:

Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Flask-WTF==1.2.1
Flask-Bcrypt==1.0.1
Flask-Migrate==4.0.5
Flask-Admin==1.6.1
python-dotenv==1.0.0
Werkzeug==3.0.1

安装依赖:

pip install -r requirements.txt

步骤3:创建配置文件

创建config.py文件,支持不同环境配置:

import os
from datetime import timedelta

class Config:
    """基础配置类"""
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-key-change-in-production'
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    # 文件上传配置
    MAX_CONTENT_LENGTH = 16 * 1024 * 1024  # 16MB
    UPLOAD_FOLDER = 'app/static/uploads'
    ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
    
    # 分页配置
    POSTS_PER_PAGE = 10
    COMMENTS_PER_PAGE = 20
    
    # 会话配置
    PERMANENT_SESSION_LIFETIME = timedelta(days=7)
    REMEMBER_COOKIE_DURATION = timedelta(days=30)

class DevelopmentConfig(Config):
    """开发环境配置"""
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'sqlite:///flask_blog_dev.db'

class TestingConfig(Config):
    """测试环境配置"""
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite:///flask_blog_test.db'
    WTF_CSRF_ENABLED = False

class ProductionConfig(Config):
    """生产环境配置"""
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///flask_blog_prod.db'

# 配置映射
config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

步骤4:创建应用工厂

创建app/__init__.py

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from flask_migrate import Migrate
from flask_admin import Admin

# 创建扩展实例(但暂不初始化)
db = SQLAlchemy()
login_manager = LoginManager()
bcrypt = Bcrypt()
migrate = Migrate()
admin = Admin(name='博客后台', template_mode='bootstrap4')

def create_app(config_name='default'):
    """应用工厂函数"""
    from config import config
    
    # 创建Flask应用实例
    app = Flask(__name__)
    
    # 加载配置
    app.config.from_object(config[config_name])
    
    # 初始化扩展
    db.init_app(app)
    login_manager.init_app(app)
    bcrypt.init_app(app)
    migrate.init_app(app, db)
    admin.init_app(app)
    
    # 配置登录管理器
    login_manager.login_view = 'auth.login'
    login_manager.login_message = '请先登录以访问此页面'
    login_manager.login_message_category = 'info'
    
    # 注册蓝图
    from app.routes.auth import auth_bp
    from app.routes.blog import blog_bp
    from app.routes.admin import admin_bp
    
    app.register_blueprint(auth_bp, url_prefix='/auth')
    app.register_blueprint(blog_bp)
    app.register_blueprint(admin_bp, url_prefix='/admin')
    
    # 创建数据库表
    with app.app_context():
        db.create_all()
    
    return app

现在,我们的项目骨架已经搭建完成。接下来,我们将进入核心功能的实现。

第二章:用户认证系统

2.1 用户数据模型

用户系统是博客的核心,我们需要设计安全的用户模型。

创建app/models.py

from datetime import datetime
from flask_login import UserMixin
from app import db, bcrypt

class User(UserMixin, db.Model):
    """用户模型"""
    __tablename__ = 'users'
    
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True, nullable=False)
    email = db.Column(db.String(120), unique=True, index=True, nullable=False)
    password_hash = db.Column(db.String(128))
    is_admin = db.Column(db.Boolean, default=False)
    is_active = db.Column(db.Boolean, default=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    # 关系定义
    posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
    comments = db.relationship('Comment', backref='author', lazy='dynamic', cascade='all, delete-orphan')
    
    def __repr__(self):
        return f'<User {self.username}>'
    
    @property
    def password(self):
        """密码属性(只写)"""
        raise AttributeError('password is not a readable attribute')
    
    @password.setter
    def password(self, password):
        """设置密码(自动哈希)"""
        self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
    
    def verify_password(self, password):
        """验证密码"""
        return bcrypt.check_password_hash(self.password_hash, password)
    
    def to_dict(self):
        """转换为字典(用于API)"""
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            'is_admin': self.is_admin,
            'created_at': self.created_at.isoformat() if self.created_at else None
        }

2.2 登录表单设计

创建app/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, TextAreaField, SelectField
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
from app.models import User

class LoginForm(FlaskForm):
    """登录表单"""
    username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=64)])
    password = PasswordField('密码', validators=[DataRequired()])
    remember_me = BooleanField('记住我')
    submit = SubmitField('登录')

class RegistrationForm(FlaskForm):
    """注册表单"""
    username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=64)])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    password = PasswordField('密码', validators=[
        DataRequired(),
        Length(min=6, message='密码至少6个字符')
    ])
    password2 = PasswordField('确认密码', validators=[
        DataRequired(),
        EqualTo('password', message='两次密码不一致')
    ])
    submit = SubmitField('注册')
    
    def validate_username(self, username):
        """验证用户名唯一性"""
        user = User.query.filter_by(username=username.data).first()
        if user:
            raise ValidationError('该用户名已被使用,请选择其他用户名')
    
    def validate_email(self, email):
        """验证邮箱唯一性"""
        user = User.query.filter_by(email=email.data).first()
        if user:
            raise ValidationError('该邮箱已被注册,请使用其他邮箱')

class ProfileForm(FlaskForm):
    """个人资料表单"""
    username = StringField('用户名', validators=[DataRequired(), Length(min=3, max=64)])
    email = StringField('邮箱', validators=[DataRequired(), Email()])
    bio = TextAreaField('个人简介', validators=[Length(max=500)])
    submit = SubmitField('更新资料')

2.3 认证路由实现

创建app/routes/auth.py

from flask import render_template, flash, redirect, url_for, request
from flask_login import login_user, logout_user, current_user, login_required
from app import db
from app.models import User
from app.forms import LoginForm, RegistrationForm, ProfileForm
from app.routes import auth_bp

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    """用户登录"""
    if current_user.is_authenticated:
        return redirect(url_for('blog.index'))
    
    form = LoginForm()
    if form.validate_on_submit():
        # 查找用户(支持用户名或邮箱登录)
        user = User.query.filter(
            (User.username == form.username.data) | 
            (User.email == form.username.data)
        ).first()
        
        if user and user.verify_password(form.password.data):
            # 验证密码并登录
            login_user(user, remember=form.remember_me.data)
            
            # 记录登录IP和时间
            user.last_login_ip = request.remote_addr
            user.last_login_at = datetime.utcnow()
            db.session.commit()
            
            # 重定向到登录前页面或首页
            next_page = request.args.get('next')
            if not next_page or not next_page.startswith('/'):
                next_page = url_for('blog.index')
            
            flash('登录成功!', 'success')
            return redirect(next_page)
        else:
            flash('用户名或密码错误', 'danger')
    
    return render_template('auth/login.html', title='登录', form=form)

@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
    """用户注册"""
    if current_user.is_authenticated:
        return redirect(url_for('blog.index'))
    
    form = RegistrationForm()
    if form.validate_on_submit():
        # 创建新用户
        user = User(
            username=form.username.data,
            email=form.email.data,
            password=form.password.data  # 这里会触发password.setter
        )
        
        db.session.add(user)
        db.session.commit()
        
        flash('注册成功!请登录', 'success')
        return redirect(url_for('auth.login'))
    
    return render_template('auth/register.html', title='注册', form=form)

@auth_bp.route('/logout')
@login_required
def logout():
    """用户登出"""
    logout_user()
    flash('已退出登录', 'info')
    return redirect(url_for('blog.index'))

@auth_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
    """个人资料"""
    form = ProfileForm()
    
    if form.validate_on_submit():
        # 更新用户信息
        current_user.username = form.username.data
        current_user.email = form.email.data
        current_user.bio = form.bio.data
        
        db.session.commit()
        flash('资料更新成功', 'success')
        return redirect(url_for('auth.profile'))
    elif request.method == 'GET':
        # 初始化表单数据
        form.username.data = current_user.username
        form.email.data = current_user.email
        form.bio.data = current_user.bio
    
    return render_template('auth/profile.html', title='个人资料', form=form)

2.4 用户认证模板

创建app/templates/auth/login.html

{% extends "base.html" %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card shadow">
            <div class="card-header bg-primary text-white">
                <h4 class="mb-0">用户登录</h4>
            </div>
            <div class="card-body">
                <form method="POST" action="">
                    {{ form.hidden_tag() }}
                    
                    <div class="mb-3">
                        {{ form.username.label(class="form-label") }}
                        {{ form.username(class="form-control") }}
                        {% if form.username.errors %}
                            <div class="text-danger">
                                {% for error in form.username.errors %}
                                    <small>{{ error }}</small>
                                {% endfor %}
                            </div>
                        {% endif %}
                    </div>
                    
                    <div class="mb-3">
                        {{ form.password.label(class="form-label") }}
                        {{ form.password(class="form-control") }}
                        {% if form.password.errors %}
                            <div class="text-danger">
                                {% for error in form.password.errors %}
                                    <small>{{ error }}</small>
                                {% endfor %}
                            </div>
                        {% endif %}
                    </div>
                    
                    <div class="mb-3 form-check">
                        {{ form.remember_me(class="form-check-input") }}
                        {{ form.remember_me.label(class="form-check-label") }}
                    </div>
                    
                    <div class="d-grid gap-2">
                        {{ form.submit(class="btn btn-primary") }}
                    </div>
                </form>
                
                <hr>
                
                <div class="text-center">
                    <p class="mb-0">还没有账号?</p>
                    <a href="{{ url_for('auth.register') }}" class="btn btn-outline-primary mt-2">
                        立即注册
                    </a>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

类似地创建注册页面和个人资料页面。

2.5 用户加载器

app/__init__.py中添加用户加载器:

@login_manager.user_loader
def load_user(user_id):
    """加载用户(Flask-Login要求)"""
    from app.models import User
    return User.query.get(int(user_id))

至此,我们的用户认证系统已经完成。用户现在可以注册、登录、查看和编辑个人资料。

第三章:博客内容管理系统

现在我们已经有了用户系统,接下来要构建博客的核心功能:文章管理、分类标签和评论系统。

3.1 创建工具函数

为了代码复用,我们先创建一些工具函数。这些函数用于生成slug、格式化时间、转换Markdown等。

app/utils/helpers.py中:

import re
import hashlib
from datetime import datetime
from urllib.parse import urlparse
from markdown import markdown
from bleach import clean, linkify

def generate_slug(text):
    """生成URL友好的slug"""
    slug = text.lower()
    slug = re.sub(r'\s+', '-', slug)
    slug = re.sub(r'[^\w\-]', '', slug)
    slug = re.sub(r'\-+', '-', slug)
    slug = slug.strip('-')
    
    if not slug:
        slug = f"post-{datetime.now().strftime('%Y%m%d%H%M%S')}"
    
    return slug

def md_to_html(markdown_text, safe_mode=True):
    """将Markdown转换为安全的HTML"""
    if not markdown_text:
        return ''
    
    html = markdown(markdown_text, 
                   extensions=['extra', 'codehilite', 'tables'])
    
    if safe_mode:
        allowed_tags = [
            'a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
            'em', 'i', 'li', 'ol', 'strong', 'ul', 'p', 'br',
            'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'hr',
            'table', 'thead', 'tbody', 'tr', 'th', 'td',
            'img', 'div', 'span'
        ]
        
        allowed_attributes = {
            'a': ['href', 'title', 'target'],
            'img': ['src', 'alt', 'title', 'width', 'height'],
            '*': ['class', 'id', 'style']
        }
        
        html = clean(html, tags=allowed_tags, attributes=allowed_attributes)
        html = linkify(html, callbacks=[])
    
    return html

3.2 文章表单设计

现在我们来创建文章和评论的表单。在app/forms.py中添加:

from wtforms import BooleanField, StringField, TextAreaField, SelectField, SubmitField
from wtforms.validators import DataRequired, Length

class PostForm(FlaskForm):
    """文章表单"""
    title = StringField('标题', validators=[DataRequired(), Length(min=5, max=200)])
    slug = StringField('URL别名', validators=[DataRequired(), Length(min=3, max=200)])
    content = TextAreaField('内容', validators=[DataRequired()], render_kw={"rows": 15})
    excerpt = TextAreaField('摘要', validators=[Length(max=300)], render_kw={"rows": 3})
    category = SelectField('分类', coerce=int, validators=[DataRequired()])
    tags = StringField('标签', validators=[Length(max=200)])
    is_published = BooleanField('立即发布')
    submit = SubmitField('发布文章')
    
    def __init__(self, *args, **kwargs):
        super(PostForm, self).__init__(*args, **kwargs)
        # 动态加载分类选项
        from app.models import Category
        self.category.choices = [(c.id, c.name) for c in Category.query.order_by('name').all()]

class CommentForm(FlaskForm):
    """评论表单"""
    content = TextAreaField('评论内容', validators=[
        DataRequired(),
        Length(min=3, max=1000)
    ], render_kw={"rows": 4, "placeholder": "请输入您的评论..."})
    submit = SubmitField('发表评论')

3.3 博客路由实现

现在实现博客的核心功能路由。创建app/routes/blog.py

from flask import render_template, flash, redirect, url_for, request, abort, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, or_
from app import db
from app.models import Post, Category, Tag, Comment
from app.forms import PostForm, CommentForm
from app.routes import blog_bp
from app.utils.helpers import generate_slug, md_to_html

@blog_bp.route('/')
def index():
    """博客首页"""
    page = request.args.get('page', 1, type=int)
    
    # 查询已发布的文章
    query = Post.query.filter_by(is_published=True).order_by(desc(Post.created_at))
    
    # 按分类筛选
    category_id = request.args.get('category_id', type=int)
    if category_id:
        query = query.filter_by(category_id=category_id)
    
    # 按标签筛选
    tag_id = request.args.get('tag_id', type=int)
    if tag_id:
        query = query.filter(Post.tags.any(id=tag_id))
    
    # 搜索功能
    search = request.args.get('q', '').strip()
    if search:
        query = query.filter(
            or_(
                Post.title.ilike(f'%{search}%'),
                Post.content.ilike(f'%{search}%'),
                Post.excerpt.ilike(f'%{search}%')
            )
        )
    
    # 分页
    posts = query.paginate(page=page, per_page=10, error_out=False)
    
    # 获取分类和热门标签
    categories = Category.query.order_by(Category.name).all()
    popular_tags = Tag.query.limit(20).all()
    
    return render_template('blog/index.html', 
                         title='首页',
                         posts=posts,
                         categories=categories,
                         popular_tags=popular_tags,
                         search=search)

@blog_bp.route('/post/<slug>')
def post_detail(slug):
    """文章详情页"""
    post = Post.query.filter_by(slug=slug, is_published=True).first_or_404()
    
    # 增加浏览次数
    post.increment_view()
    
    # 评论表单
    form = CommentForm()
    
    # 获取评论(已审核的)
    comments = Comment.query.filter_by(
        post_id=post.id,
        is_approved=True,
        parent_id=None
    ).order_by(desc(Comment.created_at)).all()
    
    # 获取相关文章(同一分类)
    related_posts = Post.query.filter_by(
        category_id=post.category_id,
        is_published=True
    ).filter(Post.id != post.id).order_by(desc(Post.created_at)).limit(5).all()
    
    return render_template('blog/post_detail.html',
                         title=post.title,
                         post=post,
                         form=form,
                         comments=comments,
                         related_posts=related_posts)

@blog_bp.route('/post/new', methods=['GET', 'POST'])
@login_required
def new_post():
    """创建新文章"""
    form = PostForm()
    
    if form.validate_on_submit():
        # 创建文章
        post = Post(
            title=form.title.data,
            slug=form.slug.data or generate_slug(form.title.data),
            content=form.content.data,
            excerpt=form.excerpt.data,
            is_published=form.is_published.data,
            author_id=current_user.id,
            category_id=form.category.data
        )
        
        # 处理标签
        if form.tags.data:
            tag_names = [t.strip() for t 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, slug=generate_slug(tag_name))
                    db.session.add(tag)
                post.tags.append(tag)
        
        db.session.add(post)
        db.session.commit()
        
        flash('文章发布成功!', 'success')
        return redirect(url_for('blog.post_detail', slug=post.slug))
    
    # 设置默认slug
    if not form.slug.data and request.args.get('title'):
        form.slug.data = generate_slug(request.args.get('title'))
    
    return render_template('blog/edit_post.html',
                         title='发布新文章',
                         form=form)

@blog_bp.route('/post/<slug>/edit', methods=['GET', 'POST'])
@login_required
def edit_post(slug):
    """编辑文章"""
    post = Post.query.filter_by(slug=slug).first_or_404()
    
    # 权限检查:只有作者或管理员可以编辑
    if post.author_id != current_user.id and not current_user.is_admin:
        abort(403)
    
    form = PostForm()
    
    if form.validate_on_submit():
        post.title = form.title.data
        post.slug = form.slug.data
        post.content = form.content.data
        post.excerpt = form.excerpt.data
        post.is_published = form.is_published.data
        post.category_id = form.category.data
        
        # 更新标签
        post.tags = []
        if form.tags.data:
            tag_names = [t.strip() for t 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, slug=generate_slug(tag_name))
                    db.session.add(tag)
                post.tags.append(tag)
        
        db.session.commit()
        flash('文章更新成功!', 'success')
        return redirect(url_for('blog.post_detail', slug=post.slug))
    
    # 初始化表单数据
    if request.method == 'GET':
        form.title.data = post.title
        form.slug.data = post.slug
        form.content.data = post.content
        form.excerpt.data = post.excerpt
        form.category.data = post.category_id
        form.is_published.data = post.is_published
        form.tags.data = ', '.join([tag.name for tag in post.tags])
    
    return render_template('blog/edit_post.html',
                         title='编辑文章',
                         form=form,
                         post=post)

@blog_bp.route('/post/<slug>/delete', methods=['POST'])
@login_required
def delete_post(slug):
    """删除文章"""
    post = Post.query.filter_by(slug=slug).first_or_404()
    
    # 权限检查:只有作者或管理员可以删除
    if post.author_id != current_user.id and not current_user.is_admin:
        abort(403)
    
    db.session.delete(post)
    db.session.commit()
    
    flash('文章已删除', 'success')
    return redirect(url_for('blog.index'))

@blog_bp.route('/comment/<int:post_id>', methods=['POST'])
@login_required
def add_comment(post_id):
    """添加评论"""
    post = Post.query.get_or_404(post_id)
    
    form = CommentForm()
    if form.validate_on_submit():
        comment = Comment(
            content=form.content.data,
            author_id=current_user.id,
            post_id=post_id
        )
        
        db.session.add(comment)
        db.session.commit()
        
        flash('评论已提交,等待审核', 'info')
    
    return redirect(url_for('blog.post_detail', slug=post.slug))

3.4 模板文件创建

博客系统需要几个核心的模板文件:

1. 基础模板 (app/templates/base.html)

这是所有页面的基础,包含导航栏、页脚和基本的HTML结构。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}{% endblock %} - Flask博客</title>
    
    <!-- Bootstrap 5 CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    
    <!-- 自定义CSS -->
    <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
    
    <!-- 代码高亮样式 -->
    <link href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-okaidia.min.css" rel="stylesheet">
    
    {% block head %}{% endblock %}
</head>
<body>
    <!-- 导航栏 -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('blog.index') }}">Flask博客</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('blog.index') }}">首页</a>
                    </li>
                    {% if current_user.is_authenticated %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('blog.new_post') }}">写文章</a>
                    </li>
                    {% endif %}
                </ul>
                <ul class="navbar-nav">
                    {% if current_user.is_authenticated %}
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
                            {{ current_user.username }}
                        </a>
                        <ul class="dropdown-menu">
                            <li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">个人资料</a></li>
                            {% if current_user.is_admin %}
                            <li><hr class="dropdown-divider"></li>
                            <li><a class="dropdown-item" href="{{ url_for('admin.index') }}">后台管理</a></li>
                            {% endif %}
                            <li><hr class="dropdown-divider"></li>
                            <li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">退出登录</a></li>
                        </ul>
                    </li>
                    {% else %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
                    </li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>

    <!-- 消息提示 -->
    <div class="container mt-3">
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show" role="alert">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% endfor %}
            {% endif %}
        {% endwith %}
    </div>

    <!-- 主要内容 -->
    <main class="container my-4">
        {% block content %}{% endblock %}
    </main>

    <!-- 页脚 -->
    <footer class="bg-dark text-white py-4 mt-5">
        <div class="container">
            <div class="row">
                <div class="col-md-6">
                    <h5>Flask博客</h5>
                    <p>分享编程技术、开发经验和学习心得</p>
                </div>
                <div class="col-md-6 text-md-end">
                    <p>&copy; {{ now.year }} Flask博客. 保留所有权利.</p>
                    <p>Powered by Flask, Bootstrap 5 and ❤️</p>
                </div>
            </div>
        </div>
    </footer>

    <!-- Bootstrap 5 JS Bundle with Popper -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    
    <!-- Prism 代码高亮 -->
    <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
    
    {% block scripts %}{% endblock %}
</body>
</html>

2. 首页模板 (app/templates/blog/index.html)

显示文章列表,支持分页、搜索和分类筛选。

{% extends "base.html" %}

{% block title %}首页{% endblock %}

{% block content %}
<div class="row">
    <!-- 主内容区域 -->
    <div class="col-lg-8">
        <!-- 搜索框 -->
        <div class="card mb-4">
            <div class="card-body">
                <form action="{{ url_for('blog.index') }}" method="get">
                    <div class="input-group">
                        <input type="text" class="form-control" name="q" placeholder="搜索文章..." value="{{ search or '' }}">
                        <button class="btn btn-primary" type="submit">搜索</button>
                    </div>
                </form>
            </div>
        </div>

        <!-- 文章列表 -->
        {% if posts.items %}
            {% for post in posts.items %}
            <div class="card mb-4">
                <div class="card-body">
                    <h2 class="card-title">
                        <a href="{{ url_for('blog.post_detail', slug=post.slug) }}" class="text-decoration-none">
                            {{ post.title }}
                        </a>
                    </h2>
                    
                    <p class="card-text">
                        {% if post.excerpt %}
                            {{ post.excerpt }}
                        {% else %}
                            {{ post.content|truncate(200) }}
                        {% endif %}
                    </p>
                    
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <span class="text-muted">
                                <i class="bi bi-person"></i> {{ post.author.username }}
                                <i class="bi bi-calendar ms-3"></i> {{ post.created_at.strftime('%Y-%m-%d') }}
                                <i class="bi bi-eye ms-3"></i> {{ post.view_count }}
                            </span>
                        </div>
                        <a href="{{ url_for('blog.post_detail', slug=post.slug) }}" class="btn btn-outline-primary">
                            阅读全文 →
                        </a>
                    </div>
                </div>
            </div>
            {% endfor %}
            
            <!-- 分页 -->
            <nav aria-label="文章分页">
                <ul class="pagination justify-content-center">
                    {% if posts.has_prev %}
                    <li class="page-item">
                        <a class="page-link" href="{{ url_for('blog.index', page=posts.prev_num, q=search, category_id=request.args.get('category_id'), tag_id=request.args.get('tag_id')) }}">
                            上一页
                        </a>
                    </li>
                    {% endif %}
                    
                    {% for page_num in posts.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
                        {% if page_num %}
                            {% if posts.page == page_num %}
                            <li class="page-item active">
                                <span class="page-link">{{ page_num }}</span>
                            </li>
                            {% else %}
                            <li class="page-item">
                                <a class="page-link" href="{{ url_for('blog.index', page=page_num, q=search, category_id=request.args.get('category_id'), tag_id=request.args.get('tag_id')) }}">
                                    {{ page_num }}
                                </a>
                            </li>
                            {% endif %}
                        {% else %}
                            <li class="page-item disabled">
                                <span class="page-link">...</span>
                            </li>
                        {% endif %}
                    {% endfor %}
                    
                    {% if posts.has_next %}
                    <li class="page-item">
                        <a class="page-link" href="{{ url_for('blog.index', page=posts.next_num, q=search, category_id=request.args.get('category_id'), tag_id=request.args.get('tag_id')) }}">
                            下一页
                        </a>
                    </li>
                    {% endif %}
                </ul>
            </nav>
        {% else %}
            <div class="alert alert-info">
                {% if search %}
                    没有找到与 "{{ search }}" 相关的文章。
                {% else %}
                    暂无文章,赶紧写第一篇吧!
                {% endif %}
            </div>
        {% endif %}
    </div>

    <!-- 侧边栏 -->
    <div class="col-lg-4">
        <!-- 分类 -->
        <div class="card mb-4">
            <div class="card-header">
                <h5 class="mb-0">文章分类</h5>
            </div>
            <div class="card-body">
                <ul class="list-unstyled">
                    <li>
                        <a href="{{ url_for('blog.index') }}" class="text-decoration-none d-flex justify-content-between py-1">
                            全部
                            <span class="badge bg-primary">{{ Post.query.filter_by(is_published=True).count() }}</span>
                        </a>
                    </li>
                    {% for category in categories %}
                    <li>
                        <a href="{{ url_for('blog.index', category_id=category.id) }}" class="text-decoration-none d-flex justify-content-between py-1">
                            {{ category.name }}
                            <span class="badge bg-secondary">{{ category.posts.filter_by(is_published=True).count() }}</span>
                        </a>
                    </li>
                    {% endfor %}
                </ul>
            </div>
        </div>

        <!-- 热门标签 -->
        <div class="card mb-4">
            <div class="card-header">
                <h5 class="mb-0">热门标签</h5>
            </div>
            <div class="card-body">
                <div class="d-flex flex-wrap gap-2">
                    {% for tag in popular_tags %}
                    <a href="{{ url_for('blog.index', tag_id=tag.id) }}" class="badge bg-info text-decoration-none">
                        {{ tag.name }}
                    </a>
                    {% endfor %}
                </div>
            </div>
        </div>

        <!-- 热门文章 -->
        <div class="card">
            <div class="card-header">
                <h5 class="mb-0">热门文章</h5>
            </div>
            <div class="card-body">
                {% set hot_posts = Post.query.filter_by(is_published=True).order_by(desc(Post.view_count)).limit(5).all() %}
                {% if hot_posts %}
                    <div class="list-group list-group-flush">
                        {% for post in hot_posts %}
                        <a href="{{ url_for('blog.post_detail', slug=post.slug) }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
                            <span class="text-truncate" style="max-width: 200px;">{{ post.title }}</span>
                            <span class="badge bg-primary rounded-pill">{{ post.view_count }}</span>
                        </a>
                        {% endfor %}
                    </div>
                {% else %}
                    <p class="text-muted mb-0">暂无热门文章</p>
                {% endif %}
            </div>
        </div>
    </div>
</div>
{% endblock %}

现在我们的博客系统已经具备了用户认证和文章管理功能。用户可以注册登录、发布文章、查看文章列表和详情。

第四章:后台管理系统

对于一个完整的博客系统,后台管理是必不可少的。我们使用Flask-Admin来快速构建功能强大的后台管理界面。

4.1 安装Flask-Admin

确保你的requirements.txt中已经包含Flask-Admin:

Flask-Admin==1.6.1

4.2 创建管理界面

创建app/routes/admin.py

from flask import redirect, url_for, request, flash
from flask_login import current_user, login_required
from flask_admin import AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from app import admin, db
from app.models import User, Post, Category, Tag, Comment
from app.routes import admin_bp

# 自定义首页视图
class MyAdminIndexView(AdminIndexView):
    @expose('/')
    @login_required
    def index(self):
        # 检查管理员权限
        if not current_user.is_admin:
            flash('您没有管理员权限', 'danger')
            return redirect(url_for('blog.index'))
        
        # 统计信息
        stats = {
            'total_users': User.query.count(),
            'total_posts': Post.query.count(),
            'published_posts': Post.query.filter_by(is_published=True).count(),
            'total_comments': Comment.query.count(),
            'approved_comments': Comment.query.filter_by(is_approved=True).count()
        }
        
        return self.render('admin/index.html', stats=stats)

# 自定义ModelView(添加权限控制)
class MyModelView(ModelView):
    def is_accessible(self):
        return current_user.is_authenticated and current_user.is_admin
    
    def inaccessible_callback(self, name, **kwargs):
        return redirect(url_for('auth.login', next=request.url))

# 用户管理视图
class UserAdminView(MyModelView):
    column_list = ['id', 'username', 'email', 'is_admin', 'is_active', 'created_at']
    column_searchable_list = ['username', 'email']
    column_filters = ['is_admin', 'is_active', 'created_at']
    
    form_columns = ['username', 'email', 'password', 'is_admin', 'is_active']
    form_extra_fields = {
        'password': PasswordField('密码')
    }
    
    def on_model_change(self, form, model, is_created):
        if form.password.data:
            model.password = form.password.data
        return super().on_model_change(form, model, is_created)

# 文章管理视图
class PostAdminView(MyModelView):
    column_list = ['id', 'title', 'author', 'category', 'is_published', 'view_count', 'created_at']
    column_searchable_list = ['title', 'content']
    column_filters = ['is_published', 'category.name', 'created_at']
    
    # 使用关系字段
    column_details_list = ['title', 'content', 'author.username', 'category.name', 'tags', 'created_at', 'updated_at']
    
    form_columns = ['title', 'slug', 'content', 'excerpt', 'author', 'category', 'tags', 'is_published']
    
    def on_form_prefill(self, form, id):
        # 表单预填充处理
        pass
    
    def after_model_change(self, form, model, is_created):
        # 模型更改后的处理
        pass

# 评论管理视图
class CommentAdminView(MyModelView):
    column_list = ['id', 'content', 'author', 'post', 'is_approved', 'created_at']
    column_searchable_list = ['content']
    column_filters = ['is_approved', 'created_at']
    
    form_columns = ['content', 'author', 'post', 'is_approved']
    
    # 批量操作
    action_disallowed_list = ['delete']
    
    @action('approve', '审核通过', '确定要审核通过选中的评论吗?')
    def action_approve(self, ids):
        try:
            for comment_id in ids:
                comment = Comment.query.get(comment_id)
                if comment:
                    comment.is_approved = True
            db.session.commit()
            flash(f'成功审核通过 {len(ids)} 条评论', 'success')
        except Exception as e:
            flash(f'审核失败: {str(e)}', 'danger')

# 注册管理视图
admin.index_view = MyAdminIndexView()
admin.add_view(UserAdminView(User, db.session, name='用户管理', category='系统管理'))
admin.add_view(PostAdminView(Post, db.session, name='文章管理', category='内容管理'))
admin.add_view(MyModelView(Category, db.session, name='分类管理', category='内容管理'))
admin.add_view(MyModelView(Tag, db.session, name='标签管理', category='内容管理'))
admin.add_view(CommentAdminView(Comment, db.session, name='评论管理', category='内容管理'))

# 管理路由
@admin_bp.route('/')
@login_required
def admin_index():
    return redirect(url_for('admin.index'))

4.3 管理界面模板

创建app/templates/admin/index.html

{% extends 'admin/master.html' %}

{% block body %}
<div class="container-fluid">
    <!-- 统计卡片 -->
    <div class="row mb-4">
        <div class="col-md-3">
            <div class="card text-white bg-primary">
                <div class="card-body">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <h6 class="card-title mb-0">用户总数</h6>
                            <h2 class="mb-0">{{ stats.total_users }}</h2>
                        </div>
                        <i class="bi bi-people fs-1 opacity-50"></i>
                    </div>
                </div>
            </div>
        </div>
        
        <div class="col-md-3">
            <div class="card text-white bg-success">
                <div class="card-body">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <h6 class="card-title mb-0">文章总数</h6>
                            <h2 class="mb-0">{{ stats.total_posts }}</h2>
                        </div>
                        <i class="bi bi-file-text fs-1 opacity-50"></i>
                    </div>
                </div>
            </div>
        </div>
        
        <div class="col-md-3">
            <div class="card text-white bg-info">
                <div class="card-body">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <h6 class="card-title mb-0">已发布文章</h6>
                            <h2 class="mb-0">{{ stats.published_posts }}</h2>
                        </div>
                        <i class="bi bi-check-circle fs-1 opacity-50"></i>
                    </div>
                </div>
            </div>
        </div>
        
        <div class="col-md-3">
            <div class="card text-white bg-warning">
                <div class="card-body">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <h6 class="card-title mb-0">评论总数</h6>
                            <h2 class="mb-0">{{ stats.total_comments }}</h2>
                        </div>
                        <i class="bi bi-chat-dots fs-1 opacity-50"></i>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <!-- 快速操作 -->
    <div class="row mb-4">
        <div class="col-12">
            <div class="card">
                <div class="card-header">
                    <h5 class="mb-0">快速操作</h5>
                </div>
                <div class="card-body">
                    <div class="d-flex gap-3">
                        <a href="{{ url_for('postadmin.create_view') }}" class="btn btn-primary">
                            <i class="bi bi-plus-circle me-2"></i>发布新文章
                        </a>
                        <a href="{{ url_for('commentadmin.index_view') }}" class="btn btn-info">
                            <i class="bi bi-chat me-2"></i>管理评论
                        </a>
                        <a href="{{ url_for('useradmin.index_view') }}" class="btn btn-warning">
                            <i class="bi bi-people me-2"></i>管理用户
                        </a>
                        <a href="{{ url_for('blog.index') }}" class="btn btn-secondary">
                            <i class="bi bi-house me-2"></i>返回博客
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <!-- 最近活动 -->
    <div class="row">
        <div class="col-md-6">
            <div class="card">
                <div class="card-header">
                    <h5 class="mb-0">最新文章</h5>
                </div>
                <div class="card-body">
                    {% set recent_posts = Post.query.order_by(desc(Post.created_at)).limit(5).all() %}
                    {% if recent_posts %}
                        <div class="list-group list-group-flush">
                            {% for post in recent_posts %}
                            <a href="{{ url_for('postadmin.details_view', id=post.id) }}" class="list-group-item list-group-item-action">
                                <div class="d-flex justify-content-between align-items-center">
                                    <span class="text-truncate" style="max-width: 250px;">
                                        {{ post.title }}
                                    </span>
                                    <small class="text-muted">
                                        {{ post.created_at.strftime('%m-%d %H:%M') }}
                                    </small>
                                </div>
                            </a>
                            {% endfor %}
                        </div>
                    {% else %}
                        <p class="text-muted mb-0">暂无文章</p>
                    {% endif %}
                </div>
            </div>
        </div>
        
        <div class="col-md-6">
            <div class="card">
                <div class="card-header">
                    <h5 class="mb-0">最新评论</h5>
                </div>
                <div class="card-body">
                    {% set recent_comments = Comment.query.order_by(desc(Comment.created_at)).limit(5).all() %}
                    {% if recent_comments %}
                        <div class="list-group list-group-flush">
                            {% for comment in recent_comments %}
                            <a href="{{ url_for('commentadmin.details_view', id=comment.id) }}" class="list-group-item list-group-item-action">
                                <div class="d-flex justify-content-between align-items-center">
                                    <span class="text-truncate" style="max-width: 250px;">
                                        {{ comment.content[:50] }}...
                                    </span>
                                    <small class="text-muted">
                                        {{ comment.created_at.strftime('%m-%d %H:%M') }}
                                    </small>
                                </div>
                            </a>
                            {% endfor %}
                        </div>
                    {% else %}
                        <p class="text-muted mb-0">暂无评论</p>
                    {% endif %}
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">

<style>
.card {
    border: none;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    transition: transform 0.3s;
}
.card:hover {
    transform: translateY(-2px);
}
</style>
{% endblock %}

现在我们的后台管理系统已经完成。管理员可以:

  1. 管理用户(查看、编辑、删除)
  2. 管理文章(发布、编辑、删除)
  3. 管理分类和标签
  4. 审核评论
  5. 查看系统统计信息