Flask入门学习教程,从入门到精通,Flask智能租房——列表页 知识点详解(7)

Flask智能租房------列表页 知识点详解


一、核心知识点总览

序号 知识点 说明
1 Flask路由与蓝图 路由注册、Blueprint模块化管理
2 请求参数获取 GET/POST参数解析与校验
3 SQLAlchemy高级查询 条件过滤、排序、分页、聚合
4 RESTful API设计 JSON响应格式统一、状态码规范
5 Redis缓存 最新/热点房源缓存策略
6 Jinja2模板渲染 列表页模板继承与渲染
7 分页实现 后端分页逻辑与前端分页组件
8 AJAX异步请求 fetch/Axios动态加载列表数据
9 前端列表渲染 DOM操作、模板字符串拼接
10 搜索与筛选 多条件组合查询

二、搜索房源列表页

2.1 功能说明

搜索房源列表页支持用户通过区域、价格范围、户型、面积、排序方式等多维度条件筛选房源,结果以分页列表形式展示。

核心功能:

  • 多条件组合搜索(区域、价格、户型、面积等)
  • 搜索结果分页展示(每页固定条数)
  • 支持按价格、发布时间等字段排序
  • 搜索条件记忆(回填表单)
  • 无结果时的空状态提示

2.2 接口设计

请求方式: GET /api/house/search

请求参数:

参数名 类型 必填 说明
area_id int 区域ID
price_min float 最低价格
price_max float 最高价格
room_type string 户型(如"1室1厅")
area_min float 最小面积(㎡)
area_max float 最大面积(㎡)
sort string 排序方式:price_asc/price_desc/newest
page int 页码,默认1
per_page int 每页条数,默认10

响应格式:

json 复制代码
{
    "code": 200,
    "msg": "查询成功",
    "data": {
        "total": 120,
        "page": 1,
        "per_page": 10,
        "pages": 12,
        "houses": [
            {
                "id": 1,
                "title": "朝阳区精装两居室",
                "price": 5500,
                "area": 85,
                "room_type": "2室1厅",
                "region": "朝阳区",
                "image": "/static/images/house1.jpg",
                "create_time": "2024-01-15"
            }
        ]
    }
}

2.3 后端实现

2.3.1 数据模型定义
python 复制代码
# models.py
from datetime import datetime
from exts import db  # 导入SQLAlchemy实例

class House(db.Model):
    """
    房源数据模型
    对应数据库中的house表,存储房源的所有信息
    """
    # 表名指定
    __tablename__ = 'house'

    # 主键ID,自增整数
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # 房源标题,最大长度200,不允许为空
    title = db.Column(db.String(200), nullable=False, comment='房源标题')

    # 租金价格,使用Numeric类型保证精度(总共10位,小数2位)
    price = db.Column(db.Numeric(10, 2), nullable=False, comment='月租金')

    # 面积,平方米
    area = db.Column(db.Float, nullable=False, comment='面积(平方米)')

    # 户型,如"2室1厅1卫"
    room_type = db.Column(db.String(50), nullable=False, comment='户型')

    # 区域外键,关联到region表
    area_id = db.Column(db.Integer, db.ForeignKey('region.id'), comment='区域ID')

    # 房源描述
    description = db.Column(db.Text, comment='房源描述')

    # 房源图片路径
    image = db.Column(db.String(500), comment='房源图片')

    # 浏览次数,用于热点统计
    view_count = db.Column(db.Integer, default=0, comment='浏览次数')

    # 是否上架
    is_active = db.Column(db.Boolean, default=True, comment='是否上架')

    # 创建时间,自动设置为记录插入时的时间
    create_time = db.Column(db.DateTime, default=datetime.now, comment='发布时间')

    # 更新时间,每次更新时自动刷新
    update_time = db.Column(db.DateTime, default=datetime.now,
                            onupdate=datetime.now, comment='更新时间')

    # 建立与Region模型的关系(多对一)
    region = db.relationship('Region', backref=db.backref('houses', lazy='dynamic'))

    def to_dict(self):
        """
        将模型对象转换为字典格式
        方便序列化为JSON响应
        """
        return {
            'id': self.id,                          # 房源ID
            'title': self.title,                     # 房源标题
            'price': float(self.price),              # 租金(转为float以序列化)
            'area': self.area,                       # 面积
            'room_type': self.room_type,             # 户型
            'region': self.region.name if self.region else '',  # 区域名称
            'image': self.image,                     # 图片路径
            'view_count': self.view_count,           # 浏览次数
            'create_time': self.create_time.strftime('%Y-%m-%d')  # 格式化日期
        }


class Region(db.Model):
    """
    区域数据模型
    存储房源所属的区域信息
    """
    __tablename__ = 'region'

    # 区域ID
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)

    # 区域名称,如"朝阳区"、"海淀区"
    name = db.Column(db.String(50), nullable=False, unique=True, comment='区域名称')

    def to_dict(self):
        """将区域对象转换为字典"""
        return {
            'id': self.id,
            'name': self.name
        }

知识点说明:

  • db.Column()primary_key=True 表示主键,autoincrement=True 表示自增
  • db.ForeignKey('region.id') 定义外键关联,括号内格式为 '表名.字段名'
  • db.relationship() 定义ORM关系映射,backref 自动生成反向引用
  • lazy='dynamic' 表示延迟加载,返回查询对象而非列表,可在后续追加过滤条件
  • onupdate=datetime.now 表示记录更新时自动刷新时间戳
  • Numeric(10, 2) 用于需要精确计算的金额字段,避免浮点精度问题
2.3.2 蓝图定义与路由注册
python 复制代码
# api/house.py
from flask import Blueprint, request, jsonify
from models import House, Region
from exts import db
from sqlalchemy import or_

# 创建房源相关接口的蓝图
# url_prefix 指定该蓝图下所有路由的公共前缀
house_bp = Blueprint('house', __name__, url_prefix='/api/house')


@house_bp.route('/search', methods=['GET'])
def search_houses():
    """
    搜索房源接口
    支持多条件组合查询、排序和分页

    处理流程:
    1. 获取并校验请求参数
    2. 构建动态查询条件
    3. 执行分页查询
    4. 格式化返回结果
    """
    # ==================== 第一步:获取请求参数 ====================
    # request.args 是一个不可变字典,存储URL中的查询参数
    # .get() 方法的第二个参数为默认值,参数不存在时使用默认值
    area_id = request.args.get('area_id', type=int)          # 区域ID,整数类型
    price_min = request.args.get('price_min', type=float)     # 最低价格,浮点类型
    price_max = request.args.get('price_max', type=float)     # 最高价格,浮点类型
    room_type = request.args.get('room_type', type=str)       # 户型,字符串类型
    area_min = request.args.get('area_min', type=float)       # 最小面积
    area_max = request.args.get('area_max', type=float)       # 最大面积
    sort = request.args.get('sort', 'newest', type=str)       # 排序方式,默认按最新
    page = request.args.get('page', 1, type=int)              # 当前页码,默认第1页
    per_page = request.args.get('per_page', 10, type=int)     # 每页条数,默认10条

    # ==================== 第二步:参数校验 ====================
    # 校验页码必须大于0
    if page < 1:
        page = 1

    # 校验每页条数在1-50之间,防止恶意大量请求
    if per_page < 1 or per_page > 50:
        per_page = 10

    # ==================== 第三步:构建基础查询 ====================
    # db.session.query(House) 创建查询对象
    # filter() 方法添加过滤条件,支持链式调用
    # 只查询已上架的房源
    query = House.query.filter(House.is_active == True)

    # ==================== 第四步:动态拼接查询条件 ====================
    # 根据用户传入的参数,动态添加过滤条件

    # 按区域筛选
    if area_id:
        # 精确匹配区域ID
        query = query.filter(House.area_id == area_id)

    # 按最低价格筛选
    if price_min is not None:
        # >= 操作,使用 SQLAlchemy 的 >= 运算符重载
        query = query.filter(House.price >= price_min)

    # 按最高价格筛选
    if price_max is not None:
        # <= 操作
        query = query.filter(House.price <= price_max)

    # 按户型筛选
    if room_type:
        # 精确匹配户型字符串
        query = query.filter(House.room_type == room_type)

    # 按最小面积筛选
    if area_min is not None:
        query = query.filter(House.area >= area_min)

    # 按最大面积筛选
    if area_max is not None:
        query = query.filter(House.area <= area_max)

    # ==================== 第五步:排序 ====================
    # 根据sort参数决定排序方式
    if sort == 'price_asc':
        # 按价格升序排列(从低到高)
        # asc() 表示升序,desc() 表示降序
        query = query.order_by(House.price.asc())
    elif sort == 'price_desc':
        # 按价格降序排列(从高到低)
        query = query.order_by(House.price.desc())
    else:
        # 默认按创建时间降序(最新的排前面)
        query = query.order_by(House.create_time.desc())

    # ==================== 第六步:分页查询 ====================
    # paginate() 方法执行分页查询
    # 参数说明:
    #   page: 当前页码
    #   per_page: 每页记录数
    #   error_out: 当页码超出范围时是否抛出404异常
    # 返回 Pagination 对象,包含分页信息和数据
    pagination = query.paginate(page=page, per_page=per_page, error_out=False)

    # ==================== 第七步:格式化返回结果 ====================
    # pagination.items: 当前页的数据列表(House对象列表)
    # 使用列表推导式将每个House对象转为字典
    house_list = [house.to_dict() for house in pagination.items]

    # 构建响应数据
    result = {
        'total': pagination.total,           # 总记录数
        'page': pagination.page,             # 当前页码
        'per_page': pagination.per_page,     # 每页条数
        'pages': pagination.pages,           # 总页数
        'has_prev': pagination.has_prev,     # 是否有上一页
        'has_next': pagination.has_next,     # 是否有下一页
        'houses': house_list                 # 房源数据列表
    }

    # jsonify() 将字典转换为JSON响应
    # 自动设置 Content-Type 为 application/json
    return jsonify(code=200, msg='查询成功', data=result)

知识点详解:

python 复制代码
# ===== 知识点1:Flask request 对象 =====
from flask import request

# 获取GET请求的查询参数(URL中?后面的键值对)
# 例如: /api/house/search?page=2&sort=price_asc
page = request.args.get('page', 1, type=int)

# 获取POST请求的表单数据
# data = request.form.get('username')

# 获取POST请求的JSON数据
# json_data = request.get_json()

# 获取请求方法
method = request.method  # 返回 'GET', 'POST', 'PUT', 'DELETE' 等


# ===== 知识点2:SQLAlchemy 过滤器详解 =====

# 等于过滤
query.filter(House.area_id == 1)

# 不等于
query.filter(House.area_id != 1)

# 大于/小于/大于等于/小于等于
query.filter(House.price > 1000)
query.filter(House.price < 5000)
query.filter(House.price >= 1000)
query.filter(House.price <= 5000)

# LIKE 模糊查询(%匹配任意多个字符,_匹配单个字符)
query.filter(House.title.like('%两居%'))

