logo头像
Snippet 博客主题

ElasticSearch之京东搜索实战

1. 前言

​ 最近学习了有关分布式搜索中间件ElasticSearch(简称ES)相关知识,为了加强对ES基础知识的巩固,我耗时多日重新设计了一套京东商品搜索的小案例。

项目的整体架构前后端分离,前端采用Vue.js作为开发框架,后端用Spring Boot + ElasticSearch来完成搜索商品的API。在此次开发过程中,我将开发如下功能:

  1. 前后端彻底的分离,前端采用Vue开发,后端采用Spring Boot 和ElasticSearch开发
  2. 京东商品数据的爬取采用Python的requests库和PyQuery库开发,并且翻页爬取全量数据
  3. 前端页面功能开发和优化,新增商品数据排序、滚动加载功能、回到顶部功能、异常处理弹框处理

好了,话不多说,让我们就从此刻开始。

效果

2. 前端页面准备

2.1 环境准备

  • node环境安装,读者移步这里自行安装,这里不过多累述,如已安装请直接跳过。

  • 全局安装webpack,最新版为webpack4.x(升级问题比较多),建议安装webpack3.x,如已安装请直接跳过。

    1
    npm install -g webpack@3
  • 全局安装Vue脚手架,如已安装请直接跳过。

    1
    npm install -g @vue/cli

2.2 搭建项目

  • 初始化项目

    1
    2
    vue create jd-search # 创建vue项目
    cd jd-search # 进入到项目根目录
  • 项目配置

    1. 安装axios请求库

      1
      npm install axios --save
    2. 安装qs库用于url序列化

      1
      npm install qs --save
    3. 安装Element-UI组件库

      1
      npm i element-ui -S
    4. 安装Element-UI滚动加载插件

      1
      npm install --save vue-infinite-scroll
  1. 在项目根目录下新建vue.config.js文件,配置项目启动后浏览器自动打开。具体代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /*
    * @Author: tong.li
    * @Date: 2020-12-04 15:23:34
    * @Last Modified by: tong.li
    * @Last Modified time: 2020-12-04 15:23:57
    */
    module.exports = {
    devServer: {
    // 主机
    host: '0.0.0.0',
    // 设置端口
    port: 8088,
    // 自动打开浏览器
    open: true
    }
    }
  • 启动测试

    1
    npm run serve # 启动稍等几秒后会自动打开浏览器,地址为http://127.0.0.1:8088

