4.1 Jinja2模板引擎基础
4.1.1 模板基本语法
<!-- templates/base.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 %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-brand">
<a href="{{ url_for('main.index') }}">我的网站</a>
</div>
<ul class="nav-links">
<li><a href="{{ url_for('main.index') }}">首页</a></li>
<li><a href="{{ url_for('main.about') }}">关于</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('auth.logout') }}">退出</a></li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">登录</a></li>
{% endif %}
</ul>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2023 我的网站. 保留所有权利.</p>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
4.1.2 变量和表达式
<!-- templates/user_profile.html -->
{% extends "base.html" %}
{% block title %}{{ user.username }}的个人资料{% endblock %}
{% block content %}
<div class="user-profile">
<h1>{{ user.username }}</h1>
<!-- 变量输出 -->
<p>邮箱: {{ user.email }}</p>
<p>注册时间: {{ user.created_at.strftime('%Y-%m-%d') }}</p>
<!-- 条件表达式 -->
<p>状态: {{ '活跃' if user.is_active else '非活跃' }}</p>
<!-- 属性访问 -->
<p>文章数量: {{ user.posts|length }}</p>
<!-- 字典访问 -->
{% if user.profile %}
<p>年龄: {{ user.profile['age'] }}</p>
<p>城市: {{ user.profile.get('city', '未设置') }}</p>
{% endif %}
<!-- 列表访问 -->
{% if user.tags %}
<p>第一个标签: {{ user.tags[0] }}</p>
<p>最后一个标签: {{ user.tags[-1] }}</p>
{% endif %}
<!-- 方法调用 -->
<p>用户名长度: {{ user.username|length }}</p>
<p>大写用户名: {{ user.username.upper() }}</p>
<!-- 算术运算 -->
<p>总积分: {{ user.score + user.bonus_score }}</p>
<p>平均分: {{ (user.score / user.posts|length)|round(2) if user.posts else 0 }}</p>
</div>
{% endblock %}
4.1.3 控制结构
<!-- templates/post_list.html -->
{% extends "base.html" %}
{% block title %}文章列表{% endblock %}
{% block content %}
<div class="post-list">
<h1>文章列表</h1>
<!-- if条件语句 -->
{% if posts %}
<div class="posts">
<!-- for循环 -->
{% for post in posts %}
<article class="post-item {{ 'featured' if post.is_featured else '' }}">
<h2>
<a href="{{ url_for('main.show_post', id=post.id) }}">
{{ post.title }}
</a>
</h2>
<!-- 嵌套if -->
{% if post.summary %}
<p class="summary">{{ post.summary }}</p>
{% else %}
<p class="summary">{{ post.content[:100] }}...</p>
{% endif %}
<div class="post-meta">
<span class="author">作者: {{ post.author.username }}</span>
<span class="date">{{ post.created_at|datetime }}</span>
<!-- 循环变量 -->
<span class="position">第{{ loop.index }}篇</span>
{% if loop.first %}
<span class="badge">最新</span>
{% endif %}
{% if loop.last %}
<span class="badge">最后</span>
{% endif %}
</div>
<!-- 标签循环 -->
{% if post.tags %}
<div class="tags">
{% for tag in post.tags %}
<span class="tag">{{ tag.name }}</span>
{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
<!-- 分页 -->
{% if pagination %}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('main.posts', page=pagination.prev_num) }}">上一页</a>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<a href="{{ url_for('main.posts', page=page_num) }}">{{ page_num }}</a>
{% else %}
<strong>{{ page_num }}</strong>
{% endif %}
{% else %}
<span>...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('main.posts', page=pagination.next_num) }}">下一页</a>
{% endif %}
</div>
{% endif %}
{% else %}
<p class="no-posts">暂无文章</p>
{% endif %}
</div>
{% endblock %}
4.2 模板过滤器
4.2.1 内置过滤器
<!-- templates/filters_demo.html -->
{% extends "base.html" %}
{% block content %}
<div class="filters-demo">
<h1>过滤器示例</h1>
<!-- 字符串过滤器 -->
<h2>字符串过滤器</h2>
<p>原文: {{ text }}</p>
<p>大写: {{ text|upper }}</p>
<p>小写: {{ text|lower }}</p>
<p>首字母大写: {{ text|capitalize }}</p>
<p>标题格式: {{ text|title }}</p>
<p>长度: {{ text|length }}</p>
<p>截断: {{ text|truncate(20) }}</p>
<p>去除空格: "{{ text|trim }}"</p>
<p>替换: {{ text|replace('Flask', 'Python') }}</p>
<!-- 数字过滤器 -->
<h2>数字过滤器</h2>
<p>原数字: {{ number }}</p>
<p>绝对值: {{ number|abs }}</p>
<p>四舍五入: {{ number|round }}</p>
<p>保留2位小数: {{ number|round(2) }}</p>
<p>整数部分: {{ number|int }}</p>
<p>浮点数: {{ number|float }}</p>
<!-- 列表过滤器 -->
<h2>列表过滤器</h2>
<p>列表: {{ items }}</p>
<p>长度: {{ items|length }}</p>
<p>第一个: {{ items|first }}</p>
<p>最后一个: {{ items|last }}</p>
<p>随机一个: {{ items|random }}</p>
<p>排序: {{ items|sort }}</p>
<p>反转: {{ items|reverse }}</p>
<p>连接: {{ items|join(', ') }}</p>
<p>去重: {{ items|unique|list }}</p>
<!-- 日期过滤器 -->
<h2>日期过滤器</h2>
<p>当前时间: {{ now }}</p>
<p>格式化: {{ now|strftime('%Y-%m-%d %H:%M:%S') }}</p>
<!-- HTML过滤器 -->
<h2>HTML过滤器</h2>
<p>原HTML: {{ html_content }}</p>
<p>转义: {{ html_content|e }}</p>
<p>安全输出: {{ html_content|safe }}</p>
<p>去除标签: {{ html_content|striptags }}</p>
<!-- 默认值过滤器 -->
<h2>默认值</h2>
<p>空值处理: {{ empty_value|default('默认值') }}</p>
<p>None处理: {{ none_value|default('无数据', true) }}</p>
</div>
{% endblock %}
4.2.2 自定义过滤器
# app.py
from flask import Flask
from datetime import datetime, timedelta
import re
app = Flask(__name__)
# 日期时间过滤器
@app.template_filter('datetime')
def datetime_filter(value, format='%Y-%m-%d %H:%M:%S'):
"""格式化日期时间"""
if value is None:
return ''
return value.strftime(format)
@app.template_filter('timeago')
def timeago_filter(value):
"""显示相对时间"""
if value is None:
return ''
now = datetime.now()
diff = now - value
if diff.days > 0:
return f'{diff.days}天前'
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f'{hours}小时前'
elif diff.seconds > 60:
minutes = diff.seconds // 60
return f'{minutes}分钟前'
else:
return '刚刚'
# 文本处理过滤器
@app.template_filter('markdown')
def markdown_filter(text):
"""简单的Markdown转换"""
if not text:
return ''
# 粗体
text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', text)
# 斜体
text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', text)
# 链接
text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'<a href="\2">\1</a>', text)
# 换行
text = text.replace('\n', '<br>')
return text
@app.template_filter('excerpt')
def excerpt_filter(text, length=100, suffix='...'):
"""生成摘要"""
if not text or len(text) <= length:
return text
return text[:length].rsplit(' ', 1)[0] + suffix
# 数字格式化过滤器
@app.template_filter('currency')
def currency_filter(value, currency='¥'):
"""货币格式化"""
if value is None:
return ''
return f'{currency}{value:,.2f}'
@app.template_filter('filesize')
def filesize_filter(bytes_value):
"""文件大小格式化"""
if bytes_value is None:
return ''
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_value < 1024.0:
return f'{bytes_value:.1f} {unit}'
bytes_value /= 1024.0
return f'{bytes_value:.1f} PB'
# 列表处理过滤器
@app.template_filter('chunk')
def chunk_filter(lst, size):
"""将列表分块"""
for i in range(0, len(lst), size):
yield lst[i:i + size]
@app.template_filter('groupby_attr')
def groupby_attr_filter(lst, attr):
"""按属性分组"""
from itertools import groupby
return groupby(sorted(lst, key=lambda x: getattr(x, attr)), key=lambda x: getattr(x, attr))
# 安全过滤器
@app.template_filter('nl2br')
def nl2br_filter(text):
"""换行符转换为<br>标签"""
if not text:
return ''
return text.replace('\n', '<br>\n')
@app.template_filter('urlize')
def urlize_filter(text):
"""将URL转换为链接"""
if not text:
return ''
url_pattern = re.compile(
r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
)
def replace_url(match):
url = match.group(0)
return f'<a href="{url}" target="_blank">{url}</a>'
return url_pattern.sub(replace_url, text)
4.2.3 过滤器使用示例
<!-- templates/blog_post.html -->
{% extends "base.html" %}
{% block content %}
<article class="blog-post">
<header>
<h1>{{ post.title }}</h1>
<div class="post-meta">
<span class="author">{{ post.author.username }}</span>
<time class="date" datetime="{{ post.created_at|datetime('%Y-%m-%d') }}">
{{ post.created_at|timeago }}
</time>
<span class="reading-time">{{ (post.content|length / 200)|round }}分钟阅读</span>
</div>
</header>
<div class="post-content">
{{ post.content|markdown|safe }}
</div>
<footer class="post-footer">
{% if post.tags %}
<div class="tags">
<strong>标签:</strong>
{% for tag in post.tags %}
<span class="tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<div class="post-stats">
<span>字数: {{ post.content|length|currency('') }}字</span>
<span>大小: {{ post.content|length|filesize }}</span>
</div>
</footer>
</article>
<!-- 相关文章 -->
{% if related_posts %}
<section class="related-posts">
<h2>相关文章</h2>
<div class="post-grid">
{% for chunk in related_posts|chunk(3) %}
<div class="post-row">
{% for post in chunk %}
<div class="post-card">
<h3><a href="{{ url_for('main.show_post', id=post.id) }}">{{ post.title }}</a></h3>
<p>{{ post.content|excerpt(80) }}</p>
<small>{{ post.created_at|timeago }}</small>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}
4.3 模板继承和包含
4.3.1 模板继承
<!-- templates/layouts/admin.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 %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
{% block head %}{% endblock %}
</head>
<body class="admin-layout">
<div class="admin-container">
<aside class="sidebar">
<div class="sidebar-header">
<h2>管理后台</h2>
</div>
<nav class="sidebar-nav">
{% block sidebar %}
<ul>
<li><a href="{{ url_for('admin.dashboard') }}">仪表板</a></li>
<li><a href="{{ url_for('admin.users') }}">用户管理</a></li>
<li><a href="{{ url_for('admin.posts') }}">文章管理</a></li>
<li><a href="{{ url_for('admin.settings') }}">系统设置</a></li>
</ul>
{% endblock %}
</nav>
</aside>
<main class="main-content">
<header class="content-header">
{% block header %}
<h1>{% block page_title %}管理后台{% endblock %}</h1>
{% endblock %}
</header>
<div class="content-body">
{% block content %}{% endblock %}
</div>
</main>
</div>
<script src="{{ url_for('static', filename='js/admin.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
<!-- templates/admin/users.html -->
{% extends "layouts/admin.html" %}
{% block title %}用户管理 - {{ super() }}{% endblock %}
{% block page_title %}用户管理{% endblock %}
{% block content %}
<div class="users-management">
<div class="toolbar">
<button class="btn btn-primary" onclick="showCreateUserModal()">添加用户</button>
<div class="search-box">
<input type="text" placeholder="搜索用户..." id="userSearch">
</div>
</div>
<div class="users-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ user.created_at|datetime }}</td>
<td>
<span class="status {{ 'active' if user.is_active else 'inactive' }}">
{{ '活跃' if user.is_active else '禁用' }}
</span>
</td>
<td>
<button class="btn btn-sm btn-edit" onclick="editUser({{ user.id }})">编辑</button>
<button class="btn btn-sm btn-delete" onclick="deleteUser({{ user.id }})">删除</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
function showCreateUserModal() {
// 显示创建用户模态框
}
function editUser(userId) {
// 编辑用户
}
function deleteUser(userId) {
// 删除用户
}
</script>
{% endblock %}
4.3.2 模板包含
<!-- templates/components/pagination.html -->
{% if pagination and pagination.pages > 1 %}
<nav class="pagination" aria-label="分页导航">
<ul class="pagination-list">
{% if pagination.has_prev %}
<li class="pagination-item">
<a href="{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}"
class="pagination-link" aria-label="上一页">
« 上一页
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=1, right_current=2) %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="pagination-item">
<a href="{{ url_for(endpoint, page=page_num, **kwargs) }}"
class="pagination-link">
{{ page_num }}
</a>
</li>
{% else %}
<li class="pagination-item">
<span class="pagination-link pagination-current" aria-current="page">
{{ page_num }}
</span>
</li>
{% endif %}
{% else %}
<li class="pagination-item">
<span class="pagination-ellipsis">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="pagination-item">
<a href="{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}"
class="pagination-link" aria-label="下一页">
下一页 »
</a>
</li>
{% endif %}
</ul>
<div class="pagination-info">
显示第 {{ pagination.per_page * (pagination.page - 1) + 1 }} -
{{ pagination.per_page * pagination.page if pagination.page < pagination.pages else pagination.total }} 条,
共 {{ pagination.total }} 条记录
</div>
</nav>
{% endif %}
<!-- templates/components/flash_messages.html -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="关闭">
<span aria-hidden="true">×</span>
</button>
{% if category == 'error' %}
<i class="icon-error"></i>
{% elif category == 'warning' %}
<i class="icon-warning"></i>
{% elif category == 'success' %}
<i class="icon-success"></i>
{% else %}
<i class="icon-info"></i>
{% endif %}
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- templates/components/user_card.html -->
<div class="user-card" data-user-id="{{ user.id }}">
<div class="user-avatar">
{% if user.avatar %}
<img src="{{ url_for('static', filename='uploads/avatars/' + user.avatar) }}"
alt="{{ user.username }}的头像">
{% else %}
<div class="avatar-placeholder">
{{ user.username[0]|upper }}
</div>
{% endif %}
</div>
<div class="user-info">
<h3 class="user-name">
<a href="{{ url_for('main.user_profile', username=user.username) }}">
{{ user.username }}
</a>
</h3>
{% if user.bio %}
<p class="user-bio">{{ user.bio|excerpt(50) }}</p>
{% endif %}
<div class="user-stats">
<span class="stat">
<i class="icon-posts"></i>
{{ user.posts|length }} 篇文章
</span>
<span class="stat">
<i class="icon-followers"></i>
{{ user.followers|length }} 关注者
</span>
</div>
<div class="user-meta">
<small class="join-date">
加入于 {{ user.created_at|datetime('%Y年%m月') }}
</small>
</div>
</div>
</div>
4.3.3 使用包含组件
<!-- templates/posts/list.html -->
{% extends "base.html" %}
{% block content %}
<div class="posts-page">
{% include 'components/flash_messages.html' %}
<header class="page-header">
<h1>文章列表</h1>
<div class="page-actions">
<a href="{{ url_for('posts.create') }}" class="btn btn-primary">写文章</a>
</div>
</header>
<div class="posts-content">
{% if posts %}
<div class="posts-list">
{% for post in posts %}
<article class="post-item">
<h2><a href="{{ url_for('posts.show', id=post.id) }}">{{ post.title }}</a></h2>
<p class="post-excerpt">{{ post.content|excerpt(200) }}</p>
<div class="post-meta">
<div class="author-info">
{% include 'components/user_card.html' with context %}
</div>
<div class="post-stats">
<span>{{ post.created_at|timeago }}</span>
<span>{{ post.views }} 次阅读</span>
<span>{{ post.comments|length }} 条评论</span>
</div>
</div>
</article>
{% endfor %}
</div>
<!-- 分页组件 -->
{% include 'components/pagination.html' %}
{% else %}
<div class="empty-state">
<h3>暂无文章</h3>
<p>还没有发布任何文章,<a href="{{ url_for('posts.create') }}">立即写一篇</a>吧!</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
4.4 静态文件管理
4.4.1 静态文件结构
static/
├── css/
│ ├── base.css
│ ├── components.css
│ ├── admin.css
│ └── themes/
│ ├── light.css
│ └── dark.css
├── js/
│ ├── main.js
│ ├── admin.js
│ ├── components/
│ │ ├── modal.js
│ │ ├── dropdown.js
│ │ └── pagination.js
│ └── vendor/
│ ├── jquery.min.js
│ └── bootstrap.min.js
├── images/
│ ├── logo.png
│ ├── favicon.ico
│ ├── icons/
│ └── backgrounds/
├── fonts/
│ ├── custom-font.woff2
│ └── icons.woff
└── uploads/
├── avatars/
├── posts/
└── documents/
4.4.2 CSS样式组织
/* static/css/base.css */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--danger-color: #dc3545;
--warning-color: #ffc107;
--info-color: #17a2b8;
--font-family-base: 'Helvetica Neue', Arial, sans-serif;
--font-size-base: 16px;
--line-height-base: 1.5;
--border-radius: 4px;
--box-shadow: 0 2px 4px rgba(0,0,0,0.1);
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 3rem;
}
/* 重置样式 */
* {
box-sizing: border-box;
}
body {
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
margin: 0;
padding: 0;
color: #333;
background-color: #fff;
}
/* 布局 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.row {
display: flex;
flex-wrap: wrap;
margin: 0 calc(-1 * var(--spacing-md) / 2);
}
.col {
flex: 1;
padding: 0 calc(var(--spacing-md) / 2);
}
/* 导航栏 */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md) 0;
background-color: var(--primary-color);
color: white;
}
.nav-brand {
font-size: 1.5rem;
font-weight: bold;
}
.nav-brand a {
color: white;
text-decoration: none;
}
.nav-links {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.nav-links li {
margin-left: var(--spacing-md);
}
.nav-links a {
color: white;
text-decoration: none;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
transition: background-color 0.3s;
}
.nav-links a:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* 按钮 */
.btn {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-base);
font-weight: 400;
text-align: center;
text-decoration: none;
border: 1px solid transparent;
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
color: white;
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: #0056b3;
border-color: #0056b3;
}
.btn-secondary {
color: white;
background-color: var(--secondary-color);
border-color: var(--secondary-color);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 0.875rem;
}
/* 表单 */
.form-group {
margin-bottom: var(--spacing-md);
}
.form-label {
display: block;
margin-bottom: var(--spacing-xs);
font-weight: 500;
}
.form-control {
display: block;
width: 100%;
padding: var(--spacing-sm);
font-size: var(--font-size-base);
border: 1px solid #ced4da;
border-radius: var(--border-radius);
transition: border-color 0.3s, box-shadow 0.3s;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* 警告框 */
.alert {
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
border: 1px solid transparent;
border-radius: var(--border-radius);
position: relative;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
.alert-error {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeaa7;
}
.alert-info {
color: #0c5460;
background-color: #d1ecf1;
border-color: #bee5eb;
}
.alert-dismissible {
padding-right: 4rem;
}
.alert .close {
position: absolute;
top: 0;
right: 0;
padding: var(--spacing-md);
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
align-items: stretch;
}
.nav-links {
justify-content: center;
margin-top: var(--spacing-md);
}
.nav-links li {
margin: 0 var(--spacing-sm);
}
.row {
flex-direction: column;
}
}
4.4.3 JavaScript组件
// static/js/main.js
// DOM加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// 初始化组件
initFlashMessages();
initDropdowns();
initModals();
initFormValidation();
});
// 闪现消息处理
function initFlashMessages() {
const alerts = document.querySelectorAll('.alert-dismissible');
alerts.forEach(alert => {
const closeBtn = alert.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 300);
});
}
// 自动消失
setTimeout(() => {
if (alert.parentNode) {
alert.style.opacity = '0';
setTimeout(() => {
alert.remove();
}, 300);
}
}, 5000);
});
}
// 下拉菜单
function initDropdowns() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
const toggle = dropdown.querySelector('.dropdown-toggle');
const menu = dropdown.querySelector('.dropdown-menu');
if (toggle && menu) {
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// 关闭其他下拉菜单
document.querySelectorAll('.dropdown-menu.show').forEach(otherMenu => {
if (otherMenu !== menu) {
otherMenu.classList.remove('show');
}
});
menu.classList.toggle('show');
});
}
});
// 点击外部关闭下拉菜单
document.addEventListener('click', function() {
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
menu.classList.remove('show');
});
});
}
// 模态框
function initModals() {
const modalTriggers = document.querySelectorAll('[data-modal-target]');
const modals = document.querySelectorAll('.modal');
modalTriggers.forEach(trigger => {
trigger.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('data-modal-target');
const modal = document.getElementById(targetId);
if (modal) {
showModal(modal);
}
});
});
modals.forEach(modal => {
const closeButtons = modal.querySelectorAll('.modal-close, [data-modal-close]');
closeButtons.forEach(btn => {
btn.addEventListener('click', function() {
hideModal(modal);
});
});
// 点击背景关闭
modal.addEventListener('click', function(e) {
if (e.target === modal) {
hideModal(modal);
}
});
});
}
function showModal(modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// 动画
requestAnimationFrame(() => {
modal.classList.add('show');
});
}
function hideModal(modal) {
modal.classList.remove('show');
document.body.style.overflow = '';
setTimeout(() => {
modal.style.display = 'none';
}, 300);
}
// 表单验证
function initFormValidation() {
const forms = document.querySelectorAll('form[data-validate]');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
if (!validateForm(form)) {
e.preventDefault();
}
});
// 实时验证
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
input.addEventListener('blur', function() {
validateField(input);
});
});
});
}
function validateForm(form) {
let isValid = true;
const inputs = form.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
if (!validateField(input)) {
isValid = false;
}
});
return isValid;
}
function validateField(field) {
const value = field.value.trim();
const type = field.type;
const required = field.hasAttribute('required');
let isValid = true;
let message = '';
// 必填验证
if (required && !value) {
isValid = false;
message = '此字段为必填项';
}
// 邮箱验证
if (type === 'email' && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
isValid = false;
message = '请输入有效的邮箱地址';
}
}
// 密码验证
if (type === 'password' && value) {
if (value.length < 6) {
isValid = false;
message = '密码长度至少6位';
}
}
// 显示验证结果
showFieldValidation(field, isValid, message);
return isValid;
}
function showFieldValidation(field, isValid, message) {
const formGroup = field.closest('.form-group');
if (!formGroup) return;
// 移除之前的验证状态
formGroup.classList.remove('has-error', 'has-success');
const existingFeedback = formGroup.querySelector('.field-feedback');
if (existingFeedback) {
existingFeedback.remove();
}
// 添加新的验证状态
if (!isValid) {
formGroup.classList.add('has-error');
if (message) {
const feedback = document.createElement('div');
feedback.className = 'field-feedback text-danger';
feedback.textContent = message;
formGroup.appendChild(feedback);
}
} else {
formGroup.classList.add('has-success');
}
}
// AJAX工具函数
function ajaxRequest(url, options = {}) {
const defaults = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
};
const config = Object.assign(defaults, options);
return fetch(url, config)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.catch(error => {
console.error('AJAX request failed:', error);
throw error;
});
}
// 工具函数
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
4.4.4 静态文件优化
# app.py - 静态文件配置
from flask import Flask, send_from_directory
import os
from datetime import datetime, timedelta
app = Flask(__name__)
# 静态文件配置
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = timedelta(days=365) # 缓存一年
# 自定义静态文件处理
@app.route('/static/<path:filename>')
def static_files(filename):
"""自定义静态文件处理"""
response = send_from_directory(app.static_folder, filename)
# 设置缓存头
if filename.endswith(('.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.ico')):
response.cache_control.max_age = 31536000 # 1年
response.cache_control.public = True
# 设置压缩
if filename.endswith(('.css', '.js', '.html', '.json', '.xml')):
response.headers['Content-Encoding'] = 'gzip'
return response
# 版本化静态文件
@app.template_global()
def versioned_url_for(endpoint, **values):
"""生成带版本号的静态文件URL"""
if endpoint == 'static':
filename = values.get('filename')
if filename:
file_path = os.path.join(app.static_folder, filename)
if os.path.exists(file_path):
# 使用文件修改时间作为版本号
mtime = os.path.getmtime(file_path)
values['v'] = int(mtime)
return url_for(endpoint, **values)
# 资源压缩和合并
class AssetManager:
def __init__(self, app=None):
self.app = app
self.bundles = {}
if app:
self.init_app(app)
def init_app(self, app):
app.jinja_env.globals['asset_url'] = self.asset_url
def register_bundle(self, name, files, output):
"""注册资源包"""
self.bundles[name] = {
'files': files,
'output': output
}
def asset_url(self, bundle_name):
"""获取资源包URL"""
if bundle_name in self.bundles:
bundle = self.bundles[bundle_name]
# 开发环境返回单个文件
if self.app.debug:
return [url_for('static', filename=f) for f in bundle['files']]
# 生产环境返回合并文件
return [url_for('static', filename=bundle['output'])]
return []
# 使用示例
asset_manager = AssetManager(app)
# 注册CSS包
asset_manager.register_bundle('css_main', [
'css/base.css',
'css/components.css',
'css/layout.css'
], 'css/main.min.css')
# 注册JS包
asset_manager.register_bundle('js_main', [
'js/vendor/jquery.min.js',
'js/components/modal.js',
'js/components/dropdown.js',
'js/main.js'
], 'js/main.min.js')
4.4.5 模板中使用静态文件
<!-- templates/base.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 %}</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/favicon.ico') }}">
<!-- CSS -->
{% if config.DEBUG %}
<!-- 开发环境:单独加载 -->
{% for css_file in asset_url('css_main') %}
<link rel="stylesheet" href="{{ css_file }}">
{% endfor %}
{% else %}
<!-- 生产环境:合并文件 -->
<link rel="stylesheet" href="{{ versioned_url_for('static', filename='css/main.min.css') }}">
{% endif %}
{% block head %}{% endblock %}
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-brand">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo" class="logo">
<a href="{{ url_for('main.index') }}">我的网站</a>
</div>
<!-- 导航内容 -->
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© 2023 我的网站</p>
</footer>
<!-- JavaScript -->
{% if config.DEBUG %}
<!-- 开发环境:单独加载 -->
{% for js_file in asset_url('js_main') %}
<script src="{{ js_file }}"></script>
{% endfor %}
{% else %}
<!-- 生产环境:合并文件 -->
<script src="{{ versioned_url_for('static', filename='js/main.min.js') }}"></script>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
本章小结
本章详细介绍了Flask的模板与静态文件系统,包括:
- Jinja2模板引擎:基本语法、变量表达式、控制结构
- 模板过滤器:内置过滤器、自定义过滤器、过滤器链
- 模板继承:基础模板、子模板、块重写、模板包含
- 静态文件管理:文件组织、CSS/JS编写、资源优化
- 前端集成:响应式设计、JavaScript组件、AJAX交互
掌握这些技能能够帮助你构建美观、交互性强的Web应用界面。
下一章预告
下一章我们将学习表单处理与验证,包括:
- WTForms表单库
- 表单字段和验证器
- CSRF保护
- 文件上传处理
- 动态表单生成
练习题
- 模板继承:创建一个三层继承的模板结构
- 自定义过滤器:实现一个Markdown渲染过滤器
- 组件化:将常用UI组件抽取为可复用模板
- 静态资源:实现CSS/JS的压缩和版本管理
- 响应式设计:创建一个移动端友好的模板