# IN 查询(匹配列表中的任一值)
query.filter(House.area_id.in_([1, 2, 3]))

# NOT IN
query.filter(House.area_id.notin_([1, 2]))

# IS NULL / IS NOT NULL
query.filter(House.description.is_(None))
query.filter(House.description.isnot(None))

# BETWEEN 范围查询
query.filter(House.price.between(1000, 5000))

# OR 条件(需要导入 or_)
from sqlalchemy import or_
query.filter(or_(House.area_id == 1, House.area_id == 2))

# AND 条件(多个filter默认就是AND)
query.filter(House.price >= 1000).filter(House.price <= 5000)

# 多字段排序
query.order_by(House.area_id.asc(), House.price.desc())
python 复制代码
# ===== 知识点3:分页对象 (Pagination) 的完整属性 =====
"""
pagination 对象包含以下常用属性和方法:

属性:
- pagination.page          当前页码(int)
- pagination.per_page      每页记录数(int)
- pagination.total         总记录数(int)
- pagination.pages         总页数(int)
- pagination.items         当前页的数据列表
- pagination.has_prev      是否有上一页(bool)
- pagination.has_next      是否有下一页(bool)
- pagination.prev_num      上一页的页码(int或None)
- pagination.next_num      下一页的页码(int或None)

方法:
- pagination.prev()        获取上一页的Pagination对象
- pagination.next()        获取下一页的Pagination对象
- pagination.iter_pages()  生成页码迭代器(用于前端渲染分页导航)
"""
2.3.3 蓝图注册到应用
python 复制代码
# app.py
from flask import Flask
from exts import db
from api.house import house_bp  # 导入房源蓝图

def create_app():
    """
    应用工厂函数
    使用工厂模式创建Flask应用,便于测试和配置管理
    """
    # 创建Flask应用实例
    app = Flask(__name__)

    # 加载配置
    # 从config.py加载配置项(数据库连接、密钥等)
    app.config.from_object('config')

    # 初始化数据库扩展
    # 将SQLAlchemy与Flask应用绑定
    db.init_app(app)

    # 注册蓝图
    # register_blueprint() 将蓝图注册到应用上
    # 注册后蓝图中的路由才会生效
    app.register_blueprint(house_bp)

    return app


# config.py
"""
应用配置文件
使用类组织配置项,便于区分不同环境
"""
class Config:
    """基础配置"""
    # 数据库连接URI
    # 格式: mysql+pymysql://用户名:密码@主机:端口/数据库名?charset=utf8mb4
    SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@localhost:3306/smart_rent?charset=utf8mb4'

    # 是否追踪对象修改(关闭以节省性能)
    SQLALCHEMY_TRACK_MODIFICATIONS = False

    # 开启SQL语句日志输出(调试时打开)
    SQLALCHEMY_ECHO = True

    # JSON配置:确保中文正常显示
    JSON_AS_ASCII = False

    # 密钥配置(用于session、CSRF等)
    SECRET_KEY = 'your-secret-key-here'

class DevelopmentConfig(Config):
    """开发环境配置"""
    DEBUG = True  # 开启调试模式

class ProductionConfig(Config):
    """生产环境配置"""
    DEBUG = False  # 关闭调试模式

2.4 前端实现

2.4.1 搜索表单页面(Jinja2模板)
html 复制代码
<!-- templates/house/search.html -->
<!-- 使用 Jinja2 模板继承,继承基础布局模板 -->
{% extends "base.html" %}

<!-- 自定义页面标题 -->
{% block title %}搜索房源 - 智能租房{% endblock %}