2.3 页面开发

  • 静态资源导入

    1. 在src/assets目录下新建css,导入style.css到src/assets/css目录下。
    2. 导入favicon.ico图标文件到public目录。
    3. 导入logo.png文件到src/assets目录。
  • 代码编写

    1. 在项目新建src/plugins/element.js,按需导入Element-UI组件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      import Vue from 'vue'
      import { Message, Backtop } from 'element-ui'
      import 'element-ui/lib/theme-chalk/index.css'
      import infiniteScroll from 'vue-infinite-scroll'

      // 挂载滚动加载组件
      Vue.use(infiniteScroll)
      // 挂载回到顶部组件
      Vue.use(Backtop)
      // 消息组件挂载
      Vue.prototype.$message = Message
    2. 在main.js利用vue的生命周期钩子函数初始化数据和基本配置

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      import Vue from 'vue'
      import App from './App.vue'
      import router from './router'
      // 导入CSS样式
      import './assets/css/style.css'
      // 导入axios
      import axios from 'axios'
      // 导入qs用于url序列化
      import qs from 'qs'
      // 导入Element-Ui组件
      import './plugins/element.js'

      // 配置请求根目录
      axios.defaults.baseURL = 'http://localhocddst:9000/jd'
      // 给Vue挂载axios
      Vue.prototype.$http = axios
      // 给Vue挂载qs
      Vue.prototype.$qs = qs

      Vue.config.productionTip = false

      new Vue({
      // 通过mounted生命周期钩子初始化数据
      mounted: function () {
      // 初始化品牌数据
      const initBrandList = [
      {
      id: 1,
      name: '佳能'
      }, {
      id: 2,
      name: '尼康'
      },
      {
      id: 3,
      name: '索尼'cdd
      },
      {
      id: 4,
      name: '哈苏'
      },
      {
      id: 5,
      name: '富士'
      },
      {
      id: 6,
      name: '莱卡'
      },
      {
      id: 7,
      name: '松下'
      },
      {v-if="jdGoodsList !== null && 'list' in jdGoodsList && jdGoodsList !== null"
      id: 8,
      name: '大疆'
      },
      {
      id: 9,
      name: '适马'
      },
      {
      id: 10,
      name: '松典'
      }
      ]
      // 初始化排序数据
      const sortData = [
      {
      id: 0,
      name: '综合',
      defaultDesc: 'true'
      },
      {
      id: 1,
      name: '人气',
      defaultDesc: 'true'
      },v-if="jdGoodsList !== null && 'list' in jdGoodsList && jdGoodsList !== null"
      {
      id: 2,
      name: '价格',
      defaultDesc: 'false'
      }
      ]
      // 初始化商品数据
      const initGoodsData = {
      // 页码
      pageNum: 1,
      // 页大小
      pageSize: 30,
      // 当前页查询的列表个数
      size: 3,
      // 查询总数
      total: 3,
      // 总页数
      pages: 1,
      // 数据
      list: [
      {v-if="jdGoodsList !== null && 'list' in jdGoodsList && jdGoodsList !== null"
      id: 1,
      sku: '71806504602',
      title: '佳能(Canon)EOS R5 全画幅专微旗舰 vlog微单相机 8K视频拍摄 微5 EOS R5',
      imgUrl: 'https://img12.360buyimg.com/n7/jfs/t1/147160/31/16416/248743/5fc60696E78885288/b3a05bc106140bfa.jpg',
      price: 26499.00,
      shopName: '彤哥哥相机铺',
      evaluationCount: 345,
      transactionsCount: 578,
      detailUrl: 'https://item.jd.com/71806504602.html'
      },
      {
      id: 2,
      sku: '57690437524',
      title: '索尼(SONY)a7r4/7RIV/ILCE-7RM4全画幅专业微单相机 单机身(不含镜头) ',
      imgUrl: 'https://img14.360buyimg.com/n7/jfs/t1/146148/22/17013/313056/5fc9c999Ee3f824b0/5dfa0f48b6389d03.jpg',
      price: 19185.00,
      shopName: '彤哥哥相机铺',
      evaluationCount: 781,
      transactionsCount: 233,
      detailUrl: 'https://item.jd.com/57690437524.html'
      },
      {
      id: null,
      sku: '10024727923051',
      title: '拍拍华为HUAWEIP40Pro+5G手机华为二手手机大陆国行陶瓷白8G+256G',
      imgUrl: 'https://img11.360buyimg.com/n7/jfs/t1/155325/20/8861/135429/5fced890Eccce267c/dc50f6ee27eb47ff.jpg',
      price: '5599.00',
      shopName: '拍拍二手官方旗舰店',
      evaluationCount: 16224,
      transactionsCount: 677,
      detailUrl: 'https://item.jd.com/10024727923051.html'
      }
      ]
      }
      localStorage.setItem('initGoodsData', JSON.stringify(initGoodsData))
      localStorage.setItem('initBrandList', JSON.stringify(initBrandList))
      localStorage.setItem('sortData', JSON.stringify(sortData))
      },
      // 通过mounted生命周期钩子清除初始化数据
      destroyed: function () {
      localStorage.removeItem('initGoodsData')
      localStorage.removeItem('brandList')
      localStorage.removeItem('sortData')
      },
      router,
      render: h => h(App)
      }).$mount('#app')
    3. 在components文件下新建Search.vue,编写页面组件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118
      119
      120
      121
      122
      123
      124
      125
      126
      127
      128
      129
      130
      131
      132
      133
      134
      135
      136
      137
      138
      139
      140
      141
      142
      143
      144
      145
      146
      147
      148
      149
      150
      151
      152
      153
      154
      155
      156
      157
      158
      159
      160
      161
      162
      163
      164
      165
      166
      167
      168
      169
      170
      171
      172
      173
      <template>
      <div class="page">
      <div id="mallPage" class=" mallist tmall- page-not-market ">
      <!-- 头部搜索 -->
      <div id="header" class=" header-list-app">
      <div class="headerLayout">
      <div class="headerCon ">
      <!-- Logo-->
      <h1 id="mallLogo">
      <img th:src="../static/images/jdlogo.png" alt="">
      </h1>

      <div class="header-extra">

      <!--搜索-->
      <div id="mallSearch" class="mall-search">
      <form name="searchTop" class="mallSearch-form clearfix">
      <fieldset>
      <legend>天猫搜索</legend>
      <div class="mallSearch-input clearfix">
      <div class="s-combobox" id="s-combobox-685">
      <div class="s-combobox-input-wrap">
      <input type="text" autocomplete="off" v-model="params.keywords" id="mq"
      class="s-combobox-input" aria-haspopup="true" placeholder="请输入关键字">
      </div>
      </div>
      <button type="submit" id="searchbtn" @click.prevent="doSearch(params.keywords)">搜索</button>
      </div>
      </fieldset>
      </form>
      <ul class="relKeyTop">
      <li><a>彤哥相机专场</a></li>
      <li><a>彤哥聊Java</a></li>
      <li><a>彤哥摄影大讲堂</a></li>
      <li><a>彤哥电脑修理铺</a></li>
      <li><a>彤哥图书</a></li>
      </ul>
      </div>
      </div>
      </div>
      </div>
      </div>

      <!-- 商品详情页面 -->
      <div id="content">
      <div class="main">
      <!-- 品牌分类 -->
      <form class="navAttrsForm">
      <div class="attrs j_NavAttrs" style="display:block">
      <div class="brandAttr j_nav_brand">
      <div class="j_Brand attr">
      <div class="attrKey">
      你可能要搜
      </div>
      <div class="attrValues">
      <ul class="av-collapse row-2">
      <li v-for="brand in brandList" :key="brand.id" @click="doSearch(brand.name)">
      <a href="#">{{brand.name}}</a>
      </li>
      </ul>
      </div>
      </div>
      </div>
      </div>
      </form>

      <!-- 排序规则 -->
      <div class="filter clearfix">
      <span v-for="sortItem in sortData" :key="sortItem.id" @click="doSortSearch(sortItem.id,sortItem.defaultDesc)">
      <a class="fSort" :class="{'fSort-cur': sortItem.id == defaultSortNumber}">
      {{sortItem.name}}
      <span v-if="sortItem.id > 1">
      <!-- 排序上标志,阻止向父级冒泡 -->
      <i class="f-ico-triangle-mt" :style="sortViewFlag==1 ? {'border-bottom': '4px solid red'} : {}" @click.stop="doSortSearch(sortItem.id,false)" ></i>
      <!-- 排序下标志,阻止向父级冒泡 -->
      <i class="f-ico-triangle-mb" :style="sortViewFlag==2 ? {'border-top': '4px solid red'} : {}" @click.stop="doSortSearch(sortItem.id,true)"></i>
      </span>
      <span v-else>
      <i class="f-ico-arrow-d"></i>
      </span>
      </a>
      </span>
      </div>
      <!-- 商品详情 -->
      <div class="view grid-nosku" v-if="jdGoodsList !== null && 'list' in jdGoodsList && jdGoodsList !== null">
      <div class="product" v-for="goods in jdGoodsList.list" :key="goods.id">
      <a :href="goods.detailUrl">
      <div class="product-iWrap">
      <!--商品封面-->
      <div class="productImg-wrap">
      <a class="productImg">
      <img :src="goods.imgUrl">
      </a>
      </div>
      <!--价格-->
      <p class="productPrice">
      <em><b>¥</b>{{goods.price}}</em>
      </p>
      <!--标题-->
      <p class="productTitle">
      <!-- 使用v-html原因是高亮渲染 -->
      <a v-html="goods.title"></a>
      </p>
      <!-- 店铺名 -->
      <div class="productShop">
      <span>店铺:{{goods.shopName}} </span>
      </div>
      <!-- 成交信息 -->
      <p class="productStatus">
      <span>月成交<em>{{goods.transactionsCount}}笔</em></span>
      <span>评价 <a>{{goods.evaluationCount}}</a></span>
      </p>
      </div>
      </a>
      </div>
      </div>
      </div>
      </div>
      </div>
      </div>
      </template>

      <script>
      export default {
      name: 'Search',
      data () {
      return {
      sortData: JSON.parse(localStorage.getItem('sortData')),
      brandList: JSON.parse(localStorage.getItem('initBrandList')),
      keywords: '',
      jdGoodsList: JSON.parse(localStorage.getItem('initGoodsData')),
      defaultSortNumber: 0,
      sortViewFlag: 0
      }
      },
      props: {
      },
      methods: {
      doSearch (keywords) {
      // 搜索操作
      if (keywords.trim() === '') {
      return
      }
      // TODO 请求后端接口
      this.jdGoodsList = []
      },
      doSortSearch (id, defaultDesc) {
      // 排序字段置为选中
      if (id === 2) {
      // 排序箭头置为选中
      this.sortViewFlag = !defaultDesc ? 1 : 2
      defaultDesc = (this.sortViewFlag === 2)
      }
      this.defaultSortNumber = id
      // 重新设置排序号和排序规则
      this.params.sortNumber = id
      this.params.isDesc = defaultDesc
      // 执行搜索
      this.doSearch(this.params.keywords)
      }
      },
      watch: {
      'params.keywords': function (newVal) {
      // 若不搜索,则默认显示LocalStorage存储的商品信息
      if (newVal === '') {
      this.jdGoodsList = JSON.parse(localStorage.getItem('initGoodsData'))
      }
      }
      }
      }
      </script>
      <style lang="scss" scope>
      </style>
    4. 在App.vue进行组件渲染

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      <template>
      <div id="app">
      <Search/>
      </div>
      </template>

      <script>
      import Search from './components/Search.vue'

      export default {
      name: 'App',
      components: {
      Search
      }
      }
      </script>

      <style lang="scss">
      </style>
  • 启动测试

    1
    npm run serve # 启动后会自动打开浏览器
  • 查看页面效果

    搜索页面效果图

