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() |