<!-- 页面CSS样式 -->
{% block style %}
<style>
    /* ===== 搜索区域容器 ===== */
    .search-section {
        background: #f8f9fa;           /* 浅灰色背景 */
        padding: 30px 0;               /* 上下内边距 */
        border-bottom: 2px solid #e9ecef; /* 底部分隔线 */
    }

    /* 搜索表单样式 */
    .search-form {
        max-width: 1200px;             /* 最大宽度限制 */
        margin: 0 auto;               /* 水平居中 */
        padding: 0 20px;              /* 左右内边距 */
    }

    /* 表单行:一行排列多个筛选项 */
    .form-row {
        display: flex;                 /* 弹性布局 */
        flex-wrap: wrap;               /* 允许换行 */
        gap: 15px;                     /* 项目间距 */
        margin-bottom: 15px;           /* 行间距 */
        align-items: center;           /* 垂直居中对齐 */
    }

    /* 每个筛选组(标签+输入框) */
    .form-group {
        display: flex;
        align-items: center;
        gap: 8px;                      /* 标签与输入框间距 */
    }

    /* 筛选标签文字 */
    .form-group label {
        font-weight: 600;             /* 加粗 */
        color: #495057;               /* 深灰色 */
        white-space: nowrap;           /* 防止换行 */
        font-size: 14px;
    }

    /* 输入框和下拉框通用样式 */
    .form-group input,
    .form-group select {
        padding: 8px 12px;            /* 内边距 */
        border: 1px solid #ced4da;    /* 边框 */
        border-radius: 4px;           /* 圆角 */
        font-size: 14px;
        outline: none;                /* 去除默认聚焦轮廓 */
        transition: border-color 0.2s; /* 边框颜色过渡动画 */
    }

    /* 输入框聚焦时的样式 */
    .form-group input:focus,
    .form-group select:focus {
        border-color: #007bff;        /* 蓝色边框 */
        box-shadow: 0 0 0 2px rgba(0,123,255,0.15); /* 外发光效果 */
    }

    /* 搜索按钮 */
    .btn-search {
        background: #007bff;          /* 蓝色背景 */
        color: white;                 /* 白色文字 */
        border: none;
        padding: 10px 30px;
        border-radius: 4px;
        font-size: 15px;
        cursor: pointer;              /* 鼠标指针变为手型 */
        transition: background 0.2s;
    }

    /* 搜索按钮悬停效果 */
    .btn-search:hover {
        background: #0056b3;          /* 更深的蓝色 */
    }

    /* ===== 房源列表区域 ===== */
    .house-list {
        max-width: 1200px;
        margin: 20px auto;
        padding: 0 20px;
    }

    /* 每个房源卡片 */
    .house-card {
        display: flex;                 /* 左图右文字布局 */
        background: white;
        border-radius: 8px;
        margin-bottom: 15px;
        overflow: hidden;              /* 隐藏溢出内容 */
        box-shadow: 0 2px 8px rgba(0,0,0,0.06); /* 微阴影 */
        transition: transform 0.2s, box-shadow 0.2s; /* 悬停过渡 */
    }

    /* 房源卡片悬停效果 */
    .house-card:hover {
        transform: translateY(-2px);   /* 微微上浮 */
        box-shadow: 0 4px 16px rgba(0,0,0,0.12); /* 阴影加深 */
    }

    /* 房源图片区域 */
    .house-card .house-image {
        width: 220px;                  /* 固定宽度 */
        height: 165px;                 /* 固定高度 */
        object-fit: cover;            /* 图片裁剪填充 */
        flex-shrink: 0;               /* 不允许缩小 */
    }

    /* 房源信息区域 */
    .house-card .house-info {
        flex: 1;                       /* 占据剩余空间 */
        padding: 15px 20px;
        display: flex;
        flex-direction: column;        /* 纵向排列 */
        justify-content: space-between; /* 内容分散对齐 */
    }

    /* 房源标题 */
    .house-card .house-title {
        font-size: 18px;
        font-weight: 600;
        color: #212529;
        margin-bottom: 8px;
        /* 限制最多显示一行,超出显示省略号 */
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* 房源详情信息行 */
    .house-card .house-detail {
        color: #6c757d;
        font-size: 14px;
        margin-bottom: 8px;
    }

    /* 房源详情中各项之间的分隔 */
    .house-card .house-detail span {
        margin-right: 15px;
    }

    /* 价格样式 */
    .house-card .house-price {
        font-size: 22px;
        font-weight: 700;
        color: #e74c3c;               /* 红色突出价格 */
    }

    /* 价格单位 */
    .house-card .house-price small {
        font-size: 13px;
        font-weight: 400;
        color: #999;
    }

    /* ===== 分页导航区域 ===== */
    .pagination {
        display: flex;
        justify-content: center;       /* 水平居中 */
        gap: 5px;
        margin: 30px 0;
    }

    /* 分页按钮通用样式 */
    .pagination a, .pagination span {
        display: inline-block;
        padding: 8px 14px;
        border: 1px solid #dee2e6;
        border-radius: 4px;
        color: #007bff;
        text-decoration: none;
        font-size: 14px;
        transition: all 0.2s;
    }

    /* 分页按钮悬停效果 */
    .pagination a:hover {
        background: #007bff;
        color: white;
        border-color: #007bff;
    }

    /* 当前页码样式(不可点击) */
    .pagination .active {
        background: #007bff;
        color: white;
        border-color: #007bff;
    }

    /* 空状态提示 */
    .empty-state {
        text-align: center;
        padding: 60px 20px;
        color: #adb5bd;
        font-size: 16px;
    }
</style>
{% endblock %}

<!-- 页面主体内容 -->
{% block content %}
<!-- 搜索区域 -->
<section class="search-section">
    <form class="search-form" id="searchForm">
        <div class="form-row">
            <!-- 区域选择下拉框 -->
            <div class="form-group">
                <label for="area_id">区域:</label>
                <select name="area_id" id="area_id">
                    <option value="">不限</option>
                    <!-- 使用Jinja2循环渲染区域列表 -->
                    <!-- regions 是从后端传递过来的区域列表 -->
                    {% for region in regions %}
                    <!-- value="{{ region.id }}" 提交区域ID -->
                    <option value="{{ region.id }}">{{ region.name }}</option>
                    {% endfor %}
                </select>
            </div>

            <!-- 价格范围输入 -->
            <div class="form-group">
                <label>价格:</label>
                <input type="number" name="price_min" placeholder="最低价" min="0">
                <span>-</span>
                <input type="number" name="price_max" placeholder="最高价" min="0">
                <span>元/月</span>
            </div>

            <!-- 户型选择 -->
            <div class="form-group">
                <label for="room_type">户型:</label>
                <select name="room_type" id="room_type">
                    <option value="">不限</option>
                    <option value="1室1厅">1室1厅</option>
                    <option value="2室1厅">2室1厅</option>
                    <option value="3室1厅">3室1厅</option>
                    <option value="3室2厅">3室2厅</option>
                </select>
            </div>

            <!-- 面积范围 -->
            <div class="form-group">
                <label>面积:</label>
                <input type="number" name="area_min" placeholder="最小面积" min="0">
                <span>-</span>
                <input type="number" name="area_max" placeholder="最大面积" min="0">
                <span>㎡</span>
            </div>

            <!-- 排序方式 -->
            <div class="form-group">
                <label for="sort">排序:</label>
                <select name="sort" id="sort">
                    <option value="newest">最新发布</option>
                    <option value="price_asc">价格从低到高</option>
                    <option value="price_desc">价格从高到低</option>
                </select>
            </div>

            <!-- 搜索按钮 -->
            <button type="submit" class="btn-search">搜索房源</button>
        </div>
    </form>
</section>

<!-- 房源列表容器 -->
<!-- id="houseList" 用于JavaScript动态插入房源数据 -->
<section class="house-list" id="houseList">
    <!-- 初始加载状态 -->
    <div class="empty-state">正在加载...</div>
</section>

<!-- 分页导航容器 -->
<!-- id="pagination" 用于JavaScript动态插入分页按钮 -->
<nav class="pagination" id="pagination"></nav>
{% endblock %}
2.4.2 前端JavaScript交互逻辑
html 复制代码
<!-- 在页面底部引入JavaScript代码 -->
{% block script %}
<script>
// ===== 页面加载完成后执行 =====
// DOMContentLoaded 事件在DOM树构建完成后触发,不等待样式表和图片加载
document.addEventListener('DOMContentLoaded', function() {

    // 获取DOM元素引用
    const searchForm = document.getElementById('searchForm');   // 搜索表单
    const houseList = document.getElementById('houseList');     // 房源列表容器
    const paginationDiv = document.getElementById('pagination'); // 分页容器

    // 当前页码(全局状态)
    let currentPage = 1;

    // ===================== 搜索表单提交事件 =====================
    // addEventListener('submit') 监听表单的提交事件
    searchForm.addEventListener('submit', function(e) {
        // preventDefault() 阻止表单的默认提交行为(页面刷新)
        // 我们使用AJAX异步提交,而不是传统的表单提交
        e.preventDefault();

        // 重置到第一页
        currentPage = 1;

        // 调用搜索函数
        fetchHouseList();
    });

    // ===================== 核心搜索函数 =====================
    /**
     * 发起AJAX请求获取房源列表
     * 使用 Fetch API 发送异步HTTP请求
     */
    function fetchHouseList() {
        // 构建查询参数对象
        const formData = new FormData(searchForm); // 从表单自动提取所有字段值

        // 手动构建URL查询参数
        // URLSearchParams 提供便捷的方法来构造查询字符串
        const params = new URLSearchParams();

        // 遍历表单数据,只添加有值的字段
        for (const [key, value] of formData.entries()) {
            // 去除空白后判断是否为空
            if (value.toString().trim() !== '') {
                params.append(key, value); // 追加参数
            }
        }

        // 添加分页参数
        params.append('page', currentPage);

        // 构建完整的请求URL
        const url = `/api/house/search?${params.toString()}`;

        // 使用 fetch 发起GET请求
        // fetch() 返回一个 Promise 对象
        fetch(url)
            .then(response => {
                // 第一个 .then() 处理HTTP响应对象
                // 判断HTTP状态码是否为成功(200-299)
                if (!response.ok) {
                    throw new Error(`HTTP错误,状态码:${response.status}`);
                }
                // .json() 将响应体解析为JSON,也返回Promise
                return response.json();
            })
            .then(data => {
                // 第二个 .then() 处理解析后的JSON数据
                if (data.code === 200) {
                    // 请求成功,渲染房源列表
                    renderHouseList(data.data.houses);
                    // 渲染分页导航
                    renderPagination(data.data);
                } else {
                    // 业务逻辑错误
                    houseList.innerHTML = `<div class="empty-state">${data.msg}</div>`;
                }
            })
            .catch(error => {
                // .catch() 捕获请求过程中发生的任何错误
                console.error('请求失败:', error);
                houseList.innerHTML = '<div class="empty-state">网络请求失败,请稍后重试</div>';
            });
    }

    // ===================== 渲染房源列表 =====================
    /**
     * 将房源数据渲染为HTML并插入到页面中
     * @param {Array} houses - 房源数据数组
     */
    function renderHouseList(houses) {
        // 判断是否有数据
        if (!houses || houses.length === 0) {
            // 无数据时显示空状态提示
            houseList.innerHTML = '<div class="empty-state">未找到符合条件的房源,请调整筛选条件</div>';
            return; // 提前结束函数
        }

        // 使用 map() 方法将数据数组转换为HTML字符串数组
        // map() 对数组每个元素执行回调函数,返回新数组
        const htmlArr = houses.map(function(house) {
            // 使用模板字符串(反引号)拼接HTML
            // 模板字符串中可以用 ${变量} 插入变量值
            return `
                <div class="house-card">
                    <!-- 房源图片 -->
                    <!-- ${house.image} 插入图片路径 -->
                    <!-- onerror 当图片加载失败时显示默认图 -->
                    <img class="house-image"
                         src="${house.image}"
                         alt="${house.title}"
                         onerror="this.src='/static/images/default.jpg'">

                    <!-- 房源信息区域 -->
                    <div class="house-info">
                        <div>
                            <!-- 房源标题 -->
                            <h3 class="house-title">
                                <!-- a标签链接到房源详情页 -->
                                <a href="/house/${house.id}">${house.title}</a>
                            </h3>
                            <!-- 房源详情:户型、面积、区域 -->
                            <p class="house-detail">
                                <span>${house.room_type}</span>
                                <span>${house.area}㎡</span>
                                <span>${house.region}</span>
                            </p>
                            <!-- 发布时间 -->
                            <p class="house-detail">
                                <span>发布时间:${house.create_time}</span>
                            </p>
                        </div>
                        <!-- 价格信息 -->
                        <div class="house-price">
                            ${house.price} <small>元/月</small>
                        </div>
                    </div>
                </div>
            `;
        });

        // join('') 将数组所有元素拼接为一个完整字符串
        // 然后通过 innerHTML 插入到容器中
        houseList.innerHTML = htmlArr.join('');
    }

    // ===================== 渲染分页导航 =====================
    /**
     * 根据分页数据渲染分页导航按钮
     * @param {Object} pageData - 分页数据对象
     */
    function renderPagination(pageData) {
        // 解构赋值,提取分页相关属性
        const { page, pages, has_prev, has_next, total } = pageData;

        // 如果总页数不超过1页,不显示分页
        if (pages <= 1) {
            paginationDiv.innerHTML = '';
            return;
        }

        let html = ''; // 分页HTML字符串

        // ===== 上一页按钮 =====
        if (has_prev) {
            // 有上一页时,按钮可点击
            html += `<a href="javascript:void(0)" onclick="goToPage(${page - 1})">上一页</a>`;
        } else {
            // 无上一页时,按钮禁用(使用span代替a标签)
            html += `<span style="color:#ccc;cursor:default">上一页</span>`;
        }

        // ===== 页码按钮 =====
        // 简单的页码显示逻辑:最多显示7个页码按钮
        let startPage = Math.max(1, page - 3);     // 起始页码(不小于1)
        let endPage = Math.min(pages, page + 3);   // 结束页码(不超过总页数)

        // 如果起始页大于1,显示第1页和省略号
        if (startPage > 1) {
            html += `<a href="javascript:void(0)" onclick="goToPage(1)">1</a>`;
            if (startPage > 2) {
                html += `<span style="color:#999">...</span>`;
            }
        }

        // 循环生成页码按钮
        for (let i = startPage; i <= endPage; i++) {
            if (i === page) {
                // 当前页码使用高亮样式(active),不可点击
                html += `<span class="active">${i}</span>`;
            } else {
                // 其他页码可点击
                html += `<a href="javascript:void(0)" onclick="goToPage(${i})">${i}</a>`;
            }
        }

        // 如果结束页小于总页数,显示省略号和最后一页
        if (endPage < pages) {
            if (endPage < pages - 1) {
                html += `<span style="color:#999">...</span>`;
            }
            html += `<a href="javascript:void(0)" onclick="goToPage(${pages})">${pages}</a>`;
        }

        // ===== 下一页按钮 =====
        if (has_next) {
            html += `<a href="javascript:void(0)" onclick="goToPage(${page + 1})">下一页</a>`;
        } else {
            html += `<span style="color:#ccc;cursor:default">下一页</span>`;
        }

        // 总记录数提示
        html += `<span style="margin-left:15px;color:#999;font-size:13px">共${total}条</span>`;

        // 插入分页HTML
        paginationDiv.innerHTML = html;
    }

    // ===================== 翻页函数 =====================
    /**
     * 跳转到指定页码
     * 此函数被设置为全局函数,以便HTML中的onclick可以调用
     * @param {number} page - 目标页码
     */
    window.goToPage = function(page) {
        currentPage = page;       // 更新当前页码
        fetchHouseList();          // 重新请求数据

        // 页面平滑滚动到列表顶部
        // scrollTo() 方法滚动到指定位置
        // behavior: 'smooth' 启用平滑滚动
        window.scrollTo({
            top: houseList.offsetTop - 10, // 滚动到列表容器位置(减去10px留白)
            behavior: 'smooth'
        });
    };

    // ===================== 页面初始化 =====================
    // 页面加载完成后立即获取第一页数据
    fetchHouseList();
});
</script>
{% endblock %}

三、最新房源列表页

3.1 功能说明

最新房源列表页展示平台上最新发布的房源信息,按照发布时间倒序排列,并使用Redis缓存提升访问性能。

核心功能:

  • 按发布时间倒序展示房源
  • Redis缓存热门数据,减少数据库查询
  • 缓存过期策略与主动更新
  • 支持分页浏览

3.2 接口设计

请求方式: GET /api/house/latest

请求参数:

参数名 类型 必填 说明
page int 页码,默认1
per_page int 每页条数,默认10

响应格式:

json 复制代码
{
    "code": 200,
    "msg": "查询成功",
    "data": {
        "total": 50,
        "page": 1,
        "per_page": 10,
        "pages": 5,
        "houses": [
            {
                "id": 10,
                "title": "国贸CBD整租一居室",
                "price": 8500,
                "area": 55,
                "room_type": "1室1厅",
                "region": "朝阳区",
                "image": "/static/images/house10.jpg",
                "create_time": "2024-03-20"
            }
        ]
    }
}

3.3 后端实现

3.3.1 Redis缓存配置与工具类
python 复制代码
# exts.py
"""
扩展模块
集中管理所有第三方扩展的初始化
"""
from flask_sqlalchemy import SQLAlchemy
from redis import Redis

# 创建SQLAlchemy实例(不绑定应用,在工厂函数中绑定)
db = SQLAlchemy()

# 创建Redis连接实例
# 参数说明:
#   host: Redis服务器地址
#   port: Redis端口号(默认6379)
#   db: 数据库编号(0-15,默认0)
#   password: 连接密码(如果设置了密码)
#   decode_responses: 设为True则自动将bytes解码为str
redis_client = Redis(
    host='localhost',
    port=6379,
    db=1,                  # 使用1号数据库(与默认0号区分开)
    password='',           # 无密码时留空
    decode_responses=True  # 自动解码返回字符串
)
python 复制代码
# utils/cache.py
"""
缓存工具模块
封装Redis缓存的通用操作
"""
import json
from exts import redis_client

# 缓存过期时间常量(单位:秒)
CACHE_EXPIRE_TIME = 300  # 5分钟过期

def get_cache(key):
    """
    从Redis获取缓存数据

    参数:
        key (str): 缓存键名

    返回:
        dict/list/None: 缓存数据(已反序列化),不存在返回None
    """
    # redis_client.get() 获取指定key的值
    # 如果key不存在,返回None
    cached = redis_client.get(key)

    if cached:
        # json.loads() 将JSON字符串反序列化为Python对象
        return json.loads(cached)

    return None


def set_cache(key, data, expire=CACHE_EXPIRE_TIME):
    """
    将数据存入Redis缓存

    参数:
        key (str): 缓存键名
        data (dict/list): 要缓存的数据
        expire (int): 过期时间(秒),默认300秒
    """
    # json.dumps() 将Python对象序列化为JSON字符串
    redis_client.set(key, json.dumps(data))

    # expire() 设置key的过期时间
    # 到期后key会自动被删除
    redis_client.expire(key, expire)


def delete_cache(key):
    """
    删除指定缓存

    参数:
        key (str): 要删除的缓存键名
    """
    # delete() 可以删除一个或多个key
    redis_client.delete(key)


def delete_cache_pattern(pattern):
    """
    按模式匹配批量删除缓存

    参数:
        pattern (str): 匹配模式,如 'house:latest:*' 匹配所有最新房源缓存

    注意:生产环境中应谨慎使用keys(),对大量key扫描可能影响性能
          可考虑使用scan()替代
    """
    # keys() 返回匹配模式的所有key列表
    keys = redis_client.keys(pattern)
    if keys:
        # delete() 接受多个参数,批量删除
        redis_client.delete(*keys)

Redis知识点详解:

python 复制代码
"""
===== Redis五种基本数据类型在缓存中的应用 =====
"""

# 1. String(字符串)------ 最常用,适合存储JSON序列化的数据
redis_client.set('user:1', '{"name":"张三","age":25}')     # 设置值
redis_client.set('user:1', '{"name":"张三"}', ex=3600)      # 设置值并指定过期时间(秒)
redis_client.get('user:1')                                    # 获取值
redis_client.exists('user:1')                                 # 判断key是否存在(返回0或1)
redis_client.ttl('user:1')                                   # 查看剩余过期时间(秒)

# 2. Hash(哈希)------ 适合存储对象的多个字段
redis_client.hset('user:1', 'name', '张三')         # 设置单个字段
redis_client.hset('user:1', 'age', 25)               # 设置另一个字段
redis_client.hget('user:1', 'name')                   # 获取单个字段值
redis_client.hgetall('user:1')                         # 获取所有字段和值(返回字典)
redis_client.hdel('user:1', 'age')                     # 删除字段

# 3. List(列表)------ 适合做队列/最新消息列表
redis_client.lpush('news:list', '新闻1', '新闻2')    # 从左侧插入(最新在前)
redis_client.rpush('news:list', '新闻3')              # 从右侧插入
redis_client.lrange('news:list', 0, 9)                # 获取索引0-9的元素(最新10条)
redis_client.ltrim('news:list', 0, 99)                # 只保留前100条

# 4. Set(集合)------ 适合去重场景
redis_client.sadd('user:1:favorites', 'house:1', 'house:2')  # 添加收藏
redis_client.smembers('user:1:favorites')                      # 获取所有收藏
redis_client.sismember('user:1:favorites', 'house:1')          # 判断是否已收藏

# 5. Sorted Set(有序集合)------ 适合排行榜
redis_client.zadd('house:hot', {'house:1': 100, 'house:2': 95})  # 添加带分数的元素
redis_client.zrevrange('house:hot', 0, 9, withscores=True)        # 按分数降序取前10
3.3.2 最新房源接口实现
python 复制代码
# api/house.py(续)

import json
from utils.cache import get_cache, set_cache, delete_cache_pattern

@house_bp.route('/latest', methods=['GET'])
def latest_houses():
    """
    最新房源接口
    按发布时间倒序返回房源列表

    性能优化策略:
    1. 首先查询Redis缓存
    2. 缓存命中则直接返回
    3. 缓存未命中则查询数据库,并将结果写入缓存
    """
    # ==================== 获取分页参数 ====================
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)

    # 参数校验
    if page < 1:
        page = 1
    if per_page < 1 or per_page > 50:
        per_page = 10

    # ==================== 构建缓存键 ====================
    # 缓存键格式: house:latest:{页码}:{每页条数}
    # 使用冒号分隔层级,这是Redis键名的通用命名规范
    cache_key = f'house:latest:{page}:{per_page}'

    # ==================== 第一步:尝试从缓存获取数据 ====================
    cached_data = get_cache(cache_key)

    if cached_data:
        # 缓存命中,直接返回缓存数据
        # 可以在响应中标记数据来源(调试用)
        return jsonify(
            code=200,
            msg='查询成功(缓存)',
            data=cached_data
        )

    # ==================== 第二步:缓存未命中,查询数据库 ====================
    # 按创建时间降序排列,获取最新的房源
    query = House.query.filter(
        House.is_active == True           # 只查询上架的房源
    ).order_by(
        House.create_time.desc()          # 按创建时间降序
    )

    # 执行分页查询
    pagination = query.paginate(page=page, per_page=per_page, error_out=False)

    # 格式化房源列表
    house_list = [house.to_dict() for house in pagination.items]

    # 构建结果数据
    result = {
        'total': pagination.total,
        'page': pagination.page,
        'per_page': pagination.per_page,
        'pages': pagination.pages,
        'has_prev': pagination.has_prev,
        'has_next': pagination.has_next,
        'houses': house_list
    }

    # ==================== 第三步:将结果写入缓存 ====================
    # 第一页缓存时间更长(因为访问频率最高)
    if page == 1:
        set_cache(cache_key, result, expire=300)   # 5分钟
    else:
        set_cache(cache_key, result, expire=120)    # 2分钟

    # 返回JSON响应
    return jsonify(code=200, msg='查询成功', data=result)