3. 京东数据爬取

  • 安装ElasticSearch7.x以及Head插件和IK中文分词插件,安装后并启动ElasticSearch

    1
    ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.10.0/elasticsearch-analysis-ik-7.10.0.zip  #安装IK分词器
  • 在git bash命令行执行如下命令来创建索引,自定义mapping,主要是为了设置中文分词

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    curl -X PUT "localhost:9200/jd_goods" -H 'Content-Type: application/json' -d \
    '
    {
    "mappings": {
    "properties": {
    "id": {
    "type": "long"
    },
    "sku": {
    "type": "text",
    "analyzer": "ik_max_word",
    "search_analyzer": "ik_max_word"
    },
    "title": {
    "type": "text",
    "analyzer": "ik_max_word",
    "search_analyzer": "ik_max_word"
    },
    "imgUrl": {
    "type": "text"
    },
    "price": {
    "type": "double"
    },
    "evaluationCount": {
    "type": "integer"
    },
    "transactionsCount": {
    "type": "integer"
    },
    "shopName": {
    "type": "text",
    "analyzer": "ik_max_word",
    "search_analyzer": "ik_max_word"
    },
    "detailUrl": {
    "type": "text"
    }
    }
    }
    }
    '
  • 安装Python环境以及脚本所需要的依赖库

    1
    pip install -r requirements.txt # 安装项目依赖
  • 爬取代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    #!/usr/bin python3.6
    # -*- encoding: utf-8 -*-
    """
    @File : __init__.py.py
    @Description : 爬取京东商品数据
    @Author : tong.li
    @Email : lt_alex@163.com
    @Blog : https://ltalex.gitee.io
    @Time : 2020/12/6 下午8:31
    """
    import requests
    from pyquery import PyQuery as pq
    import time
    from requests.exceptions import RequestException
    import re
    from elasticsearch import Elasticsearch
    from elasticsearch import helpers
    import json
    import random

    # 连接ElasticSearch
    es = Elasticsearch(
    ['127.0.0.1'],
    port=9200
    )


    def request_page(url):
    try:
    # 设置请求头
    headers = {
    'User-Agent' : 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36'
    }
    datas = []
    response = requests.get(url, headers=headers)
    if response.status_code == 200:
    text = response.text
    # 获取京东商品数据
    doc = pq(text)
    lis = doc('div#J_goodsList li[data-sku]').items()

    for index,li in enumerate(lis) :
    goods = {
    # ID=微秒级时间戳+索引
    'id': int(round(time.time() * 1000000)) + index,
    #SKU
    'sku': li.attr('data-sku'),
    # 名称,正则替换掉不需要的空格
    'title': re.sub('\\s{2,}|\\n*','',li('.p-name em').text()),
    # 图片
    'imgUrl': 'https:' + li('.p-img img').attr('data-lazy-img'),
    # 价格
    'price': float(0.0 if li('.p-price i').text() == '免费' else li('.p-price i').text()),
    # 店铺名称
    'shopName': li('.p-shop span').text(),
    # 评价数,随机生成
    'evaluationCount': random.randint(100,99999),
    # 成交数,随机生成
    'transactionsCount': random.randint(100,9999),
    # 跳转到的商品详情地址
    'detailUrl': 'https:' + li('.p-img a').attr('href')
    }
    datas.append(goods)
    return datas
    return None
    except RequestException:
    return None

    # 存储到:ElasticSearch
    def storeToES(datas):
    actions = []
    for data in datas:
    action = {
    '_index':'jd_goods', #索引名称
    '_source': data
    }
    actions.append(action)
    helpers.bulk(es, actions)

    def getData(url):
    datas = request_page(url)
    # 往ES批量插入数据
    if len(datas) != 0 : {
    storeToES(datas)
    }
    print(json.dumps(datas,ensure_ascii=False))
    print('-' * 200)


    if __name__ == '__main__':
    # 爬取京东商品数据
    keywords = "相机" # 关键字信息,相当于京东搜索输入框
    url = 'https://search.jd.com/Search?keyword='+keywords+'&enc=utf-8&page='
    for i in range(100):
    getData(url + str(i+1))
    # 京东有反爬虫限制,爬的太多会有IP或验证码限制,等待0.5毫秒再次请求
    time.sleep(0.5)
  • 运行爬虫

    1
    python jd-goods-crawler.py # jd-goods-crawler.py为上述爬取代码的文件名
  • 启动运行

    京东数据爬取

  • ES查看索引数据

    image-20201208164051555

