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>&copy; 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="上一页">
                    &laquo; 上一页
                </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="下一页">
                    下一页 &raquo;
                </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">&times;</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>&copy; 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的模板与静态文件系统,包括:

  1. Jinja2模板引擎:基本语法、变量表达式、控制结构
  2. 模板过滤器:内置过滤器、自定义过滤器、过滤器链
  3. 模板继承:基础模板、子模板、块重写、模板包含
  4. 静态文件管理:文件组织、CSS/JS编写、资源优化
  5. 前端集成:响应式设计、JavaScript组件、AJAX交互

掌握这些技能能够帮助你构建美观、交互性强的Web应用界面。

下一章预告

下一章我们将学习表单处理与验证,包括:

  • WTForms表单库
  • 表单字段和验证器
  • CSRF保护
  • 文件上传处理
  • 动态表单生成

练习题

  1. 模板继承:创建一个三层继承的模板结构
  2. 自定义过滤器:实现一个Markdown渲染过滤器
  3. 组件化:将常用UI组件抽取为可复用模板
  4. 静态资源:实现CSS/JS的压缩和版本管理
  5. 响应式设计:创建一个移动端友好的模板