3.3.3 缓存主动更新(发布新房源时清除缓存)
python 复制代码
# api/house.py(续)

@house_bp.route('/publish', methods=['POST'])
def publish_house():
    """
    发布新房源接口
    发布成功后需要清除相关缓存,确保用户能及时看到最新数据
    """
    # 获取JSON请求数据
    data = request.get_json()

    # 参数校验(使用字典解构获取参数)
    title = data.get('title')
    price = data.get('price')
    area = data.get('area')
    room_type = data.get('room_type')
    area_id = data.get('area_id')
    description = data.get('description', '')  # 描述默认为空字符串
    image = data.get('image', '')

    # 基本参数非空校验
    if not all([title, price, area, room_type]):
        # all() 函数检查可迭代对象中是否所有元素都为真
        return jsonify(code=400, msg='缺少必填参数'), 400

    # 创建房源对象
    new_house = House(
        title=title,
        price=price,
        area=area,
        room_type=room_type,
        area_id=area_id,
        description=description,
        image=image
    )

    # 添加到数据库会话并提交
    try:
        db.session.add(new_house)     # 添加到会话
        db.session.commit()           # 提交事务

        # ===== 清除最新房源缓存 =====
        # 新房源发布后,所有"最新房源"的缓存都可能过期
        # 使用通配符匹配删除所有相关缓存
        delete_cache_pattern('house:latest:*')

        return jsonify(code=200, msg='发布成功', data=new_house.to_dict())

    except Exception as e:
        # 发生异常时回滚事务
        # 回滚会撤销当前会话中所有未提交的更改
        db.session.rollback()
        return jsonify(code=500, msg=f'发布失败:{str(e)}'), 500

知识点 --- Flask中的异常处理:

python 复制代码
"""
===== SQLAlchemy 事务管理知识点 =====

1. db.session.add(obj)     --- 将对象加入会话(暂不写入数据库)
2. db.session.commit()     --- 提交事务,将所有更改写入数据库
3. db.session.rollback()   --- 回滚事务,撤销所有未提交的更改
4. db.session.flush()      --- 将更改发送到数据库但不提交(可以获取自增ID等)
5. db.session.delete(obj)  --- 标记对象为删除状态(需要commit生效)

事务最佳实践:
- 在try/except块中操作数据库
- 成功时commit,失败时rollback
- 不要在循环中频繁commit,应批量提交
"""

3.4 前端实现

html 复制代码
<!-- templates/house/latest.html -->
{% extends "base.html" %}

{% block title %}最新房源 - 智能租房{% endblock %}