4. 后端接口开发

4.1 环境准备

4.2 项目搭建

  • 方式一: 通过在线的web工具Spring Initializr初始化Spring Boot项目
  • 方式二: 通过IDEA新建项目中的Spring Initialzr初始化Spring Boot项目

4.3 接口开发

  • 依赖导入,我这里是用Gradle构建的项目

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    plugins {
    id 'org.springframework.boot' version '2.4.0'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    id 'java'
    }

    group = 'com.jd'
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '1.8'

    configurations {
    compileOnly {
    extendsFrom annotationProcessor
    }
    }

    repositories {
    mavenLocal() //直接使用本地maven仓库
    maven { // 镜像源采用阿里云,加速依赖下载
    url 'https://maven.aliyun.com/repository/public'
    }
    mavenCentral() // 使用中央仓库
    }

    dependencies {
    // Web相关依赖
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // ElasticSearch相关依赖
    implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
    // 分页插件
    implementation 'com.github.pagehelper:pagehelper:5.1.2'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    }

    test {
    useJUnitPlatform()
    }
  • 项目配置application.yml

    1
    2
    3
    4
    5
    6
    server:
    port: 9000 # 项目端口号配置
    spring:
    elasticsearch: # ElasticSearch配置
    rest:
    uris: http://127.0.0.1:9200
  • 全局跨域配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    package com.jd.config;

    import org.springframework.context.annotation.Configuration;

    import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    /**
    * @projectName: jd-search-api
    * @className: com.jd.config.GlobalCorsFilter
    * @description: 全局跨域配置
    * @author: tong.li
    * @createTime: 2020/12/10 20:16
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    @Configuration
    public class GlobalCorsFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse response = (HttpServletResponse) res;
    HttpServletRequest reqs = (HttpServletRequest) req;
    response.setHeader("Access-Control-Allow-Origin",reqs.getHeader("Origin"));
    response.setHeader("Access-Control-Allow-Credentials", "true");
    response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE,PUT");
    response.setHeader("Access-Control-Max-Age", "3600");
    response.setHeader("Access-Control-Allow-Headers", "Origin,Content-Type,Accept,token,X-Requested-With");
    chain.doFilter(req, res);
    }
    }
  • JSON序列化配置,处理BigDecimal类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    package com.jd.config;

    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import org.springframework.context.annotation.Configuration;
    import java.io.IOException;
    import java.math.BigDecimal;
    import java.text.DecimalFormat;

    /**
    * @projectName: jd-search-api
    * @className: com.jd.config.BigDecimalSerializer
    * @description: BigDecimal序列化设置
    * @author: tong.li
    * @createTime: 2020/12/10 20:16
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    @Configuration
    public class BigDecimalSerializer extends JsonSerializer<BigDecimal> {

    @Override
    public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
    if (value == null || value.compareTo(BigDecimal.ZERO) == 0) {
    gen.writeString("0.00");
    return;
    }
    DecimalFormat df = new DecimalFormat("#.00");
    gen.writeString(df.format(value));
    }

    }
  • 实体定义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    package com.jd.entity;


    import com.fasterxml.jackson.databind.annotation.JsonSerialize;
    import com.jd.config.BigDecimalSerializer;
    import lombok.Data;
    import java.io.Serializable;
    import java.math.BigDecimal;

    /**
    * @projectName: jd-search-apihlightBuilder;
    import org.elasticsearch.search.fetch.subphase.highlight.Hig
    * @className: com.jd.entity.GoodsDetail
    * @description: 商品详情实体类
    * @author: tong.li
    * @createTime: 2020/12/8 19:14
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    @Data
    public class GoodsDetail implements Serializable {

    private static final long serialVersionUID = 121384327857834L;

    /**
    * 商品ID
    */
    private Long id;

    /**
    * 商品SKU标识
    */
    private String sku;

    /**
    * 商品名称
    */
    private String title;

    /**
    * 商品图片
    */
    private String imgUrl;

    /**
    * 商品价格
    */
    @JsonSerialize(using = BigDecimalSerializer.class)
    private BigDecimal price;

    /**
    * 商品所在的商户名称
    */
    private String shopName;

    /**
    * 评价数
    */
    private Integer evaluationCount;

    /**
    * 成交数,随机生成
    */
    private Integer transactionsCount;

    /**
    * 商品详情页跳转地址
    */
    private String detailUrl;


    /**
    * 搜索排名
    */
    private float score;

    }
  • web表现层:Controller定义接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    package com.jd.controller;

    import com.jd.service.ISearchService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.springframework.web.bind.annotation.RestController;
    import java.time.LocalDateTime;
    import java.util.LinkedHashMap;
    import java.util.Map;

    /**
    * @projectName: jd-search-api
    * @className: com.jd.controller.SearchController
    * @description: 搜索相关接口
    * @author: tong.li
    * @createTime: 2020/12/8 19:21
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    @RestController
    @RequestMapping("/jd")
    @Slf4j
    public class SearchController {

    @Autowired
    private ISearchService searchService;

    /**
    * 搜索API GET请求
    * @param keywords 搜索关键字
    * @param pageNo 分页页码,不传默认查第一页
    * @param pageSize 分页页大小,不传默认查一页查30条
    * @param sortNumber 排序号(排序字段) 0-按默认评分排序,1-按评价数排序,2-按价格排序
    * @param isDesc 是否倒叙排序,默认倒序
    */
    @GetMapping("/search")
    public Map<String, Object> searchGoods(@RequestParam(required = false) String keywords,
    @RequestParam(required = false, defaultValue = "1") Integer pageNo,
    @RequestParam(required = false, defaultValue = "30") Integer pageSize,
    @RequestParam(required = false, defaultValue = "0") Integer sortNumber,
    @RequestParam(required = false, defaultValue = "true") Boolean isDesc) {
    log.info("搜索参数:{},{},{},{},{}",keywords, pageNo, pageSize, sortNumber, isDesc);
    // 为了程序严谨性,处理一下页码和页大小
    pageNo = pageNo <= 0 ? 1 : pageNo;
    pageSize = pageSize <= 0 ? 30 : pageSize;
    // 进行条件搜索
    Map<String, Object> rs = new LinkedHashMap<>();
    rs.put("timestamp", LocalDateTime.now());
    try {
    // 这里为了方便模拟真实项目开发,使用Map组装返回给前端,实际开发中是封装的泛型响应实体类为主
    rs.put("status", 200);
    rs.put("message", "搜索成功");
    rs.put("data",searchService.search(keywords, pageNo, pageSize,sortNumber,isDesc));
    } catch (Exception e) {
    rs.put("status", 500);
    rs.put("message", "搜索失败,服务器异常");
    rs.put("data",null);
    log.error("搜索异常", e);
    }
    return rs;
    }
    }
  • 业务处理层:Service处理搜索逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /**
    * @projectName: jd-search-api
    * @className: com.jd.service.impl.ISearchService
    * @description: 搜索API接口抽象层
    * @author: tong.li
    * @createTime: 2020/12/8 19:41
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    public interface ISearchService {

    PageInfo search(String keywords, Integer pageNo, Integer pageSize,Integer sortNumber, Boolean isDesc) throws Exception;

    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    package com.jd.service.impl;

    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.github.pagehelper.PageInfo;
    import com.jd.entity.GoodsDetail;
    import com.jd.service.ISearchService;
    import org.elasticsearch.action.search.SearchRequest;
    import org.elasticsearch.action.search.SearchResponse;
    import org.elasticsearch.client.RequestOptions;
    import org.elasticsearch.client.RestHighLevelClient;
    import org.elasticsearch.common.text.Text;
    import org.elasticsearch.common.unit.TimeValue;
    import org.elasticsearch.index.query.MatchQueryBuilder;
    import org.elasticsearch.index.query.QueryBuilders;
    import org.elasticsearch.search.SearchHit;
    import org.elasticsearch.search.SearchHits;
    import org.elasticsearch.search.builder.SearchSourceBuilder;
    import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
    import org.elasticsearch.search.fetch.subphase.highlight.HighlightField;
    import org.elasticsearch.search.sort.SortOrder;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    import org.springframework.util.CollectionUtils;
    import org.springframework.util.ObjectUtils;
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;

    /**
    * @projectName: jd-search-api
    * @className: com.jd.service.impl.SearchServiceImpl
    * @description: 搜索实现逻辑层
    * @author: tong.li
    * @createTime: 2020/12/8 1:942
    * @version: v1.0
    * @copyright: 版权所有 © 李彤
    */
    @Service
    public class SearchServiceImpl implements ISearchService {

    /**
    * 京东商品数据索引名称
    */
    private static final String JD_GOODS_INDEX_NAME = "jd_goods";

    /**
    * title字段名
    */
    private static final String FILED_NAME_TITLE = "title";

    /**
    * Jackson序列化
    */
    @Autowired
    private ObjectMapper objectMapper;

    private static Map<Integer, String> sortFiledMap;

    static {
    sortFiledMap = new HashMap<Integer, String>() {
    {
    // 人气按评价数排序
    put(1,"evaluationCount");
    // 按价格排序
    put(2,"price");
    }
    };
    }

    /**
    * 官方建议使用ElasticSearch高级的Rest客户端
    */
    @Autowired
    private RestHighLevelClient restHighLevelClient;

    @Override
    public PageInfo search(String keywords, Integer pageNo, Integer pageSize,Integer sortNumber, Boolean isDesc) throws Exception {
    // 存放搜索后的数据
    List<GoodsDetail> data = new ArrayList<>();
    // 构建搜索请求
    SearchRequest searchRequest = new SearchRequest(JD_GOODS_INDEX_NAME);
    // 构建SearchSourceBuilder方便查询
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    // 分页设置,from是从哪个索引查,size是大小
    sourceBuilder.from((pageNo-1) * pageSize);
    sourceBuilder.size(pageSize);
    // 设置排序,若sortNumber小于等于0,则是默认score排序,若大于1,则按指定字段排序
    if (sortNumber > 0 && sortNumber <= 2) {
    // 这里需要注意,字段进行聚合和排序操作时,如果字段类型是Text类型的,会报错。
    // 由于ES默认情况下会禁用Text字段优化,因此无法进行聚合或排序。若想启用,请将字段的fielddata设置为true,通过取消反转索引来加载字段数据
    // 这种处理方法会占用大量内存,ES官方不建议这样做,建议更改要聚合或排序字段的字段类型
    // ES默认score排序,若使用其他字段排序,score是获取不到的
    sourceBuilder.sort(sortFiledMap.get(sortNumber) ,isDesc ? SortOrder.DESC : SortOrder.ASC);
    }

    // 查询keywords
    if (!ObjectUtils.isEmpty(keywords)) {
    MatchQueryBuilder titleQueryBuilder = QueryBuilders.matchQuery(FILED_NAME_TITLE, keywords);
    sourceBuilder.query(titleQueryBuilder);
    }
    // 高亮设置
    HighlightBuilder highlightBuilder = new HighlightBuilder();
    highlightBuilder.field("title") // 设置高亮字段
    .preTags("<span style='color:red'>") // 设置前置标签以及样式
    .postTags("</span>") // 设置闭合标签以及样式
    .highlighterType("unified") // 设置高亮类型
    .requireFieldMatch(false); // 关闭多个字段高亮
    sourceBuilder.highlighter(highlightBuilder);
    // 设置超时时间为60秒
    sourceBuilder.timeout(new TimeValue(60, TimeUnit.SECONDS));
    // 执行搜索
    searchRequest.source(sourceBuilder);
    SearchResponse searchResponse = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
    // 获取结果
    SearchHits hits = searchResponse.getHits();
    // 创建分页对象
    PageInfo<GoodsDetail> pageInfo = new PageInfo<>(data);
    // 设置分页数据
    pageInfo.setPageNum(pageNo);
    pageInfo.setPageSize(pageSize);
    if (hits == null || hits.getTotalHits().value == 0) {
    return pageInfo;
    }
    // 解析结果
    for (SearchHit hit : hits) {
    String sourceAsString = hit.getSourceAsString();
    GoodsDetail goodsDetail = objectMapper.readValue(sourceAsString, GoodsDetail.class);
    // 获取高亮字段
    Map<String, HighlightField> highlightFields = hit.getHighlightFields();
    HighlightField title = null;
    if (!CollectionUtils.isEmpty(highlightFields)) {
    title = highlightFields.get(FILED_NAME_TITLE);
    }
    if (title != null) {
    // 如果存在高亮字段,解析高亮字段并将原来的字段值覆盖掉
    Text[] fragments = title.getFragments();
    if (fragments != null && fragments.length > 0) {
    goodsDetail.setTitle(fragments[0].string());
    }
    }
    // 设置排名
    goodsDetail.setScore(Float.isNaN(hit.getScore()) ? 0.0f : hit.getScore());
    data.add(goodsDetail);

    }
    // 获取总数
    long total = hits.getTotalHits().value;
    pageInfo.setTotal(total);
    pageInfo.setList(data);
    // 当前页的数量
    pageInfo.setSize(data.size());
    // 总共多少页
    pageInfo.setPages(total== 0 ? 0: (int) (total % pageSize == 0 ? total / pageSize : (total / pageSize) + 1));
    // 是否有下一页
    pageInfo.setHasNextPage(pageInfo.getPageNum() < pageInfo.getPages());
    // 是否有上一页
    pageInfo.setHasPreviousPage(pageInfo.getPageNum() > 1 && pageInfo.getPageNum() <= pageInfo.getPages() );
    // 是否为第一页
    pageInfo.setIsFirstPage(pageInfo.getPageNum() == 1);
    // 是否为最后一页
    pageInfo.setIsLastPage(pageInfo.getPageNum() == pageInfo.getPages());
    return pageInfo;
    }
    }
  • 启动类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.jd;

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;

    /**
    * 启动类,直接运行即可
    */
    @SpringBootApplication
    public class Application {

    public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
    }

    }
  • 运行启用类成功后,测试接口,接口地址如下:

    1
    http://localhost:9000/jd/search?keywords=佳能&pageNo=1&pageSize=30&sortNumber=2&isDesc=true

