zoukankan      html  css  js  c++  java
  • 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方法, 发送重新查询的请求.
  • 相关阅读:
    依赖注入简单解释
    设计模式
    Git 命令使用
    手机版自适应
    自定义属性的添加
    innerText Textcontent浏览器兼容代码
    获取间的内容
    密码长度为6-10的判断
    模拟输入框
    排他功能
  • 原文地址:https://www.cnblogs.com/gcxblogs/p/13589972.html
Copyright © 2011-2022 走看看