{% block style %}
<style>
    /* 页面头部区域 */
    .page-header {
        max-width: 1200px;
        margin: 20px auto 0;
        padding: 0 20px;
        display: flex;
        justify-content: space-between;  /* 两端对齐 */
        align-items: center;
    }

    /* 页面标题 */
    .page-header h2 {
        font-size: 24px;
        color: #212529;
        font-weight: 700;
    }

    /* 标题装饰线 */
    .page-header h2::before {
        content: '';
        display: inline-block;
        width: 4px;
        height: 22px;
        background: #007bff;
        margin-right: 10px;
        vertical-align: middle;          /* 垂直居中 */
        border-radius: 2px;
    }

    /* 更新时间提示 */
    .page-header .update-time {
        font-size: 13px;
        color: #adb5bd;
    }

    /* 房源网格布局 */
    .house-grid {
        max-width: 1200px;
        margin: 20px auto;
        padding: 0 20px;
        display: grid;
        /* 自适应网格:每列最小260px,自动填充尽可能多的列 */
        grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
        gap: 20px;                       /* 网格间距 */
    }

    /* 网格卡片 */
    .house-grid .grid-card {
        background: white;
        border-radius: 8px;
        overflow: hidden;
        box-shadow: 0 2px 8px rgba(0,0,0,0.06);
        transition: transform 0.25s ease, box-shadow 0.25s ease;
    }

    .house-grid .grid-card:hover {
        transform: translateY(-4px);
        box-shadow: 0 8px 24px rgba(0,0,0,0.12);
    }

    /* 卡片图片 */
    .grid-card .card-image {
        width: 100%;
        height: 180px;
        object-fit: cover;
        display: block;
    }

    /* 卡片内容区域 */
    .grid-card .card-body {
        padding: 15px;
    }

    /* 卡片标题 */
    .grid-card .card-title {
        font-size: 16px;
        font-weight: 600;
        color: #212529;
        margin-bottom: 8px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* 卡片描述信息 */
    .grid-card .card-text {
        font-size: 13px;
        color: #6c757d;
        margin-bottom: 12px;
    }

    /* 卡片底部:价格+时间 */
    .grid-card .card-footer {
        display: flex;
        justify-content: space-between;
        align-items: flex-end;
    }

    /* 价格 */
    .grid-card .price {
        font-size: 20px;
        font-weight: 700;
        color: #e74c3c;
    }

    .grid-card .price small {
        font-size: 12px;
        font-weight: 400;
        color: #999;
    }

    /* 发布时间 */
    .grid-card .time {
        font-size: 12px;
        color: #adb5bd;
    }

    /* 新标签 */
    .new-badge {
        position: absolute;
        top: 10px;
        left: 10px;
        background: #e74c3c;
        color: white;
        font-size: 12px;
        padding: 2px 8px;
        border-radius: 3px;
        font-weight: 600;
    }

    /* 卡片图片容器(用于定位新标签) */
    .card-image-wrap {
        position: relative;
    }

    /* 加载更多按钮 */
    .load-more {
        display: block;
        max-width: 200px;
        margin: 20px auto 40px;
        padding: 12px 0;
        background: #f8f9fa;
        border: 1px solid #dee2e6;
        border-radius: 6px;
        color: #495057;
        font-size: 14px;
        cursor: pointer;
        text-align: center;
        transition: all 0.2s;
    }

    .load-more:hover {
        background: #007bff;
        color: white;
        border-color: #007bff;
    }
</style>
{% endblock %}

{% block content %}
<!-- 页面头部 -->
<div class="page-header">
    <h2>最新房源</h2>
    <span class="update-time" id="updateTime"></span>
</div>

<!-- 房源网格容器 -->
<div class="house-grid" id="houseGrid">
    <div class="empty-state" style="grid-column: 1/-1;">正在加载...</div>
</div>

<!-- 加载更多按钮(用于无限滚动或手动加载的备用方案) -->
<button class="load-more" id="loadMore" style="display:none;">加载更多</button>
{% endblock %}

{% block script %}
<script>
document.addEventListener('DOMContentLoaded', function() {

    const houseGrid = document.getElementById('houseGrid');
    const loadMoreBtn = document.getElementById('loadMore');
    const updateTimeEl = document.getElementById('updateTime');

    let currentPage = 1;       // 当前页码
    let isLoading = false;     // 是否正在加载(防止重复请求)
    let allLoaded = false;     // 是否已加载全部数据

    // ==================== 加载最新房源 ====================
    function loadLatestHouses(append = false) {
        // 如果正在加载或已全部加载,直接返回
        if (isLoading || allLoaded) return;

        isLoading = true; // 设置加载标志

        // 显示加载提示
        if (!append) {
            houseGrid.innerHTML = '<div class="empty-state" style="grid-column:1/-1">正在加载...</div>';
        }

        // 发起API请求
        fetch(`/api/house/latest?page=${currentPage}&per_page=12`)
            .then(response => response.json())  // 解析JSON
            .then(result => {
                if (result.code === 200) {
                    const houses = result.data.houses;

                    if (houses.length === 0 && currentPage === 1) {
                        // 第一页就无数据
                        houseGrid.innerHTML = '<div class="empty-state" style="grid-column:1/-1">暂无房源信息</div>';
                    } else if (houses.length === 0) {
                        // 后续页无数据,说明已全部加载
                        allLoaded = true;
                        loadMoreBtn.textContent = '没有更多了';
                    } else {
                        if (!append) {
                            // 首次加载,清空容器
                            houseGrid.innerHTML = '';
                        }
                        // 渲染房源卡片
                        renderGridCards(houses);

                        // 检查是否还有更多数据
                        if (result.data.has_next) {
                            loadMoreBtn.style.display = 'block';
                        } else {
                            allLoaded = true;
                            loadMoreBtn.style.display = 'none';
                        }
                    }

                    // 更新时间显示
                    updateTimeEl.textContent = `更新于 ${new Date().toLocaleString('zh-CN')}`;
                }

                isLoading = false; // 重置加载标志
            })
            .catch(error => {
                console.error('加载失败:', error);
                isLoading = false;
                houseGrid.innerHTML = '<div class="empty-state" style="grid-column:1/-1">加载失败,请刷新重试</div>';
            });
    }

    // ==================== 渲染网格卡片 ====================
    /**
     * 将房源数据渲染为网格卡片形式
     * 使用 document.createElement 创建DOM元素(更安全)
     * @param {Array} houses - 房源数组
     */
    function renderGridCards(houses) {
        // 使用 DocumentFragment 提高DOM操作性能
        // DocumentFragment 是轻量级文档片段,不会触发重排
        const fragment = document.createDocumentFragment();

        houses.forEach(house => {
            // 创建卡片容器
            const card = document.createElement('div');
            card.className = 'grid-card';  // 设置CSS类

            // 判断是否为新房源(3天内发布的标记"新")
            const publishDate = new Date(house.create_time);
            const now = new Date();
            // 计算时间差(毫秒):当前时间 - 发布时间
            const diffDays = (now - publishDate) / (1000 * 60 * 60 * 24);
            // 如果时间差小于3天,显示"新"标签
            const newBadge = diffDays < 3 ? '<span class="new-badge">新</span>' : '';

            // 使用 innerHTML 设置卡片内容
            card.innerHTML = `
                <div class="card-image-wrap">
                    ${newBadge}
                    <img class="card-image"
                         src="${house.image}"
                         alt="${house.title}"
                         loading="lazy"
                         onerror="this.src='/static/images/default.jpg'">
                </div>
                <div class="card-body">
                    <h4 class="card-title" title="${house.title}">${house.title}</h4>
                    <p class="card-text">${house.room_type} | ${house.area}㎡ | ${house.region}</p>
                    <div class="card-footer">
                        <span class="price">${house.price}<small>元/月</small></span>
                        <span class="time">${house.create_time}</span>
                    </div>
                </div>
            `;

            // 为卡片添加点击事件,跳转到详情页
            card.addEventListener('click', function() {
                // 使用 window.location.href 跳转页面
                window.location.href = `/house/${house.id}`;
            });

            // 鼠标样式设为手型(表示可点击)
            card.style.cursor = 'pointer';

            // 将卡片添加到文档片段
            fragment.appendChild(card);
        });

        // 一次性将所有卡片添加到DOM(只触发一次重排)
        houseGrid.appendChild(fragment);
    }

    // ==================== 加载更多按钮事件 ====================
    loadMoreBtn.addEventListener('click', function() {
        currentPage++;           // 页码加1
        loadLatestHouses(true);  // append=true 追加模式
    });

    // ==================== 滚动加载(可选) ====================
    // 实现滚动到底部自动加载更多
    // throttle 节流处理,避免频繁触发
    let scrollTimer = null;
    window.addEventListener('scroll', function() {
        // 清除之前的定时器(实现节流)
        if (scrollTimer) clearTimeout(scrollTimer);

        // 延迟150ms执行
        scrollTimer = setTimeout(function() {
            // 判断是否滚动到页面底部附近
            // scrollTop: 已滚动的高度
            // clientHeight: 可视区域高度
            // scrollHeight: 页面总高度
            const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
            const clientHeight = document.documentElement.clientHeight;
            const scrollHeight = document.documentElement.scrollHeight;

            // 距离底部200px时触发加载
            if (scrollTop + clientHeight >= scrollHeight - 200) {
                if (!isLoading && !allLoaded) {
                    currentPage++;
                    loadLatestHouses(true);
                }
            }
        }, 150);
    });

    // 初始化加载
    loadLatestHouses();
});
</script>
{% endblock %}

知识点 --- 性能优化相关:

python 复制代码
"""
===== 图片懒加载 (loading="lazy") =====

HTML原生属性,当图片进入可视区域时才开始加载
减少首屏加载的数据量,提升页面响应速度

<img src="image.jpg" loading="lazy" alt="图片">

===== DocumentFragment 文档片段 =====

当需要向DOM中批量插入多个元素时:
- 直接多次appendChild:每次都会触发页面重排(reflow),性能差
- 使用DocumentFragment:先在内存中构建所有节点,一次性插入DOM,只触发一次重排

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
    const div = document.createElement('div');
    fragment.appendChild(div);
}
document.body.appendChild(fragment);  // 一次性插入

===== 节流(Throttle)与防抖(Debounce) =====

节流:固定时间间隔内只执行一次(适合scroll事件)
防抖:连续触发时只执行最后一次(适合搜索框输入)

// 防抖示例:用户停止输入300ms后才发起搜索
let debounceTimer = null;
searchInput.addEventListener('input', function() {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
        fetchHouseList();  // 发起搜索
    }, 300);
});
"""

四、热点房源列表页

4.1 功能说明

热点房源列表页根据浏览量、收藏量、评分等指标综合排序,展示平台最受欢迎的房源。

核心功能:

  • 基于浏览量/热度评分排序
  • Redis Sorted Set维护热度排行榜
  • 定时更新热度数据
  • 热点房源列表展示(排行榜样式)

4.2 接口设计

请求方式: GET /api/house/hot

请求参数:

参数名 类型 必填 说明
count int 返回条数,默认20(最大50)

响应格式:

json 复制代码
{
    "code": 200,
    "msg": "查询成功",
    "data": {
        "houses": [
            {
                "rank": 1,
                "id": 5,
                "title": "望京SOHO旁精装三居室",
                "price": 12000,
                "area": 120,
                "room_type": "3室2厅",
                "region": "朝阳区",
                "image": "/static/images/house5.jpg",
                "view_count": 2580,
                "create_time": "2024-02-10"
            }
        ]
    }
}

4.3 后端实现

4.3.1 热度计算与排行榜维护
python 复制代码
# utils/hot_score.py
"""
热点房源工具模块
使用Redis Sorted Set维护房源热度排行榜
"""
from exts import redis_client
from models import House

# 热度排行榜的Redis键名
HOT_RANKING_KEY = 'house:hot:ranking'


def update_hot_ranking():
    """
    更新热点房源排行榜

    算法:
    1. 查询所有上架房源的浏览量
    2. 计算热度分数(可加入时间衰减因子)
    3. 写入Redis Sorted Set

    热度分数公式:
    score = view_count * 1.0 + favorite_count * 5.0 - day_diff * 0.1

    说明:
    - 浏览量权重1.0(基础指标)
    - 收藏量权重5.0(收藏比浏览更有价值)
    - 时间衰减:每过一天扣0.1分(老房源热度自然下降)
    """
    from datetime import datetime, timedelta
    from sqlalchemy import func

    # 查询所有上架房源
    houses = House.query.filter(House.is_active == True).all()

    # 先清除旧的排行榜数据
    # delete() 删除key
    redis_client.delete(HOT_RANKING_KEY)

    # 遍历房源,计算热度分数
    for house in houses:
        # 计算距今天数
        # datetime.now() - house.create_time 得到 timedelta 对象
        # .days 属性获取天数差
        day_diff = (datetime.now() - house.create_time).days

        # 计算热度分数
        # max(0, ...) 确保分数不为负数
        score = max(0,
            house.view_count * 1.0       # 浏览量 * 1.0
            - day_diff * 0.1              # 时间衰减
        )

        # zadd() 向Sorted Set中添加成员
        # 参数格式: {成员: 分数}
        redis_client.zadd(HOT_RANKING_KEY, {str(house.id): score})

    # 只保留前100名(节省内存)
    # zremrangebyrank() 移除指定排名范围外的成员
    # 0到-1表示保留排名0到最后一个(即全部保留)
    # 这里保留排名0到99(前100个)
    redis_client.zremrangebyrank(HOT_RANKING_KEY, 0, -101)

    print(f'热点排行榜更新完成,共{len(houses)}条房源')


def get_hot_house_ids(count=20):
    """
    从Redis获取热点房源ID列表

    参数:
        count (int): 获取条数

    返回:
        list: 房源ID列表(按热度降序)
    """
    # zrevrange() 按分数从高到低返回成员
    # 参数: key, start, stop (闭区间,0-based索引)
    # withscores=True 同时返回分数
    result = redis_client.zrevrange(HOT_RANKING_KEY, 0, count - 1, withscores=True)

    # result格式: [('house_id_1', score_1), ('house_id_2', score_2), ...]
    # 提取ID列表
    house_ids = [int(item[0]) for item in result]

    return house_ids
4.3.2 定时任务配置
python 复制代码
# tasks/hot_task.py
"""
定时任务模块
使用APScheduler实现定时更新热点排行榜
"""
from apscheduler.schedulers.background import BackgroundScheduler
from utils.hot_score import update_hot_ranking

def init_scheduler(app):
    """
    初始化定时任务调度器

    参数:
        app: Flask应用实例
    """
    # 创建后台调度器
    scheduler = BackgroundScheduler()

    # 添加定时任务
    # 参数说明:
    #   func: 要执行的函数
    #   trigger: 触发类型 ('interval'=固定间隔, 'cron'=定时, 'date'=一次性)
    #   minutes: 间隔分钟数
    #   id: 任务唯一标识(用于管理)
    scheduler.add_job(
        func=update_hot_ranking,         # 执行更新排行榜的函数
        trigger='interval',              # 固定间隔触发
        minutes=30,                      # 每30分钟执行一次
        id='update_hot_ranking',         # 任务ID
        replace_existing=True            # 如果同ID任务已存在则替换
    )

    # 启动调度器
    scheduler.start()

    # 应用关闭时关闭调度器
    import atexit
    atexit.register(lambda: scheduler.shutdown())
4.3.3 热点房源接口实现
python 复制代码
# api/house.py(续)

from utils.hot_score import get_hot_house_ids

@house_bp.route('/hot', methods=['GET'])
def hot_houses():
    """
    热点房源接口
    基于热度排行榜返回热门房源列表

    处理流程:
    1. 从Redis热度排行榜获取房源ID列表
    2. 根据ID列表从数据库批量查询房源详情
    3. 按热度排序返回结果
    """
    # ==================== 获取参数 ====================
    count = request.args.get('count', 20, type=int)

    # 限制返回数量在1-50之间
    count = max(1, min(count, 50))

    # ==================== 尝试从缓存获取 ====================
    cache_key = f'house:hot:list:{count}'
    cached_data = get_cache(cache_key)

    if cached_data:
        return jsonify(code=200, msg='查询成功(缓存)', data=cached_data)

    # ==================== 从Redis排行榜获取ID ====================
    hot_ids = get_hot_house_ids(count)

    if not hot_ids:
        # 排行榜为空,直接从数据库查询(降级方案)
        # 按浏览量排序
        houses = House.query.filter(
            House.is_active == True
        ).order_by(
            House.view_count.desc()
        ).limit(count).all()

        house_list = []
        for index, house in enumerate(houses):
            house_dict = house.to_dict()
            house_dict['rank'] = index + 1  # 添加排名
            house_list.append(house_dict)
    else:
        # ==================== 根据ID列表批量查询 ====================
        # SQLAlchemy 的 in_() 查询
        # 注意:in_() 查询不会保持原始顺序
        houses = House.query.filter(
            House.id.in_(hot_ids),
            House.is_active == True
        ).all()

        # 将查询结果转为字典,以ID为键(便于按排行榜顺序获取)
        house_dict_map = {house.id: house for house in houses}

        # 按排行榜顺序组装结果(保持热度排序)
        house_list = []
        rank = 1
        for house_id in hot_ids:
            if house_id in house_dict_map:
                house = house_dict_map[house_id]
                house_dict = house.to_dict()
                house_dict['rank'] = rank     # 添加排名
                house_list.append(house_dict)
                rank += 1

    # ==================== 构建结果并缓存 ====================
    result = {
        'houses': house_list
    }

    # 热点数据缓存较短时间(10分钟),因为热度变化较频繁
    set_cache(cache_key, result, expire=600)

    return jsonify(code=200, msg='查询成功', data=result)

知识点 --- SQLAlchemy in_() 查询与排序保持:

python 复制代码
"""
===== in_() 查询不保持顺序的问题 =====

当使用 filter(House.id.in_([3, 1, 5])) 时,
数据库返回的结果可能按主键排序(1, 3, 5),而非传入的顺序(3, 1, 5)。

解决方案一:Python端手动排序(如上面代码所示)
  用字典建立映射,然后按原始顺序遍历

解决方案二:使用数据库的 FIELD() 函数(MySQL特有)
  from sqlalchemy import text
  order_expr = text("FIELD(house.id, 3, 1, 5)")
  query.order_by(order_expr)

解决方案三:使用 CASE WHEN(通用SQL)
  from sqlalchemy import case
  ordering = case(
      {3: 0, 1: 1, 5: 2},  # id -> 排序值
      value=House.id
  )
  query.order_by(ordering)
"""

4.4 前端实现

html 复制代码
<!-- templates/house/hot.html -->
{% extends "base.html" %}

{% block title %}热点房源 - 智能租房{% endblock %}

{% block style %}
<style>
    /* 页面标题区域 */
    .page-header {
        max-width: 1200px;
        margin: 20px auto 0;
        padding: 0 20px;
    }

    .page-header h2 {
        font-size: 24px;
        color: #212529;
        font-weight: 700;
    }

    /* 热点房源列表容器 */
    .hot-list {
        max-width: 1200px;
        margin: 20px auto;
        padding: 0 20px;
    }

    /* 每个热点房源项 */
    .hot-item {
        display: flex;
        align-items: center;
        background: white;
        border-radius: 8px;
        margin-bottom: 12px;
        padding: 15px;
        box-shadow: 0 2px 6px rgba(0,0,0,0.04);
        transition: transform 0.2s, box-shadow 0.2s;
        cursor: pointer;
    }

    .hot-item:hover {
        transform: translateX(4px);
        box-shadow: 0 4px 12px rgba(0,0,0,0.1);
    }

    /* 排名数字 */
    .hot-item .rank {
        width: 40px;
        height: 40px;
        border-radius: 50%;               /* 圆形 */
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 18px;
        font-weight: 700;
        margin-right: 15px;
        flex-shrink: 0;                   /* 不允许缩小 */
    }

    /* 前三名特殊颜色 */
    .hot-item .rank.top-1 {
        background: linear-gradient(135deg, #FFD700, #FFA500); /* 金色渐变 */
        color: white;
    }

    .hot-item .rank.top-2 {
        background: linear-gradient(135deg, #C0C0C0, #A0A0A0); /* 银色渐变 */
        color: white;
    }

    .hot-item .rank.top-3 {
        background: linear-gradient(135deg, #CD7F32, #8B4513); /* 铜色渐变 */
        color: white;
    }

    /* 4名以后的排名样式 */
    .hot-item .rank.normal {
        background: #f0f0f0;
        color: #666;
    }

    /* 房源图片 */
    .hot-item .hot-image {
        width: 120px;
        height: 90px;
        object-fit: cover;
        border-radius: 6px;
        margin-right: 15px;
        flex-shrink: 0;
    }

    /* 房源信息区域(flex: 1占满剩余空间) */
    .hot-item .hot-info {
        flex: 1;
        min-width: 0;                     /* 防止flex子项溢出 */
    }

    /* 房源标题 */
    .hot-item .hot-title {
        font-size: 16px;
        font-weight: 600;
        color: #212529;
        margin-bottom: 6px;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* 房源详情 */
    .hot-item .hot-detail {
        font-size: 13px;
        color: #6c757d;
        margin-bottom: 4px;
    }

    /* 浏览量显示(带火焰图标效果) */
    .hot-item .hot-views {
        font-size: 12px;
        color: #e74c3c;
    }

    /* 热度条形图 */
    .hot-item .heat-bar {
        width: 80px;
        height: 6px;
        background: #f0f0f0;
        border-radius: 3px;
        margin-right: 20px;
        overflow: hidden;
        flex-shrink: 0;
    }

    /* 热度条填充部分 */
    .hot-item .heat-bar .heat-fill {
        height: 100%;
        border-radius: 3px;
        background: linear-gradient(90deg, #ff6b6b, #ee5a24);
        transition: width 0.6s ease;     /* 宽度过渡动画 */
    }

    /* 价格区域 */
    .hot-item .hot-price {
        text-align: right;
        min-width: 100px;
        flex-shrink: 0;
    }

    .hot-item .hot-price .price-value {
        font-size: 20px;
        font-weight: 700;
        color: #e74c3c;
    }

    .hot-item .hot-price .price-unit {
        font-size: 12px;
        color: #999;
    }
</style>
{% endblock %}

{% block content %}
<div class="page-header">
    <h2>热点房源排行</h2>
</div>

<div class="hot-list" id="hotList">
    <div class="empty-state">正在加载...</div>
</div>
{% endblock %}

{% block script %}
<script>
document.addEventListener('DOMContentLoaded', function() {

    const hotList = document.getElementById('hotList');

    // ==================== 加载热点房源 ====================
    fetch('/api/house/hot?count=20')
        .then(response => response.json())
        .then(result => {
            if (result.code === 200) {
                renderHotList(result.data.houses);
            } else {
                hotList.innerHTML = `<div class="empty-state">${result.msg}</div>`;
            }
        })
        .catch(error => {
            console.error('加载失败:', error);
            hotList.innerHTML = '<div class="empty-state">加载失败</div>';
        });

    // ==================== 渲染热点列表 ====================
    /**
     * 渲染热点房源列表(排行榜样式)
     * @param {Array} houses - 带rank字段的房源数组
     */
    function renderHotList(houses) {
        if (!houses || houses.length === 0) {
            hotList.innerHTML = '<div class="empty-state">暂无热点房源</div>';
            return;
        }

        // 计算最大浏览量(用于热度条百分比计算)
        // Math.max() 配合展开运算符获取数组最大值
        const maxViews = Math.max(...houses.map(h => h.view_count || 0));

        // 使用 map 生成HTML数组
        const htmlArr = houses.map(house => {
            // 根据排名决定排名数字的CSS类
            let rankClass = 'normal'; // 默认样式
            if (house.rank === 1) rankClass = 'top-1';       // 第1名金色
            else if (house.rank === 2) rankClass = 'top-2';  // 第2名银色
            else if (house.rank === 3) rankClass = 'top-3';  // 第3名铜色

            // 计算热度条宽度百分比
            // 如果最大浏览量为0,则百分比为0
            const heatPercent = maxViews > 0
                ? Math.round((house.view_count / maxViews) * 100) // 四舍五入取整
                : 0;

            return `
                <div class="hot-item" onclick="location.href='/house/${house.id}'">
                    <!-- 排名 -->
                    <div class="rank ${rankClass}">${house.rank}</div>

                    <!-- 房源图片 -->
                    <img class="hot-image"
                         src="${house.image}"
                         alt="${house.title}"
                         onerror="this.src='/static/images/default.jpg'">

                    <!-- 房源信息 -->
                    <div class="hot-info">
                        <h4 class="hot-title">${house.title}</h4>
                        <p class="hot-detail">${house.room_type} | ${house.area}㎡ | ${house.region}</p>
                        <p class="hot-views">浏览 ${house.view_count} 次</p>
                    </div>

                    <!-- 热度条 -->
                    <div class="heat-bar">
                        <div class="heat-fill" style="width: ${heatPercent}%"></div>
                    </div>

                    <!-- 价格 -->
                    <div class="hot-price">
                        <div class="price-value">${house.price}</div>
                        <div class="price-unit">元/月</div>
                    </div>
                </div>
            `;
        });

        hotList.innerHTML = htmlArr.join('');

        // ===== 热度条动画效果 =====
        // 延迟设置宽度,触发CSS transition动画
        // 先将所有热度条宽度设为0,再设为实际值
        setTimeout(() => {
            const heatFills = document.querySelectorAll('.heat-fill');
            heatFills.forEach(fill => {
                const targetWidth = fill.style.width;  // 保存目标宽度
                fill.style.width = '0%';               // 先设为0
                // 使用 requestAnimationFrame 确保浏览器已渲染
                requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                        fill.style.width = targetWidth; // 恢复目标宽度,触发过渡动画
                    });
                });
            });
        }, 100);
    }
});
</script>
{% endblock %}

五、综合知识点汇总与补充案例

5.1 Flask路由知识点

python 复制代码
"""
===== Flask路由系统完整知识 =====
"""
from flask import Flask, request, jsonify, redirect, url_for

app = Flask(__name__)

# 1. 基本路由
@app.route('/')
def index():
    return '首页'

# 2. 支持多种HTTP方法
@app.route('/api/data', methods=['GET', 'POST'])
def handle_data():
    if request.method == 'GET':
        return jsonify(msg='GET请求')
    elif request.method == 'POST':
        data = request.get_json()  # 获取JSON请求体
        return jsonify(msg='POST请求', received=data)

# 3. URL路径参数(动态路由)
@app.route('/house/<int:house_id>')
def house_detail(house_id):
    # <int:house_id> 中 int 是转换器,确保参数为整数
    # 如果传入非整数,Flask自动返回404
    return f'房源详情:ID={house_id}'

# 4. 多个URL指向同一视图
@app.route('/about')
@app.route('/about-us')
def about():
    return '关于我们'

# 5. URL尾部斜杠控制
@app.route('/trailing/')
def trailing():
    # 访问 /trailing 或 /trailing/ 都能匹配
    return '带斜杠'

@app.route('/no-trailing')
def no_trailing():
    # 只匹配 /no-trailing
    # 访问 /no-trailing/ 会返回404
    return '不带斜杠'

# 6. 请求钩子(Hook)
@app.before_request
def before_request_func():
    """
    在每次请求处理之前执行
    常用于:用户认证检查、日志记录、数据库连接
    """
    print(f'收到请求:{request.method} {request.path}')

@app.after_request
def after_request_func(response):
    """
    在每次请求处理之后执行
    可以修改响应对象
    常用于:添加CORS头、日志记录
    """
    # 添加跨域访问控制头
    response.headers['Access-Control-Allow-Origin'] = '*'
    return response

@app.teardown_request
def teardown_request_func(exception):
    """
    请求结束时执行(即使发生异常也会执行)
    用于清理资源
    """
    if exception:
        print(f'请求异常:{exception}')

5.2 Blueprint(蓝图)模块化知识点

python 复制代码
"""
===== 蓝图完整使用示例 =====

蓝图是Flask中实现模块化组织的机制
可以将路由、模板、静态文件等按功能模块分组
"""

# ===== 文件结构 =====
"""
project/
├── app.py              # 应用工厂
├── config.py           # 配置文件
├── exts.py             # 扩展初始化
├── api/
│   ├── __init__.py
│   ├── house.py        # 房源蓝图
│   ├── user.py         # 用户蓝图
│   └── common.py       # 公共蓝图
├── models/
│   ├── __init__.py
│   ├── house.py        # 房源模型
│   └── user.py         # 用户模型
├── templates/          # 模板目录
│   ├── base.html
│   └── house/
│       ├── search.html
│       ├── latest.html
│       └── hot.html
└── static/             # 静态文件
    ├── css/
    ├── js/
    └── images/
"""

# ===== api/house.py =====
from flask import Blueprint

# 创建蓝图实例
# Blueprint(name, import_name, url_prefix, template_folder, static_folder)
#   name: 蓝图名称,用于 url_for() 中定位资源
#   import_name: 通常传 __name__
#   url_prefix: 该蓝图所有路由的URL前缀
#   template_folder: 蓝图专属模板目录(可选)
house_bp = Blueprint(
    'house',
    __name__,
    url_prefix='/api/house'
)

@house_bp.route('/search')
def search():
    return '搜索'

@house_bp.route('/latest')
def latest():
    return '最新'

# ===== api/user.py =====
user_bp = Blueprint(
    'user',
    __name__,
    url_prefix='/api/user'
)

@user_bp.route('/login')
def login():
    return '登录'

# ===== app.py - 注册蓝图 =====
def create_app():
    app = Flask(__name__)
    app.config.from_object('config.Config')

    # 注册多个蓝图
    app.register_blueprint(house_bp)
    app.register_blueprint(user_bp)

    return app

# ===== url_for 使用蓝图 =====
from flask import url_for

# 在模板或Python代码中生成URL
# 格式: url_for('蓝图名.视图函数名', 参数)
# url_for('house.search')        -> '/api/house/search'
# url_for('house.detail', id=5)  -> '/api/house/detail/5'

5.3 SQLAlchemy高级查询知识点

python 复制代码
"""
===== SQLAlchemy 查询API完整示例 =====
"""
from sqlalchemy import and_, or_, not_, func, desc, asc
from models import House, Region
from exts import db

# ========== 基础查询 ==========
# 查询所有记录
all_houses = House.query.all()              # 返回列表

# 查询第一条记录
first_house = House.query.first()           # 返回对象或None

# 根据主键查询
house = House.query.get(1)                   # 返回对象或None

# ========== 条件过滤 ==========
# filter() - 通用过滤(支持各种运算符)
houses = House.query.filter(House.price > 3000).all()

# filter_by() - 简单等值过滤(仅支持等于)
houses = House.query.filter_by(is_active=True, area_id=1).all()

# ========== 组合条件 ==========
# AND条件(链式filter等价于AND)
houses = House.query.filter(
    House.price >= 2000,
    House.price <= 5000,
    House.is_active == True
).all()

# 显式AND
houses = House.query.filter(
    and_(House.price >= 2000, House.area_id == 1)
).all()

# OR条件
houses = House.query.filter(
    or_(House.area_id == 1, House.area_id == 2)
).all()

# NOT条件
houses = House.query.filter(
    not_(House.area_id == 3)
).all()

# ========== 聚合查询 ==========
# COUNT - 计数
count = db.session.query(func.count(House.id)).scalar()  # 总房源数

# 按区域分组计数
area_stats = db.session.query(
    House.area_id,                        # 分组字段
    func.count(House.id).label('count')   # 计数,label命名
).group_by(House.area_id).all()           # GROUP BY

# AVG - 平均值
avg_price = db.session.query(
    func.avg(House.price)
).filter(House.is_active == True).scalar()

# MAX / MIN
max_price = db.session.query(func.max(House.price)).scalar()
min_price = db.session.query(func.min(House.price)).scalar()

# SUM - 求和
total_area = db.session.query(
    func.sum(House.area)
).filter(House.area_id == 1).scalar()

# ========== 子查询 ==========
# 查找高于平均价格的房源
# 先构建子查询
avg_subquery = db.session.query(
    func.avg(House.price)
).filter(House.is_active == True).scalar_subquery()

# 主查询中使用子查询结果
expensive_houses = House.query.filter(
    House.price > avg_subquery
).all()

# ========== 关联查询 ==========
# 方法1:使用join
results = db.session.query(House, Region).join(
    Region, House.area_id == Region.id   # JOIN条件
).filter(
    Region.name == '朝阳区'
).all()

# 方法2:使用relationship(需要在模型中定义relationship)
houses = House.query.join(House.region).filter(
    Region.name == '朝阳区'
).all()

# ========== 分页查询 ==========
# 方法1:使用paginate()(推荐)
pagination = House.query.paginate(page=1, per_page=10, error_out=False)
# pagination.items     当前页数据
# pagination.total     总数
# pagination.pages     总页数

# 方法2:手动分页(使用offset和limit)
page = 1
per_page = 10
houses = House.query.order_by(House.create_time.desc()) \
    .offset((page - 1) * per_page) \
    .limit(per_page) \
    .all()

# ========== 排序 ==========
# 单字段排序
houses = House.query.order_by(House.price.asc()).all()    # 升序
houses = House.query.order_by(House.price.desc()).all()   # 降序

# 多字段排序
houses = House.query.order_by(
    House.area_id.asc(),        # 先按区域升序
    House.price.desc()          # 再按价格降序
).all()

# ========== 更新操作 ==========
house = House.query.get(1)
if house:
    house.price = 6000        # 修改属性
    house.title = '新标题'
    db.session.commit()       # 提交更新

# 批量更新
House.query.filter(House.is_active == False).update(
    {'view_count': 0}         # 将所有下架房源浏览量归零
)
db.session.commit()

# ========== 删除操作 ==========
house = House.query.get(1)
if house:
    db.session.delete(house)  # 标记删除
    db.session.commit()       # 提交删除

# 批量删除
House.query.filter(House.is_active == False).delete()
db.session.commit()

5.4 JSON响应规范知识点

python 复制代码
"""
===== Flask统一JSON响应格式 =====
"""
from flask import jsonify

# ===== 方法1:直接使用jsonify =====
@app.route('/api/example1')
def example1():
    return jsonify(
        code=200,
        msg='成功',
        data={'name': '张三'}
    )
    # 输出: {"code": 200, "data": {"name": "张三"}, "msg": "成功"}

# ===== 方法2:自定义响应封装 =====
class ApiResponse:
    """
    统一API响应类
    封装常用的响应格式,保持一致性
    """
    @staticmethod
    def success(data=None, msg='操作成功'):
        """成功响应"""
        return jsonify(code=200, msg=msg, data=data)

    @staticmethod
    def error(msg='操作失败', code=400):
        """错误响应"""
        return jsonify(code=code, msg=msg, data=None), code

    @staticmethod
    def unauthorized(msg='请先登录'):
        """未授权响应"""
        return jsonify(code=401, msg=msg, data=None), 401

    @staticmethod
    def forbidden(msg='无权限访问'):
        """禁止访问响应"""
        return jsonify(code=403, msg=msg, data=None), 403

    @staticmethod
    def not_found(msg='资源不存在'):
        """资源不存在响应"""
        return jsonify(code=404, msg=msg, data=None), 404

    @staticmethod
    def server_error(msg='服务器内部错误'):
        """服务器错误响应"""
        return jsonify(code=500, msg=msg, data=None), 500

# 使用示例
@app.route('/api/house/<int:id>')
def get_house(id):
    house = House.query.get(id)
    if not house:
        return ApiResponse.not_found('房源不存在')
    return ApiResponse.success(house.to_dict(), '查询成功')

5.5 Jinja2模板引擎知识点

html 复制代码
<!--
===== Jinja2模板语法完整示例 =====
-->

<!-- ===== 变量输出 ===== -->
<!-- {{ 变量名 }} 输出变量值 -->
<h1>{{ title }}</h1>
<p>{{ user.name }}</p>
<p>{{ house.price }}</p>

<!-- 过滤器:用管道符 | 调用 -->
<p>{{ name | upper }}</p>              <!-- 转大写 -->
<p>{{ name | lower }}</p>              <!-- 转小写 -->
<p>{{ name | capitalize }}</p>         <!-- 首字母大写 -->
<p>{{ content | truncate(100) }}</p>    <!-- 截取前100个字符 -->
<p>{{ number | round(2) }}</p>         <!-- 保留2位小数 -->
<p>{{ price | int }}</p>               <!-- 转整数 -->
<p>{{ items | length }}</p>            <!-- 列表长度 -->
<p>{{ text | default('暂无') }}</p>    <!-- 默认值 -->
<p>{{ html | safe }}</p>               <!-- 不转义HTML(危险!慎用) -->

<!-- ===== 条件判断 ===== -->
{% if user %}
    <p>欢迎,{{ user.name }}</p>
{% elif guest %}
    <p>欢迎访客</p>
{% else %}
    <p>请登录</p>
{% endif %}

<!-- 比较运算 -->
{% if house.price > 5000 %}
    <span class="expensive">高价房源</span>
{% endif %}

<!-- 逻辑运算 -->
{% if house.is_active and house.price < 3000 %}
    <span class="tag">低价好房</span>
{% endif %}

<!-- ===== 循环遍历 ===== -->
<!-- 遍历列表 -->
{% for house in houses %}
    <div class="house-card">
        <!-- loop.index: 当前循环次数(从1开始) -->
        <!-- loop.index0: 当前循环索引(从0开始) -->
        <!-- loop.first: 是否第一次循环 -->
        <!-- loop.last: 是否最后一次循环 -->
        <!-- loop.length: 序列总长度 -->
        <span class="rank">{{ loop.index }}</span>
        <h3>{{ house.title }}</h3>
        <p>{{ house.price }}元/月</p>
    </div>
{% else %}
    <!-- for...else: 当列表为空时执行 -->
    <p>暂无房源数据</p>
{% endfor %}

<!-- 遍历字典 -->
{% for key, value in config.items() %}
    <p>{{ key }}: {{ value }}</p>
{% endfor %}

<!-- ===== 模板继承 ===== -->

<!-- base.html(父模板) -->
<!DOCTYPE html>
<html>
<head>
    <title>{% block title %}默认标题{% endblock %}</title>
    {% block style %}{% endblock %}
</head>
<body>
    <header>网站导航</header>

    <!-- content block: 子模板必须填充 -->
    {% block content %}{% endblock %}

    <footer>版权信息</footer>

    {% block script %}{% endblock %}
</body>
</html>

<!-- child.html(子模板) -->
{% extends "base.html" %}

{% block title %}子页面标题{% endblock %}

{% block content %}
    <h1>子页面内容</h1>
{% endblock %}

<!-- ===== 宏(Macro)------ 可复用的模板片段 ===== -->
{% macro render_house_card(house) %}
    <div class="house-card">
        <h3>{{ house.title }}</h3>
        <p>{{ house.price }}元/月</p>
        <p>{{ house.area }}㎡ | {{ house.room_type }}</p>
    </div>
{% endmacro %}

<!-- 调用宏 -->
{% for house in houses %}
    {{ render_house_card(house) }}
{% endfor %}

<!-- ===== 包含其他模板 ===== -->
{% include "components/pagination.html" %}

<!-- ===== 设置变量 ===== -->
{% set total_price = 0 %}
{% for item in items %}
    {% set total_price = total_price + item.price %}
{% endfor %}
<p>总价: {{ total_price }}</p>

5.6 完整分页后端工具封装

python 复制代码
# utils/pagination.py
"""
分页工具模块
封装通用的分页查询逻辑,方便多处复用
"""

def paginate_query(query, page, per_page):
    """
    通用分页查询函数

    参数:
        query: SQLAlchemy查询对象
        page (int): 当前页码
        per_page (int): 每页记录数

    返回:
        dict: 包含分页信息和数据的字典
    """
    # 参数校验和默认值
    page = max(1, page)              # 页码最小为1
    per_page = max(1, min(per_page, 50))  # 每页1-50条

    # 执行分页查询
    pagination = query.paginate(
        page=page,
        per_page=per_page,
        error_out=False
    )

    # 返回结构化结果
    return {
        'total': pagination.total,         # 总记录数
        'page': pagination.page,           # 当前页码
        'per_page': pagination.per_page,   # 每页条数
        'pages': pagination.pages,         # 总页数
        'has_prev': pagination.has_prev,   # 有上一页?
        'has_next': pagination.has_next,   # 有下一页?
        'items': pagination.items          # 当前页数据列表
    }


def generate_page_range(current_page, total_pages, window=3):
    """
    生成分页导航的页码范围

    参数:
        current_page (int): 当前页码
        total_pages (int): 总页数
        window (int): 当前页左右各显示几个页码

    返回:
        list: 页码字典列表,包含page和active字段

    示例:
        generate_page_range(5, 10, window=2)
        返回: [{'page': 3, 'active': False}, {'page': 4, 'active': False},
               {'page': 5, 'active': True}, {'page': 6, 'active': False},
               {'page': 7, 'active': False}]
    """
    # 计算起始页和结束页
    start = max(1, current_page - window)
    end = min(total_pages, current_page + window)

    # 生成页码列表
    pages = []
    for i in range(start, end + 1):
        pages.append({
            'page': i,
            'active': i == current_page  # 是否为当前页
        })

    return pages

5.7 综合案例:完整的搜索页面路由

python 复制代码
# api/house.py - 完整的搜索页面渲染路由

from flask import render_template, request
from models import House, Region

@house_bp.route('/search-page')
def search_page():
    """
    搜索房源页面(服务端渲染版本)

    使用Jinja2模板直接渲染HTML页面
    数据直接嵌入HTML中返回给浏览器

    与API+AJAX方式的区别:
    - 服务端渲染:SEO友好,首屏加载快,但交互需要刷新页面
    - 前后端分离:交互流畅,但SEO不友好(可通过SSR解决)
    """
    # ===== 获取搜索参数 =====
    area_id = request.args.get('area_id', type=int)
    price_min = request.args.get('price_min', type=float)
    price_max = request.args.get('price_max', type=float)
    room_type = request.args.get('room_type', type=str)
    sort = request.args.get('sort', 'newest', type=str)
    page = request.args.get('page', 1, type=int)
    per_page = 10

    # ===== 构建查询 =====
    query = House.query.filter(House.is_active == True)

    if area_id:
        query = query.filter(House.area_id == area_id)
    if price_min is not None:
        query = query.filter(House.price >= price_min)
    if price_max is not None:
        query = query.filter(House.price <= price_max)
    if room_type:
        query = query.filter(House.room_type == room_type)

    # 排序
    if sort == 'price_asc':
        query = query.order_by(House.price.asc())
    elif sort == 'price_desc':
        query = query.order_by(House.price.desc())
    else:
        query = query.order_by(House.create_time.desc())

    # 分页
    pagination = query.paginate(page=page, per_page=per_page, error_out=False)

    # ===== 获取所有区域(用于筛选下拉框) =====
    regions = Region.query.order_by(Region.id).all()

    # ===== 渲染模板 =====
    # render_template() 第一个参数是模板路径(相对于templates目录)
    # 后续参数为传递给模板的变量
    return render_template(
        'house/search.html',
        # 传递给模板的变量
        pagination=pagination,    # 分页对象(包含items、page等)
        regions=regions,          # 区域列表
        # 回填搜索条件
        selected_area_id=area_id,
        selected_price_min=request.args.get('price_min', ''),
        selected_price_max=request.args.get('price_max', ''),
        selected_room_type=room_type or '',
        selected_sort=sort
    )

六、本章知识点总结

知识领域 核心知识点 关键API/方法
路由管理 Blueprint蓝图、路由装饰器、URL参数 Blueprint(), @route(), url_prefix
请求处理 参数获取、类型转换、校验 request.args.get(), request.get_json()
数据库查询 条件过滤、排序、分页、聚合 filter(), order_by(), paginate(), func.count()
缓存策略 Redis String/Sorted Set、缓存键设计、过期策略 get(), set(), expire(), zadd(), zrevrange()
JSON响应 统一格式、状态码、序列化 jsonify(), to_dict()
模板渲染 模板继承、变量输出、循环、过滤器 render_template(), {% extends %}, {% for %}
前端交互 Fetch API、DOM操作、分页组件、懒加载 fetch(), createElement(), innerHTML
性能优化 缓存、分页、懒加载、节流防抖、DocumentFragment 定时任务、loading="lazy"requestAnimationFrame
错误处理 try/except、事务回滚、参数校验 db.session.rollback(), ApiResponse
定时任务 APScheduler调度器 BackgroundScheduler, add_job()
相关推荐
极光代码工作室8 小时前
基于NLP的论文关键词提取系统
python·深度学习·自然语言处理·nlp
Wang ruoxi8 小时前
Pygame 小游戏——数独
开发语言·python·pygame
吠品8 小时前
处理 Python 类继承中那些变来变去的初始化参数
linux·前端·python
阿寻寻8 小时前
【人工智能学习-20260608】什么是生成式AI?
人工智能·学习
会Tk矩阵群控的小木8 小时前
小红书矩阵软件:基于Python+ADB的多设备批量管理自动化脚本实战
运维·python·adb·矩阵·自动化·新媒体运营·个人开发
复园电子8 小时前
企业PDF批量盖章开发集成指南:API对接OA/LIMS系统,高并发落地实战
开发语言·python·pdf
sensen_kiss8 小时前
CPT304 SoftwareEngineeringII 软件工程 2 Pt.5 软件复用(Software Reuse)
学习·软件工程
石山代码8 小时前
类型限定符的底层实现原理是什么?
python
xian_wwq8 小时前
【学习笔记】倾斜摄影、高斯泼溅(3DGS)、点云与数字孪生“族谱”全盘点
笔记·学习·3d
伶俜668 小时前
# ✨ 零基础学 ArkUI 动画(专题一):从 animateTo 到 Lottie,一篇吃透全部
学习·华为·harmonyos