image-20201211131158776

5. 前后端对接

  • 前提条件:启动ElasticSearch服务保证数据可用、启动后端服务保证接口可用、前端安装Axios库、qs库、Element组件库

  • 前端接口请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    import Vue from 'vue'
    import App from './App.vue'
    import router from './router'
    // 导入CSS样式
    import './assets/css/style.css'
    // 导入axios
    import axios from 'axios'
    // 导入qs用于url序列化
    import qs from 'qs'
    // 导入Element-Ui组件
    import './plugins/element.js'

    // 配置请求根目录
    axios.defaults.baseURL = 'http://localhost:9000/jd'
    // 给Vue挂载axios
    Vue.prototype.$http = axios
    Vue.prototype.$qs = qs

    Vue.config.productionTip = false

    new Vue({
    // 通过mounted生命周期钩子初始化数据
    mounted: async function () {
    // 初始化品牌数据
    const initBrandList = [
    {
    id: 1,
    name: '佳能'
    }, {
    id: 2,
    name: '尼康'
    },
    {
    id: 3,
    name: '索尼'
    },
    {
    id: 4,
    name: '哈苏'
    },
    {
    id: 5,
    name: '富士'
    },
    {
    id: 6,
    name: '莱卡'
    },
    {
    id: 7,
    name: '松下'
    },
    {
    id: 8,
    name: '大疆'
    },
    {
    id: 9,
    name: '适马'
    },
    {
    id: 10,
    name: '松典'
    }
    ]
    // 初始化排序数据
    const sortData = [
    {
    id: 0,
    name: '综合',
    defaultDesc: 'true'
    },
    {
    id: 1,
    name: '人气',
    defaultDesc: 'true'
    },
    {
    id: 2,
    name: '价格',
    defaultDesc: 'false'
    }
    ]
    const { data: rs } = await this.$http.get('/search?' + this.$qs.stringify(this.params))
    if (rs.status !== 200) {
    // 如果请求失败,进行弹框
    return this.$message(rs.message)
    }
    // 注释掉假数据,将查询的数据填充到该字段
    const initGoodsData = rs.data

    localStorage.setItem('initGoodsData', JSON.stringify(initGoodsData))
    localStorage.setItem('initBrandList', JSON.stringify(initBrandList))
    localStorage.setItem('sortData', JSON.stringify(sortData))
    },
    // 通过mounted生命周期钩子清除初始化数据
    destroyed: function () {
    localStorage.removeItem('initGoodsData')
    localStorage.removeItem('brandList')
    localStorage.removeItem('sortData')
    },
    router,
    render: h => h(App)
    }).$mount('#app')
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    <template>
    <div class="page">
    <div id="mallPage" class=" mallist tmall- page-not-market ">
    <!-- 头部搜索 -->
    <div id="header" class=" header-list-app">
    <div class="headerLayout">
    <div class="headerCon ">
    <!-- Logo-->
    <h1 id="mallLogo">
    <img th:src="../static/images/jdlogo.png" alt="">
    </h1>

    <div class="header-extra">

    <!--搜索-->
    <div id="mallSearch" class="mall-search">
    <form name="searchTop" class="mallSearch-form clearfix">
    <fieldset>
    <legend>天猫搜索</legend>
    <div class="mallSearch-input clearfix">
    <div class="s-combobox" id="s-combobox-685">
    <div class="s-combobox-input-wrap">
    <input type="text" autocomplete="off" v-model="params.keywords" id="mq"
    class="s-combobox-input" aria-haspopup="true" placeholder="请输入关键字">
    </div>
    </div>
    <button type="submit" id="searchbtn" @click.prevent="doSearch(params.keywords)">搜索</button>
    </div>
    </fieldset>
    </form>
    <ul class="relKeyTop">
    <li><a>彤哥相机专场</a></li>
    <li><a>彤哥聊Java</a></li>
    <li><a>彤哥摄影大讲堂</a></li>
    <li><a>彤哥电脑修理铺</a></li>
    <li><a>彤哥图书</a></li>
    </ul>
    </div>
    </div>
    </div>
    </div>
    </div>

    <!-- 商品详情页面 -->
    <div id="content">
    <div class="main">
    <!-- 品牌分类 -->
    <form class="navAttrsForm">
    <div class="attrs j_NavAttrs" style="display:block">
    <div class="brandAttr j_nav_brand">
    <div class="j_Brand attr">
    <div class="attrKey">
    你可能要搜
    </div>
    <div class="attrValues">
    <ul class="av-collapse row-2">
    <li v-for="brand in brandList" :key="brand.id" @click.prevent="doSearch(brand.name)">
    <a href="#">{{brand.name}}</a>
    </li>
    </ul>
    </div>
    </div>
    </div>
    </div>
    </form>

    <!-- 排序规则 -->
    <div class="filter clearfix">
    <span v-for="sortItem in sortData" :key="sortItem.id" @click="doSortSearch(sortItem.id,sortItem.defaultDesc)">
    <a class="fSort" :class="{'fSort-cur': sortItem.id == defaultSortNumber}">
    {{sortItem.name}}
    <span v-if="sortItem.id > 1">
    <!-- 排序上标志,阻止向父级冒泡 -->
    <i class="f-ico-triangle-mt" :style="sortViewFlag==1 ? {'border-bottom': '4px solid red'} : {}" @click.stop="doSortSearch(sortItem.id,false)" ></i>
    <!-- 排序下标志,阻止向父级冒泡 -->
    <i class="f-ico-triangle-mb" :style="sortViewFlag==2 ? {'border-top': '4px solid red'} : {}" @click.stop="doSortSearch(sortItem.id,true)"></i>
    </span>
    <span v-else>
    <i class="f-ico-arrow-d"></i>
    </span>
    </a>
    </span>
    </div>
    <!-- 商品详情,滚动加载 -->
    <div class="view grid-nosku" infinite-scroll-disabled="disabled" v-infinite-scroll="loadMore" v-if="jdGoodsList !== null && 'list' in jdGoodsList && jdGoodsList !== null">
    <div class="product" v-for="goods in jdGoodsList.list" :key="goods.id">
    <a :href="goods.detailUrl">
    <div class="product-iWrap">
    <!--商品封面-->
    <div class="productImg-wrap">
    <a class="productImg">
    <img :src="goods.imgUrl">
    </a>
    </div>
    <!--价格-->
    <p class="productPrice">
    <em><b>¥</b>{{goods.price}}</em>
    </p>
    <!--标题-->xiaog
    <p class="productTitle">
    <!-- 使用v-html原因是高亮渲染 -->
    <a v-html="goods.title"></a>
    </p>
    <!-- 店铺名 -->
    <div class="productShop">
    <span>店铺:{{goods.shopName}} </span>
    </div>
    <!-- 成交信息 -->
    <p class="productStatus">
    <span>月成交<em>{{goods.transactionsCount}}笔</em></span>
    <span>评价 <a>{{goods.evaluationCount}}</a></span>
    </p>
    </div>
    </a>
    </div>
    </div>
    <div>
    <p v-if="this.loading">加载中...</p>
    <div><p v-if="this.noMore">没有更多了</p></div>
    </div>
    </div>
    </div>
    </div>
    <!-- 回到顶部 -->
    <el-backtop target="#mallPage" :bottom="100">
    <div class="back-top" >UP</div>
    </el-backtop>
    </div>
    </template>

    <script>
    export default {
    name: 'Search',
    data () {
    return {
    sortData: JSON.parse(localStorage.getItem('sortData')),
    brandList: JSON.parse(localStorage.getItem('initBrandList')),
    params: {
    keywords: '',
    pageNo: 1,
    pageSize: 30,
    sortNumber: 0,
    isDesc: true
    },
    jdGoodsList: JSON.parse(localStorage.getItem('initGoodsData')),
    defaultSortNumber: 0,
    sortViewFlag: 0,
    loading: false
    }
    },
    props: {
    },
    methods: {
    async doSearch (keywords, sc) {
    console.log(typeof (sc) === 'undefined')
    if (typeof (sc) === 'undefined') {
    this.params.pageNo = 1
    }
    this.params.keywords = keywords
    // 请求后端接口进行搜索操作
    const { data: rs } = await this.$http.get('/search?' + this.$qs.stringify(this.params))
    if (rs.status !== 200) {
    // 如果请求失败,进行弹框
    return this.$message.error(rs.message)
    }
    if (sc === true) {
    this.jdGoodsList.list = this.jdGoodsList.list.concat(rs.data.list)
    return
    }
    this.jdGoodsList = rs.data
    },
    async doSortSearch (id, defaultDesc) {
    // 排序字段置为选中
    if (id === 2) {
    // 排序箭头置为选中
    this.sortViewFlag = !defaultDesc ? 1 : 2
    defaultDesc = (this.sortViewFlag === 2)
    }
    this.defaultSortNumber = id
    // 重新设置排序号和排序规则
    this.params.sortNumber = id
    this.params.isDesc = defaultDesc
    // 执行搜索
    this.doSearch(this.params.keywords)
    },
    /**
    * 滚动加载
    */
    loadMore () {
    this.loading = true
    setTimeout(() => {
    // 页码+1
    this.params.pageNo += 1
    this.loading = false
    if (this.params.pageNo > this.jdGoodsList.pages) {
    // 加载完成所有数据后,将页码置为初始值1
    this.params.pageNo = 1
    return
    }
    // 执行搜索
    this.doSearch(this.params.keywords, true)
    }, 2000)
    }
    },
    watch: {
    'params.keywords': async function (newVal) {
    // 若不搜索,则默认显示LocalStorage存储的商品信息
    if (newVal === '') {
    // 执行搜索
    this.doSearch(newVal)
    }
    }
    },
    computed: {
    noMore () {
    return this.params.pageNo === this.jdGoodsList.pages
    },
    disabled () {
    return this.loading || this.noMore
    }
    }
    }
    </script>
    <style lang="scss" scope>
    #mallPage {
    height: 100vh;
    overflow-x: hidden;
    }
    .back-top {
    height: 100%;
    width: 100%;
    background-color: #f2f5f6;
    box-shadow: 0 0 6px rgba(0,0,0, .12);
    text-align: center;
    line-height: 40px;
    color: #1989fa;
    }
    </style>

6. 最终效果

最终效果

7. 源码仓库

8. 参考资料

支付宝打赏 微信打赏

请作者喝杯咖啡吧