Flask-爱家租房项目ihome-08-首页和搜索页
首页
进入首页之后, 除了需要右上角展示当前登录用户名外, 还需要轮播展示订购次数最多的5个房屋的图片.
首页后端逻辑编写
在房屋模块的视图文件ihome/api_1_0/houses.py
中添加获取主页房屋信息的视图.
# ihome/api_1_0/houses.py
@api.route('/index/houses')
def get_index_houses():
redis_key = 'index_houses'
# 缓存中获取数据
try:
data_json = redis_connect.get(redis_key).decode()
except Exception as e:
current_app.logger.error(e)
data_json = None
# 不存在则查询数据库
if not data_json:
try:
# 根据房屋的订购次数倒序, 取前5个房屋
houses = Houses.query.filter(Houses.default_image_url is not None).order_by(
Houses.order_count.desc()).limit(constants.INDEX_HOUSES_COUNT).all()
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='获取房屋信息异常')
# 提取房屋标题/图片/价格
data = [{'title': house.title, 'img_url': house.default_image_url, 'price': house.price, 'id': house.id} for
house in houses]
# 转化为json
data_json = json.dumps(data)
# 存入redis缓存中
redis_connect.setex(redis_key, constants.INDEX_HOUSES_EXPIRES, data_json)
return f'{{"errno": "0", "data": {data_json}}}', 200, {'Content-Type': 'application/json'}
注:
- 主页是最经常被访问的页面, 因此通常也需要设置缓存
- 降序排序为
模型类.字段.desc()
, 获取前几条使用limit
首页前段逻辑编写
在主页对应的js文件/static/js/index.js
中添加获取主页信息的ajax代码, 一共有三个请求, 获取用户/获取房屋图片/获取城区信息
// /static/js/index.js
$(document).ready(function(){
//发送ajax获取登录信息
$.get("/api/v1.0/sessions", function (resp) {
if (resp.errno == '0'){
//已登录,显示登录用户名
$(".user-info>.user-name").html(resp.data.name);
$(".user-info").show();
}else{
//未登录,显示登录注册框
$(".top-bar>.register-login").show();
}
}, "json")
//发送ajax请求, 获取房屋信息
$.get('/api/v1.0/index/houses', function (resp) {
if (resp.errno == '0'){
//获取成功
//设置页面图片
$('.swiper-wrapper').html(template('index-houses', {houses: resp.data}));
//轮播图
var mySwiper = new Swiper ('.swiper-container', {
loop: true,
autoplay: 2000,
autoplayDisableOnInteraction: false,
pagination: '.swiper-pagination',
paginationClickable: true
});
}else {
//获取失败
alert(resp.errmsg);
}
}, 'json');
//发送ajax请求获取地区信息
$.get('api/v1.0/areas', function (resp) {
if (resp.errno == '0'){
//获取成功
//设置城区信息
$('.area-list').html(template('index-areas', {areas: resp.data}));
//设置城区的点击事件
$('.area-list a').click(function (e) {
//展示选择的城区
$('#area-btn').html($(this).html());
//给搜索按钮添加选择的地区属性, 因为搜索按钮点击后会从自身获取搜索条件
$('.search-btn').attr('area-id', $(this).attr('area-id'));
$('.search-btn').attr('area-name', $(this).html());
//隐藏城区选择框
$('#area-modal').modal("hide");
});
}else {
//获取失败
alert(resp.errmsg);
}
}, 'json');
注:
- 轮播图的对象的创建需要放在回调函数中才有效, 因为如果放在回调函数外部, 那么该代码会先于回调函数执行, 而此时图片等信息并没有获得, 所以轮播效果无效
- 同理, 设置城区的点击事件也需要放在获取城区的ajax的回调函数中
点击首页的搜索按钮后, 应该跳转到搜索页面, 同时将首页的城区和入住日期的搜索条件传过去, 这里就把搜索条件拼接到了url中, 给按钮添加一个点击事件
// /static/js/index.js
function goToSearchPage(th) {
var url = "/search.html?";
url += ("aid=" + $(th).attr("area-id"));
url += "&";
var areaName = $(th).attr("area-name");
if (undefined == areaName) areaName="";
url += ("aname=" + areaName);
url += "&";
url += ("sd=" + $(th).attr("start-date"));
url += "&";
url += ("ed=" + $(th).attr("end-date"));
location.href = url;
}
注:
-
该点击事件获取查询条件时, 是从自身的属性中获取的, 因此在城区选择框和日期选择框选择完成之后, 需要给搜索框添加相应的搜索属性, 如:
//给搜索按钮添加选择的地区属性, 因为搜索按钮点击后会从自身获取搜索条件 $('.search-btn').attr('area-id', $(this).attr('area-id')); $('.search-btn').attr('area-name', $(this).html());
搜索结果页
在首页点击搜索按钮后, 需要在搜索页面展示相应的搜索结果, 查询条件就拼在url中, /search.html?aid=城区id&aname=城区名字&sd=起始日期&ed=结束日期
, 如:
同时在搜索结果页上方也还有三个搜索选择器, 可以继续选择入住时间, 城区, 和排序方式进行再次搜索
搜索页后端逻辑编写
搜索时同样是会往后端发送ajax搜索请求, 请求的url为: /search/houses?aid=城区id&sd=起始日期&ed=结束日期&page=页数&sorted_by=排序方式
@api.route('/search/houses')
def get_search_houses():
# 获取查询条件
area_id = request.args.get('aid') # 地区ID
start_date = request.args.get('sd') # 起始日期
end_date = request.args.get('ed') # 结束日期
page = request.args.get('page') # 页数
sorted_by = request.args.get('sorted_by') # 排序
# 先从缓存中获取结果
redis_key = f'search_{area_id}_{start_date}_{end_date}_{sorted_by}'
try:
info_json = redis_connect.hget(redis_key, page).decode()
except Exception as e:
current_app.logger.error(e)
info_json = None
# 缓存不存在则查询数据库
if not info_json:
# 处理条件, 出现异常则认为条件为空
# 地区ID
try:
area_id = int(area_id)
except Exception as e:
area_id = None
# 日期
try:
start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d') if start_date else None
end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d') if end_date else None
except Exception as e:
return jsonify(errno=RET.PARAMERR, errmsg='日期格式错误')
if start_date and end_date and start_date > end_date:
return jsonify(errno=RET.PARAMERR, errmsg='起始日期不能大于终止日期')
# 页码
try:
page = int(page)
except Exception as e:
page = 1
# 排序
if sorted_by not in constants.SORTED_BY:
sorted_by = 'latest'
# 查询数据库
# 这里的起始日期和结束日期是需要针对订单模型类Orders的起始时间和结束时间来查的, 需要排除掉在参数查询时间段内已经出租了的房源
# 但是ORM中不太好使用子查询, 因此编写查询的思路就和包含子查询的SQL的执行过程差不多, 先执行子查询内部(查询订单表), 再执行外部(查询房屋表)
# 先从订单模型类中查出在查询时间段内已经租出去的房屋
# 使用列表把查询条件动态汇总起来
filter_param = [Orders.status == 'ACCEPTED']
if start_date and end_date:
# 若条件起止日期都存在, 那么找订单的起止日期包含在条件的起止日期内的订单
filter_param.append(start_date <= Orders.start_date)
filter_param.append(Orders.end_date <= end_date)
elif start_date:
# 若条件的开始日期存在, 结束日期为空, 那么只需要找订单的结束日期不小于条件的开始日期的订单
filter_param.append(Orders.end_date >= start_date)
elif end_date:
# 若条件的开始日期为空, 结束日期存在, 那么只需要找订单的开始日期不大于条件的结束日期的订单
filter_param.append(Orders.start_date <= end_date)
# 若条件的起止日期都为空, 那么只需要找有顾客正在入住的订单, 即状态为accepted
# 通过拆包把查询条件拆开进行查询
try:
ordered_orders = Orders.query.filter(*filter_param).all()
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='获取订单数据异常')
# 获取订单的id
ordered_house_ids = [order.house_id for order in ordered_orders]
# 再查询房屋模型类, 把上面查到的房屋排除掉就好了
try:
houses_query_set = Houses.query.filter(Houses.area_id == area_id if area_id else Houses.area_id,
Houses.id.notin_(ordered_house_ids))
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='获取房屋数据异常')
# 排序
if sorted_by == 'new':
houses_query_set = houses_query_set.order_by(Houses.created_date.desc())
elif sorted_by == 'booking':
houses_query_set = houses_query_set.order_by(Houses.order_count.desc())
elif sorted_by == 'price-inc':
houses_query_set = houses_query_set.order_by(Houses.price)
else:
houses_query_set = houses_query_set.order_by(Houses.price.desc())
# 分页, 把结果按每页per_page条记录进行分页, 获取第page页的分页对象pagination
try:
pagination = houses_query_set.paginate(page=page, per_page=constants.SEARCH_HOUSES_PAGE_COUNT)
except Exception as e:
current_app.logger.error(e)
return jsonify(errno=RET.DBERR, errmsg='数据分页异常')
# 获取页面数据和总页数
houses = pagination.items
total_page = pagination.pages
# 提取房屋信息
house_info = [house.get_search_info() for house in houses]
info_dict = {'house_info': house_info, 'current_page': page, 'total_page': total_page}
# 转为json
info_json = json.dumps(info_dict)
# 存入缓存中, 存在多条命令需要保持一致性, 所以使用pipeline
try:
# 创建pipeline对象
pipe = redis_connect.pipeline()
# 往管道添加命令
pipe.hset(redis_key, page, info_json)
pipe.expire(redis_key, constants.SEARCH_HOUSES_EXPIRES)
# 统一执行命令
pipe.execute()
except Exception as e:
current_app.logger.error(e)
return f'{{"errno": "0", "data": {info_json}}}'
注:
-
搜索结果页面也是经常被访问的, 因此设置了缓存, key为查询条件的拼接, 值为hash类型
- 这里的key中并没有把页数也放进去, 而是将值采用hash的方式存储, hash的键为页数, 值为具体的查询结果
- 因为如果key中也放入页数的话, 那么同样的查询条件, 第一页和第二页会存在两条redis记录, 由于两次查询的间隔导致他们分别有着自己的过期时间, 那么可能存在第一页已经过期, 因此再次查询时第一页是最新的记录信息, 而第二页还没有过期, 此时第二页还是老的信息, 那么就可能发生这两页的信息出现重叠或者冲突. 因此两页的信息应该是需要同步过期或刷新的, 所以这里就用了hash类型, 将所有查到的页数都放在一条redis记录中, 用页码作为hash的键, 页面内容作为hash的值
-
查询条件都是可以为空的, 因此需要考虑到条件为空的情况, 这里如果为空则赋值为None
-
对于查询条件获取的时候都是字符串类型的, 使用时需要转为数字或者日期类型
-
使用
datetime
模块的.datetime.strptime(字符串类型, 字符串日期格式)
将字符串类型转化为日期类型,.strftime(日期类型, 字符串日期格式)
将日期类型转化为字符串类型 -
排序设置了标识,
'new'
: 按创建时间倒叙,'booking'
: 按订单数量倒叙,'price-inc'
: 按价格升序,'price-des'
: 按价格降序 -
查询条件中的起止日期, 作用是查询在这段时间内可以出租的房源, 也就是说这段时间内没有订单预定的房源, 因此这个条件是限制了订单模块的起止时间, 所以按正常的sql应该简单写作:
select ih.id, ih.title, ih.default_img_url from ih_houses ih where ih.id not in (select io.house_id from ih_orders io where nvl(p_start_date,io.start_date) between io.start_date and io.end_date and nvl(p_end_date, io.end_date between io.start_date and io.end_date) and io.status in ('ACCEPTED'))
但是在
SQLAlchemy
的ORM
中, 不太好直接一句话把上面的子查询表达出来, 所以可以根据sql语句的执行顺序依次把相应的ORM语句写出来, 比如首先查询这段时间内已经被租出去且正在住的订单, 然后再查询房屋时把前面查到的订单房源给排除掉, 就可以查到想要的结果了. -
在ORM的查询语句中, 如果存在比较多的查询条件, 可以使用一个列表把这些查询条件先临时保存起来, 最后在执行查询的语句中使用
*
进行拆包, 将多个语句并列放在同一纬度上查询. 如:# 列表中添加查询条件 filter_param = [Orders.status == 'ACCEPTED'] filter_param.append(Orders.start_date <= end_date) # 查询时进行拆包 ordered_orders = Orders.query.filter(*filter_param).all()
-
注意上面添加进列表的东西, 并不是这个表达式的执行结果(True/Flase), 而是一个二进制语句对象
[<sqlalchemy.sql.elements.BinaryExpression object at 0x7f3cf2bcde10>]
, 所以最后拆包的时候才能把这个条件语句还原回去.之所以是一个二进制语句对象, 是因为所有的
>
|<
|==
|>=
|<=
符号, 都会执行符号前面的对象的魔法方法, 对应的为__gt__
|__lt__
|__eq__
|__ge__
|__lq__
, 而模型类的字段重写了这些方法, 让这些方法返回了语句对象我们也可以自定义一个类, 重写这些方法, 可以看到下面的a对象不管等于什么值返回的都是1:
In [6]: class A: ...: def __eq__(self, obj): ...: return 1 ...: In [7]: a = A() In [8]: a.__eq__(1) Out[8]: 1 In [9]: a.__eq__(2) Out[9]: 1 In [10]: a == 1 Out[10]: 1 In [11]: a == 3 Out[11]: 1
-
模型类.字段.notin_()
方法表示某字段的值不在...范围内 -
SQLAlchemy的分页, 使用查询结果集对象的
.paginate(page=第几页, per_page=每页多少条数据)
方法可以得到第n页的分页对象.使用分页对象的
.items
属性可以的到该页里面的具体内容,.page
属性可以得到总页数, 更多分页的属性和方法可以查看官网: http://www.pythondoc.com/flask-sqlalchemy/api.html#flask.ext.sqlalchemy.Pagination -
redis的
pipeline
可以将多条redis命令暂时存放在一起, 最后一起提交执行, 使用方法为先创建pipeline
对象, 再添加语句, 最后执行语句try: # 创建pipeline对象 pipe = redis_connect.pipeline() # 往管道添加命令 pipe.hset(redis_key, page, info_json) pipe.expire(redis_key, constants.SEARCH_HOUSES_EXPIRES) # 统一执行命令 pipe.execute() except Exception as e: ......
搜索页前端逻辑编写
前端需要实现的功能:
- 进入搜索页后, 根据url的参数调用ajax请求执行查询, 并将查询结果展示出来
- 将查询结果进行分页查询, 默认查询第一页, 根据ajax返回的查询结果判断是否还有下一页, 如果有, 那么当屏幕向下滑动时, 滑到一定程度则发送下一页的ajax请求查询结果, 直到没有下一页
- 在选择完页面顶部的三个选择框之后, 自动进行重新根据筛选条件进行查询
编辑搜索页对应的js文件search.js
添加发送ajax查询的方法
//static/js/search.js
//初始化页面全局变量
var curr_page = 1;
var next_page = 1;
var total_page = 1;
var house_data_querying = true; //表示正在查询过程中, 则此时不能再发送查询请求
//定义发送搜索请求的方法
function send_ajax(action){
//获取html中的搜索条件
var areaId = $('.filter-area li[class="active"]').attr('area-id');
var startDate = $("#start-date").val();
var endDate = $("#end-date").val();
var sortedBy = $(".filter-sort li[class='active']").attr('sort-key');
//处理查询条件
if (areaId == undefined){
areaId = ''
}
if (startDate == undefined){
startDate = ''
}
if (endDate == undefined){
endDate = ''
}
if (sortedBy == undefined){
sortedBy = 'new'
}
//append追加则查询下一页, 否则重新查询第一页
if (action == 'append'){
page = next_page
}else {
page = 1
}
//发送ajax请求执行查询
var searchUrl ='api/v1.0/search/houses?aid='+areaId+'&sd='+startDate+'&ed='+endDate+'&page='+page+'&sorted_by='+sortedBy;
$.get(searchUrl, function (resp) {
//进入回调函数, 则把查询状态改为false
house_data_querying = false;
if (resp.errno == '0'){
if (resp.data.total_page == 0){
$('.house-list').html('暂时没有符合条件的房源信息')
}else {
//查询成功
total_page = resp.data.total_page;
//根据action参数, 使用模板设置查询结果
if (action == 'append'){
//拼接展示这一页的信息
curr_page = page
$('.house-list').append(template('search-houses', {houses: resp.data.house_info}));
}else{
//重置当前页为1
curr_page = 1
next_page = 1
//重新查询覆盖
$('.house-list').html(template('search-houses', {houses: resp.data.house_info}));
}
}
}else{
//查询失败
alert(resp.errmsg);
}
}, 'json');
}
注:
-
定义了几个全局变量, 程序间可以通过全局变量来进行传值, 省去在方法中手动新增参数.
-
由于查询完结果后, 存在两种情况, 一是在原来的显示结果基础上追加显示这一次查询后的结果, 二是清空原来的显示, 将这一次的查询结果覆盖上去, 所以设置了一个参数action来区分两种行为
-
发送请求时是从三个条件选择框中选择具体的查询条件的, 被选中的条件的
class
属性等于active
.
设置查询页加载时的行为
//static/js/search.js
$(document).ready(function(){
//获取url查询条件
var queryData = decodeQuery();
//提取查询条件
var areaId = queryData["aid"];
var areaName = queryData["aname"];
var startDate = queryData["sd"];
var endDate = queryData["ed"];
//将url的查询日期设置到日期查询框中
$("#start-date").val(startDate);
$("#end-date").val(endDate);
updateFilterDateDisplay();
//url中不存在地区条件地区选择框显示'位置区域'
if (!areaName) areaName = "位置区域";
$(".filter-title-bar>.filter-title").eq(1).children("span").eq(0).html(areaName);
//发送ajax请求查询地区信息
$.get('api/v1.0/areas', function (resp) {
if (resp.errno == '0'){
//获取成功, 添加地区列表html, 将url中的地区ID的li标签添加active属性
for (var i=1; i<Object.keys(resp.data).length+1; i++){
if (parseInt(areaId) == i){
$(".filter-area").append('<li area-id="' + i + '" class="active">' + resp.data[i] + '</li>')
}else{
$(".filter-area").append('<li area-id="' + i + '">' + resp.data[i] + '</li>')
}
}
// 发送查询请求
send_ajax('refresh');
}
}, 'json');
//这一步会在回调函数之前执行, 所以获取不到值, 需要放到上面的回调函数中
// send_ajax('refresh');
// 获取页面显示窗口的高度
var windowHeight = $(window).height();
// 为窗口的滚动添加事件函数
window.onscroll=function(){
// var a = document.documentElement.scrollTop==0? document.body.clientHeight : document.documentElement.clientHeight;
var b = document.documentElement.scrollTop==0? document.body.scrollTop : document.documentElement.scrollTop;
var c = document.documentElement.scrollTop==0? document.body.scrollHeight : document.documentElement.scrollHeight;
// 如果滚动到接近窗口底部
if(c-b<windowHeight+50){
// 如果没有正在向后端发送查询房屋列表信息的请求
if (!house_data_querying) {
// 将正在向后端查询房屋列表信息的标志设置为真,
house_data_querying = true;
// 如果当前页面数还没到达总页数
if(curr_page < total_page) {
// 将要查询的页数设置为当前页数加1
next_page = curr_page + 1;
// 向后端发送请求,查询下一页房屋数据
send_ajax('append');
} else {
house_data_querying = false;
}
}
}
}
//地区选择框的点击事件
$(".filter-item-bar>.filter-area").on("click", "li", function(e) {
if (!$(this).hasClass("active")) {
$(this).addClass("active");
$(this).siblings("li").removeClass("active");
$(".filter-title-bar>.filter-title").eq(1).children("span").eq(0).html($(this).html());
} else {
$(this).removeClass("active");
$(".filter-title-bar>.filter-title").eq(1).children("span").eq(0).html("位置区域");
}
//点击后隐藏选择框
$('.filter-area').removeClass("active");
$(".display-mask").click();
});
//排序选择框的点击事件
$(".filter-item-bar>.filter-sort").on("click", "li", function(e) {
if (!$(this).hasClass("active")) {
$(this).addClass("active");
$(this).siblings("li").removeClass("active");
$(".filter-title-bar>.filter-title").eq(2).children("span").eq(0).html($(this).html());
//点击后隐藏选择框
$('.filter-sort').removeClass("active");
$(".display-mask").click();
}
})
//查询条件底部灰框的点击事件
$(".display-mask").on("click", function(e) {
$(this).hide();
$filterItem.removeClass('active');
updateFilterDateDisplay();
// 执行查询
send_ajax('refresh');
});
})
注:
- 页面一加载就需要根据url中的查询条件设置对应三个条件选择框的属性, 将选中的条件属性
class
设置为active
. - 发送查询ajax请求之前需要发送查询地区的ajax请求, 并需要设置好了地区的html信息后才能发送查询ajax请求, 所以该请求需要放到查询地区的ajax请求的回调函数中. 如果放到回调函数外面, 则获取不到被选中的地区信息.
- 设置了一个窗口滚动函数, 当滚动到一定高度时, 判断是否还有下一页, 如果有, 则发送查询下一页的请求, 并将查询结果添加到当前结果的后面.
- 在时间选择框中选择完时间后, 需要点击一下黑色的背景框
display-mask
, 黑色背景框中的点击事件中就会发送重新查询数据的请求. - 地区选择框和排序选择框选择完之后, 立马隐藏选择框, 并调用黑色背景框的
click
方法, 发送重新查询的请求.