目標:零基礎也能從頭搭建一個支持文章管理、評論、分類標簽、搜索、用戶登錄的博客系統
技術棧:Flask + SQLite + SQLAlchemy + Jinja2 + HTML/CSS + Flask-Login
開發工具:VSCode
學習重點:MVC 模式、數據庫操作、會話管理、表單處理
一、項目概述
本項目是一個基于 Python Web 框架 Flask 構建的輕量級個人博客系統,旨在為開發者提供一個功能完整、結構清晰、易于學習和擴展的 Web 應用范例。系統采用 MVC 設計模式 組織代碼結構,使用 SQLite 作為數據庫,結合 HTML/CSS 前端模板 實現用戶友好的交互界面,完整實現了博客核心功能模塊。
該系統不僅具備實用價值,更注重教學意義,特別適合 Python Web 開發初學者理解 Web 請求響應機制、數據庫操作、用戶會話管理與前后端交互流程。
二、技術棧
類別 | 技術 |
---|---|
后端框架 | Flask (輕量級 Python Web 框架) |
數據庫 | SQLite(嵌入式數據庫,無需額外服務) |
ORM | Flask-SQLAlchemy(對象關系映射,簡化數據庫操作) |
用戶認證 | Flask-Login(管理用戶登錄狀態與會話) |
前端技術 | HTML5 + CSS3 + Jinja2 模板引擎 |
安全機制 | 密碼哈希(Werkzeug.security)、CSRF 防護(基礎) |
開發工具 | VSCode、Python 虛擬環境 |
三、核心功能
文章管理
- 支持文章的創建、編輯、刪除與查看詳情
- 文章包含標題、內容、發布時間、作者信息
分類與標簽系統
- 每篇文章可歸屬一個分類(如“技術”、“生活”)
- 支持多標簽管理(如“Python”、“Flask”),便于內容組織與檢索
用戶評論功能
- 登錄用戶可在文章頁發表評論
- 評論按時間排序展示,增強互動性
全文搜索
- 支持通過關鍵詞在文章標題和內容中進行模糊搜索
- 搜索結果實時展示,提升用戶體驗
用戶系統與會話管理
- 用戶注冊與登錄功能
- 基于 Flask-Login 的會話管理,確保安全訪問控制
- 權限控制:僅文章作者可編輯或刪除自己的文章
響應式前端界面
- 使用原生 HTML/CSS 構建簡潔美觀的頁面布局
- 支持導航菜單、消息提示、表單驗證等基礎交互
四、架構設計(MVC 模式)
系統嚴格遵循 MVC(Model-View-Controller)設計模式,實現關注點分離:
- Model(模型層):由
models.py
定義數據模型(User、Post、Comment、Category、Tag),通過 SQLAlchemy 映射到 SQLite 數據庫。 - View(視圖層):使用 Jinja2 模板引擎在
templates/
目錄下渲染 HTML 頁面,實現動態內容展示。 - Controller(控制器層):
routes.py
中的路由函數處理 HTTP 請求,調用模型進行數據操作,并返回對應視圖。
五、項目特點
- ? 零依賴外部服務:使用 SQLite,無需安裝數據庫服務器
- ? 開箱即用:提供完整代碼與依賴文件,一鍵運行
- ? 學習友好:代碼結構清晰,注釋詳盡,適合初學者理解 Web 開發全流程
- ? 可擴展性強:模塊化設計,便于后續集成 Markdown 編輯器、分頁、REST API 等功能
- ? 安全基礎:用戶密碼加密存儲,防止明文泄露
📁 項目目錄結構
/blog├── app.py # 主程序入口├── models.py # 數據模型定義├── routes.py # 路由與控制器邏輯├── config.py # 配置文件├── requirements.txt # 依賴包列表├── instance/│ └── blog.db # 自動生成的 SQLite 數據庫├── templates/ # HTML 模板│ ├── base.html # 布局模板│ ├── index.html # 首頁│ ├── login.html # 登錄頁│ ├── create_post.html # 發布文章│ ├── post.html # 文章詳情│ └── register.html # 注冊頁(可選)└── static/└── style.css # 樣式文件
? 第一步:環境準備
1. 創建項目文件夾
mkdir blog && cd blog
2. 創建虛擬環境
python -m venv venv
# Windows
venv\Scripts\activate
# macOS/Linux
source venv/bin/activate
3. 安裝依賴
創建 requirements.txt
文件:
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Werkzeug==3.0.3
安裝:
pip install -r requirements.txt
? 第二步:配置文件 config.py
# config.py
import osclass Config:SECRET_KEY = 'your-secret-key-here-change-it' # 用于 session 加密SQLALCHEMY_DATABASE_URI = 'sqlite:///blog.db'SQLALCHEMY_TRACK_MODIFICATIONS = FalseDATABASE_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'instance', 'blog.db')
? 第三步:數據庫模型 models.py
# models.py
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetimedb = SQLAlchemy()# 關聯表:文章-標簽(多對多)
post_tags = db.Table('post_tags',db.Column('post_id', db.Integer, db.ForeignKey('post.id')),db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'))
)class User(UserMixin, db.Model):id = db.Column(db.Integer, primary_key=True)username = db.Column(db.String(80), unique=True, nullable=False)password = db.Column(db.String(120), nullable=False)posts = db.relationship('Post', backref='author', lazy=True)comments = db.relationship('Comment', backref='author', lazy=True)class Category(db.Model):id = db.Column(db.Integer, primary_key=True)name = db.Column(db.String(50), unique=True, nullable=False)posts = db.relationship('Post', backref='category', lazy=True)class Tag(db.Model):id = db.Column(db.Integer, primary_key=True)name = db.Column(db.String(50), unique=True, nullable=False)class Post(db.Model):id = db.Column(db.Integer, primary_key=True)title = db.Column(db.String(120), nullable=False)content = db.Column(db.Text, nullable=False)created_at = db.Column(db.DateTime, default=datetime.utcnow)updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)category_id = db.Column(db.Integer, db.ForeignKey('category.id'))tags = db.relationship('Tag', secondary=post_tags, backref='posts')comments = db.relationship('Comment', backref='post', lazy=True)class Comment(db.Model):id = db.Column(db.Integer, primary_key=True)content = db.Column(db.Text, nullable=False)created_at = db.Column(db.DateTime, default=datetime.utcnow)post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
? 第四步:主程序 app.py
# app.py
from flask import Flask
from models import db
from routes import bp
from config import Config
from flask_login import LoginManagerdef create_app():app = Flask(__name__)app.config.from_object(Config)# 初始化數據庫db.init_app(app)# 創建 instance 文件夾和數據庫import osif not os.path.exists('instance'):os.makedirs('instance')with app.app_context():db.create_all()# 初始化登錄管理login_manager = LoginManager()login_manager.login_view = 'bp.login'login_manager.init_app(app)from models import User@login_manager.user_loaderdef load_user(user_id):return User.query.get(int(user_id))# 注冊藍圖app.register_blueprint(bp)return appif __name__ == '__main__':app = create_app()app.run(debug=True)
? 第五步:路由與控制器 routes.py
# routes.py
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_user, logout_user, login_required, current_user
from models import db, User, Post, Category, Tag, Comment
from werkzeug.security import generate_password_hash, check_password_hashbp = Blueprint('bp', __name__)@bp.route('/')
def index():search = request.args.get('q')if search:posts = Post.query.filter((Post.title.contains(search)) | (Post.content.contains(search))).order_by(Post.created_at.desc()).all()else:posts = Post.query.order_by(Post.created_at.desc()).all()categories = Category.query.all()return render_template('index.html', posts=posts, categories=categories)@bp.route('/post/<int:id>')
def post(id):post = Post.query.get_or_404(id)return render_template('post.html', post=post)@bp.route('/post/create', methods=['GET', 'POST'])
@login_required
def create_post():if request.method == 'POST':title = request.form['title']content = request.form['content']category_id = request.form.get('category_id')tag_names = request.form.get('tags', '').split(',')# 獲取或創建分類category = Noneif category_id:category = Category.query.get(category_id)# 處理標簽tags = []for name in tag_names:name = name.strip()if name:tag = Tag.query.filter_by(name=name).first()if not tag:tag = Tag(name=name)db.session.add(tag)tags.append(tag)post = Post(title=title,content=content,category=category,tags=tags,author=current_user)db.session.add(post)db.session.commit()flash('文章發布成功!')return redirect(url_for('bp.index'))categories = Category.query.all()return render_template('create_post.html', categories=categories)@bp.route('/post/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_post(id):post = Post.query.get_or_404(id)if post.author != current_user:flash('你沒有權限編輯此文章。')return redirect(url_for('bp.post', id=id))if request.method == 'POST':post.title = request.form['title']post.content = request.form['content']post.category_id = request.form.get('category_id')# 更新標簽tag_names = request.form.get('tags', '').split(',')post.tags.clear()for name in tag_names:name = name.strip()if name:tag = Tag.query.filter_by(name=name).first()if not tag:tag = Tag(name=name)db.session.add(tag)post.tags.append(tag)db.session.commit()flash('文章已更新!')return redirect(url_for('bp.post', id=id))categories = Category.query.all()tag_str = ', '.join([t.name for t in post.tags])return render_template('create_post.html', post=post, categories=categories, tag_str=tag_str)@bp.route('/post/<int:id>/delete', methods=['POST'])
@login_required
def delete_post(id):post = Post.query.get_or_404(id)if post.author != current_user:flash('你沒有權限刪除此文章。')return redirect(url_for('bp.post', id=id))db.session.delete(post)db.session.commit()flash('文章已刪除。')return redirect(url_for('bp.index'))@bp.route('/comment/<int:post_id>', methods=['POST'])
@login_required
def add_comment(post_id):content = request.form['content']post = Post.query.get_or_404(post_id)comment = Comment(content=content, post=post, author=current_user)db.session.add(comment)db.session.commit()flash('評論已發布!')return redirect(url_for('bp.post', id=post_id))@bp.route('/login', methods=['GET', 'POST'])
def login():if request.method == 'POST':username = request.form['username']password = request.form['password']user = User.query.filter_by(username=username).first()if user and check_password_hash(user.password, password):login_user(user)flash('登錄成功!')return redirect(url_for('bp.index'))else:flash('用戶名或密碼錯誤。')return render_template('login.html')@bp.route('/register', methods=['GET', 'POST'])
def register():if request.method == 'POST':username = request.form['username']password = request.form['password']if User.query.filter_by(username=username).first():flash('用戶名已存在。')else:hashed = generate_password_hash(password)user = User(username=username, password=hashed)db.session.add(user)db.session.commit()flash('注冊成功,請登錄。')return redirect(url_for('bp.login'))return render_template('register.html')@bp.route('/logout')
@login_required
def logout():logout_user()flash('已退出登錄。')return redirect(url_for('bp.index'))
? 第六步:HTML 模板
1. 基礎布局 templates/base.html
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{% block title %}我的博客{% endblock %}</title><link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body><header><h1><a href="{{ url_for('bp.index') }}">我的博客</a></h1><nav>{% if current_user.is_authenticated %}<span>歡迎, {{ current_user.username }}!</span><a href="{{ url_for('bp.create_post') }}">發布文章</a><a href="{{ url_for('bp.logout') }}">退出</a>{% else %}<a href="{{ url_for('bp.login') }}">登錄</a><a href="{{ url_for('bp.register') }}">注冊</a>{% endif %}</nav></header><main>{% with messages = get_flashed_messages() %}{% if messages %}<ul class="flashes">{% for message in messages %}<li>{{ message }}</li>{% endfor %}</ul>{% endif %}{% endwith %}{% block content %}{% endblock %}</main><footer><p>© 2025 我的博客系統</p></footer>
</body>
</html>
2. 首頁 templates/index.html
<!-- templates/index.html -->
{% extends "base.html" %}{% block content %}
<h2>文章列表</h2><!-- 搜索框 -->
<form method="get" class="search-form"><input type="text" name="q" placeholder="搜索文章..." value="{{ request.args.get('q', '') }}"><button type="submit">搜索</button>
</form><!-- 文章列表 -->
{% for post in posts %}
<article class="post-preview"><h3><a href="{{ url_for('bp.post', id=post.id) }}">{{ post.title }}</a></h3><p class="meta">發布于 {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}{% if post.category %} | 分類: {{ post.category.name }}{% endif %}</p><p>{{ post.content[:200] }}...</p>{% if post.tags %}<div class="tags">{% for tag in post.tags %}<span class="tag">{{ tag.name }}</span>{% endfor %}</div>{% endif %}
</article>
{% else %}
<p>暫無文章。</p>
{% endfor %}
{% endblock %}
3. 文章詳情 templates/post.html
<!-- templates/post.html -->
{% extends "base.html" %}{% block content %}
<article class="post"><h1>{{ post.title }}</h1><p class="meta">作者: {{ post.author.username }} |發布于 {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}{% if post.category %} | 分類: {{ post.category.name }}{% endif %}</p><div class="content">{{ post.content }}</div>{% if post.tags %}<div class="tags">{% for tag in post.tags %}<span class="tag">{{ tag.name }}</span>{% endfor %}</div>{% endif %}<!-- 編輯/刪除 -->{% if current_user.is_authenticated and current_user == post.author %}<p><a href="{{ url_for('bp.edit_post', id=post.id) }}">編輯</a> |<a href="#" onclick="if(confirm('確定刪除?')) document.getElementById('delete-form').submit()">刪除</a></p><form id="delete-form" action="{{ url_for('bp.delete_post', id=post.id) }}" method="post" style="display:none;"></form>{% endif %}<!-- 評論 --><h3>評論 ({{ post.comments|length }})</h3>{% if current_user.is_authenticated %}<form method="post" action="{{ url_for('bp.add_comment', post_id=post.id) }}"><textarea name="content" placeholder="寫下你的評論..." required></textarea><button type="submit">發表評論</button></form>{% else %}<p><a href="{{ url_for('bp.login') }}">登錄</a>后可發表評論。</p>{% endif %}{% for comment in post.comments %}<div class="comment"><strong>{{ comment.author.username }}</strong><span class="date">{{ comment.created_at.strftime('%Y-%m-%d %H:%M') }}</span><p>{{ comment.content }}</p></div>{% endfor %}
</article>
{% endblock %}
4. 發布/編輯文章 templates/create_post.html
<!-- templates/create_post.html -->
{% extends "base.html" %}{% block content %}
<h2>{% if post %}編輯文章{% else %}發布新文章{% endif %}</h2><form method="post"><label>標題 *</label><input type="text" name="title" value="{{ post.title if post }}" required><label>內容 *</label><textarea name="content" rows="10" required>{{ post.content if post }}</textarea><label>分類</label><select name="category_id"><option value="">無分類</option>{% for cat in categories %}<option value="{{ cat.id }}" {% if post and post.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}</select><label>標簽(多個用逗號分隔)</label><input type="text" name="tags" value="{{ tag_str if tag_str else '' }}" placeholder="如:Python,Flask"><button type="submit">{% if post %}更新文章{% else %}發布文章{% endif %}</button>
</form><a href="{{ url_for('bp.index') }}">返回首頁</a>
{% endblock %}
6. 注冊頁 templates/register.html
<!-- templates/register.html -->
{% extends "base.html" %}{% block content %}
<h2>注冊</h2>
<form method="post"><label>用戶名 *</label><input type="text" name="username" required><label>密碼 *</label><input type="password" name="password" required><button type="submit">注冊</button>
</form>
<p>已有賬號?<a href="{{ url_for('bp.login') }}">去登錄</a></p>
{% endblock %}
? 第七步:CSS 樣式 static/style.css
/* static/style.css */
* {margin: 0;padding: 0;box-sizing: border-box;
}body {font-family: Arial, sans-serif;line-height: 1.6;color: #333;max-width: 800px;margin: 0 auto;padding: 20px;
}header {display: flex;justify-content: space-between;align-items: center;padding-bottom: 20px;border-bottom: 1px solid #eee;margin-bottom: 30px;
}header h1 a {text-decoration: none;color: #0056b3;
}nav a {margin-left: 15px;color: #0056b3;text-decoration: none;
}nav a:hover {text-decoration: underline;
}.flashes {background: #d4edda;color: #155724;padding: 10px;border-radius: 4px;margin-bottom: 20px;
}.post-preview {margin-bottom: 30px;padding-bottom: 20px;border-bottom: 1px solid #eee;
}.post-preview h3 {margin-bottom: 5px;
}.post-preview h3 a {color: #0056b3;text-decoration: none;
}.meta {color: #666;font-size: 0.9em;margin-bottom: 10px;
}.tags {margin-top: 10px;
}.tag {display: inline-block;background: #0056b3;color: white;padding: 2px 8px;border-radius: 12px;font-size: 0.8em;margin-right: 5px;
}form {margin: 20px 0;
}label {display: block;margin: 10px 0 5px;font-weight: bold;
}input[type="text"], input[type="password"], textarea, select {width: 100%;padding: 8px;border: 1px solid #ddd;border-radius: 4px;
}button {background: #0056b3;color: white;padding: 10px 15px;border: none;border-radius: 4px;cursor: pointer;margin-top: 10px;
}button:hover {background: #003d82;
}.search-form {display: flex;margin-bottom: 30px;
}.search-form input {flex: 1;margin-right: 10px;
}.comment {border: 1px solid #eee;padding: 10px;margin-bottom: 10px;border-radius: 4px;
}.comment .date {color: #666;font-size: 0.8em;margin-left: 10px;
}footer {text-align: center;margin-top: 50px;color: #666;font-size: 0.9em;
}
? 第八步:運行項目
- 確保你在
blog
目錄下 - 運行:
python app.py
瀏覽器訪問:http://127.0.0.1:5000
? 第九步:使用說明
- 訪問
/register
注冊一個賬號 - 登錄后即可發布文章
- 支持分類、標簽、搜索、評論
- 只有作者可編輯/刪除自己的文章
? 學習要點總結
概念 | 項目中體現 |
---|---|
MVC | models.py (M) + routes.py (C) + templates/ (V) |
數據庫操作 | SQLAlchemy ORM 實現增刪改查 |
會話管理 | Flask-Login 處理登錄狀態 |
表單處理 | request.form 獲取數據 |
模板渲染 | Jinja2 動態生成 HTML |
? 后續建議
- 添加 Markdown 支持
- 增加分頁
- 使用 Bootstrap 美化界面
- 部署到云端(如 Render.com)