Flask-爱家租房项目ihome-08-首页和搜索页

首页

进入首页之后, 除了需要右上角展示当前登录用户名外, 还需要轮播展示订购次数最多的5个房屋的图片.

image-20200831080207616

首页后端逻辑编写

在房屋模块的视图文件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'}

注:

  1. 主页是最经常被访问的页面, 因此通常也需要设置缓存
  2. 降序排序为模型类.字段.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');

注:

  1. 轮播图的对象的创建需要放在回调函数中才有效, 因为如果放在回调函数外部, 那么该代码会先于回调函数执行, 而此时图片等信息并没有获得, 所以轮播效果无效
  2. 同理, 设置城区的点击事件也需要放在获取城区的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;
}

注:

  1. 该点击事件获取查询条件时, 是从自身的属性中获取的, 因此在城区选择框和日期选择框选择完成之后, 需要给搜索框添加相应的搜索属性, 如:

    //给搜索按钮添加选择的地区属性, 因为搜索按钮点击后会从自身获取搜索条件
    $('.search-btn').attr('area-id', $(this).attr('area-id'));
    $('.search-btn').attr('area-name', $(this).html());
    

搜索结果页

在首页点击搜索按钮后, 需要在搜索页面展示相应的搜索结果, 查询条件就拼在url中, /search.html?aid=城区id&aname=城区名字&sd=起始日期&ed=结束日期, 如:

image-20200831095345014

同时在搜索结果页上方也还有三个搜索选择器, 可以继续选择入住时间, 城区, 和排序方式进行再次搜索

搜索页后端逻辑编写

搜索时同样是会往后端发送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}}}'

注:

  1. 搜索结果页面也是经常被访问的, 因此设置了缓存, key为查询条件的拼接, 值为hash类型

    • 这里的key中并没有把页数也放进去, 而是将值采用hash的方式存储, hash的键为页数, 值为具体的查询结果
    • 因为如果key中也放入页数的话, 那么同样的查询条件, 第一页和第二页会存在两条redis记录, 由于两次查询的间隔导致他们分别有着自己的过期时间, 那么可能存在第一页已经过期, 因此再次查询时第一页是最新的记录信息, 而第二页还没有过期, 此时第二页还是老的信息, 那么就可能发生这两页的信息出现重叠或者冲突. 因此两页的信息应该是需要同步过期或刷新的, 所以这里就用了hash类型, 将所有查到的页数都放在一条redis记录中, 用页码作为hash的键, 页面内容作为hash的值
  2. 查询条件都是可以为空的, 因此需要考虑到条件为空的情况, 这里如果为空则赋值为None

  3. 对于查询条件获取的时候都是字符串类型的, 使用时需要转为数字或者日期类型

  4. 使用datetime模块的.datetime.strptime(字符串类型, 字符串日期格式)将字符串类型转化为日期类型, .strftime(日期类型, 字符串日期格式)将日期类型转化为字符串类型

  5. 排序设置了标识, 'new': 按创建时间倒叙, 'booking': 按订单数量倒叙, 'price-inc': 按价格升序, 'price-des': 按价格降序

  6. 查询条件中的起止日期, 作用是查询在这段时间内可以出租的房源, 也就是说这段时间内没有订单预定的房源, 因此这个条件是限制了订单模块的起止时间, 所以按正常的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'))
    

    但是在SQLAlchemyORM中, 不太好直接一句话把上面的子查询表达出来, 所以可以根据sql语句的执行顺序依次把相应的ORM语句写出来, 比如首先查询这段时间内已经被租出去且正在住的订单, 然后再查询房屋时把前面查到的订单房源给排除掉, 就可以查到想要的结果了.

  7. 在ORM的查询语句中, 如果存在比较多的查询条件, 可以使用一个列表把这些查询条件先临时保存起来, 最后在执行查询的语句中使用*进行拆包, 将多个语句并列放在同一纬度上查询. 如:

    # 列表中添加查询条件
    filter_param = [Orders.status == 'ACCEPTED']
    filter_param.append(Orders.start_date <= end_date)
    # 查询时进行拆包
    ordered_orders = Orders.query.filter(*filter_param).all()
    
  8. 注意上面添加进列表的东西, 并不是这个表达式的执行结果(True/Flase), 而是一个二进制语句对象[<sqlalchemy.sql.elements.BinaryExpression object at 0x7f3cf2bcde10>], 所以最后拆包的时候才能把这个条件语句还原回去.

    之所以是一个二进制语句对象, 是因为所有的>|<|==|>=|<=符号, 都会执行符号前面的对象的魔法方法, 对应的为__gt__|__lt__|__eq__|__ge__|__lq__, 而模型类的字段重写了这些方法, 让这些方法返回了语句对象

    image-20200831124621699

    我们也可以自定义一个类, 重写这些方法, 可以看到下面的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
    
  9. 模型类.字段.notin_()方法表示某字段的值不在...范围内

  10. SQLAlchemy的分页, 使用查询结果集对象的.paginate(page=第几页, per_page=每页多少条数据)方法可以得到第n页的分页对象.

    使用分页对象的.items属性可以的到该页里面的具体内容, .page属性可以得到总页数, 更多分页的属性和方法可以查看官网: http://www.pythondoc.com/flask-sqlalchemy/api.html#flask.ext.sqlalchemy.Pagination

  11. 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:
        ......
    

搜索页前端逻辑编写

前端需要实现的功能:

  1. 进入搜索页后, 根据url的参数调用ajax请求执行查询, 并将查询结果展示出来
  2. 将查询结果进行分页查询, 默认查询第一页, 根据ajax返回的查询结果判断是否还有下一页, 如果有, 那么当屏幕向下滑动时, 滑到一定程度则发送下一页的ajax请求查询结果, 直到没有下一页
  3. 在选择完页面顶部的三个选择框之后, 自动进行重新根据筛选条件进行查询

编辑搜索页对应的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');
}

注:

  1. 定义了几个全局变量, 程序间可以通过全局变量来进行传值, 省去在方法中手动新增参数.

  2. 由于查询完结果后, 存在两种情况, 一是在原来的显示结果基础上追加显示这一次查询后的结果, 二是清空原来的显示, 将这一次的查询结果覆盖上去, 所以设置了一个参数action来区分两种行为

  3. 发送请求时是从三个条件选择框中选择具体的查询条件的, 被选中的条件的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');
    });

})

注:

  1. 页面一加载就需要根据url中的查询条件设置对应三个条件选择框的属性, 将选中的条件属性class设置为active.
  2. 发送查询ajax请求之前需要发送查询地区的ajax请求, 并需要设置好了地区的html信息后才能发送查询ajax请求, 所以该请求需要放到查询地区的ajax请求的回调函数中. 如果放到回调函数外面, 则获取不到被选中的地区信息.
  3. 设置了一个窗口滚动函数, 当滚动到一定高度时, 判断是否还有下一页, 如果有, 则发送查询下一页的请求, 并将查询结果添加到当前结果的后面.
  4. 在时间选择框中选择完时间后, 需要点击一下黑色的背景框display-mask, 黑色背景框中的点击事件中就会发送重新查询数据的请求.
  5. 地区选择框和排序选择框选择完之后, 立马隐藏选择框, 并调用黑色背景框的click方法, 发送重新查询的请求.
原文地址:https://www.cnblogs.com/gcxblogs/p/13589972.html