引言:为什么需要完整的项目实战?
“学完了Flask基础、扩展、蓝图、性能优化...但真正要自己从头搭建一个项目时,还是不知道从哪里开始!”
这可能是许多学习者的共同感受。理论知识就像散落的珍珠,需要通过项目实战这根线串起来,才能变成美丽的项链。
在前面的14篇教程中,我们已经系统学习了:
- Flask基础与路由系统
- 数据库操作与ORM
- 模板引擎与前端交互
- 扩展生态与模块化设计
- 性能优化与高并发处理
现在是时候把这些知识整合起来,完成一个真正可用的项目了。
我们即将构建的个人博客系统将包含:
- 用户系统:注册、登录、权限控制
- 文章管理:发布、编辑、删除、分类、标签
- 评论系统:用户评论、回复、审核
- 部署上线:从开发到生产环境的完整流程
这个项目不仅仅是一个教程示例,而是一个可以直接用于个人博客的完整系统。学完后,你完全可以基于这个代码搭建自己的技术博客!
第一章:项目规划与环境搭建
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>© {{ 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 %}
现在我们的后台管理系统已经完成。管理员可以:
- 管理用户(查看、编辑、删除)
- 管理文章(发布、编辑、删除)
- 管理分类和标签
- 审核评论
- 查看系统统计信息