基于 Laravel + Vue 构建 API 驱动的前后端分离应用系列(二十三) —— 通过 Vue Mixins 在前端首页对咖啡店进行过滤筛选

随着咖啡店数量的增多,需要按照指定条件对咖啡店进行过滤筛选,才能找到心仪的咖啡店。由于我们现在在应用首页已经将所有咖啡店数据一次性返回了,所以现在我们在前端基于 Vue 对咖啡店进行过滤,当然,随着数据的进一步增大,筛选过滤功能必须集合后端 API 实现,但是对于目前数据量来说,前端处理就可以了。

我们将基于以下几个维度对数据进行过滤:

  • 文本搜索
  • 标签选择
  • 是否是烘焙店
  • 冲泡方法

我们将使用 Vue Mixins 技术在前端对咖啡店数据进行过滤。

下面我们将依次为上面每个筛选维度创建过滤处理函数并将其各自放置到单独的 JavaScript 文件中。

第一步:文本过滤处理函数

首先在 resources/assets/js/mixins 目录下创建一个 filters 子目录,在该子目录下创建 CafeTextFilter.js 来存放文本筛选处理函数,筛选的文本字段包括咖啡店名称、位置名称、地址、城市及省份,如果以上任意字段包含筛选文本,则该咖啡店会显示到筛选结果中,否则不显示(如果文本筛选字段为空,则显示咖啡店):

export const CafeTextFilter = {
    methods: {
        processCafeTextFilter(cafe, text) {
            // 筛选文本不为空时才会处理
            if (text.length > 0) {
                // 如果咖啡店名称、位置、地址或城市与筛选文本匹配,则返回 true,否则返回 false
                if (cafe.name.toLowerCase().match('[^,]*' + text.toLowerCase() + '[,$]*')
                    || cafe.location_name.toLowerCase().match('[^,]*' + text.toLowerCase() + '[,$]*')
                    || cafe.address.toLowerCase().match('[^,]*' + text.toLowerCase() + '[,$]*')
                    || cafe.city.toLowerCase().match('[^,]*' + text.toLowerCase() + '[,$]*')
                ) {
                    return true;
                } else {
                    return false;
                }
            } else {
                return true;
            }
        }
    }
};

第二步:标签过滤处理函数

接下来在 resources/assets/js/mixins/filters 目录下创建 CafeTagsFilter.js,编写标签过滤处理函数,如果咖啡店任意标签包含筛选标签则显示该咖啡店:

export const CafeTagsFilter = {
    methods: {
        processCafeTagsFilter(cafe, tags) {
            // 如果标签不为空则进行处理
            if (tags.length > 0) {
                var cafeTags = [];

                // 将咖啡店所有标签推送到 cafeTags 数组中
                for (var i = 0; i < cafe.tags.length; i++) {
                    cafeTags.push(cafe.tags[i].tag);
                }

                // 遍历所有待处理标签,如果标签在 cafeTags 数组中返回 true
                for (var i = 0; i < tags.length; i++) {
                    if (cafeTags.indexOf(tags[i]) > -1) {
                        return true;
                    }
                }

                // 如果所有待处理标签都不在 cafeTags 数组中则返回 false
                return false;
            } else {
                return true;
            }
        }
    }
};

第三步:是否是烘焙店过滤处理函数

然后是针对咖啡店的 roaster 字段进行过滤的处理函数, 创建 resources/assets/js/mixins/filters/CafeIsRoasterFilter.js 文件,编写代码如下:

export const CafeIsRoasterFilter = {
    methods: {
        processCafeIsRoasterFilter(cafe) {
            // 检查咖啡店是否是烘焙店
            if (cafe.roaster === 1) {
                return true;
            } else {
                return false;
            }
        }
    }
};

第四步:冲泡方法过滤处理函数

最后是冲泡方法过滤处理函数,创建 resources/assets/js/mixins/filters/CafeBrewMethodsFilter.js 文件并编写代码如下,和标签过滤类似,任意冲泡方法匹配则显示该咖啡店:

export const CafeBrewMethodsFilter = {
    methods: {
        processCafeBrewMethodsFilter(cafe, brewMethods) {
            // 如果冲泡方法不为空,则进行处理
            if (brewMethods.length > 0) {
                var cafeBrewMethods = [];

                // 将咖啡店所有冲泡方法都推送到 cafeBrewMethods 数组
                for (var i = 0; i < cafe.brew_methods.length; i++) {
                    cafeBrewMethods.push(cafe.brew_methods[i].method);
                }

                // 遍历所有待处理冲泡方法,如果在 cafeBrewMethods 数组中则返回 true
                for (var i = 0; i < brewMethods.length; i++) {
                    if (cafeBrewMethods.indexOf(brewMethods[i]) > -1) {
                        return true;
                    }
                }

                // 如果都不在 cafeBrewMethods 数组中则返回 false
                return false;

            } else {
                return true;
            }
        }
    }
};

第五步:创建过滤器 Vue 组件

接下来,我们为以上筛选条件定义 Vue 组件,以便用户可以在页面上对不同条件进行设置/选择从而筛选出自己想要的咖啡店,在 resources/assets/js/components/cafes 目录下创建 CafeFilter.vue 文件,作为咖啡店筛选组件,初始化代码如下:

<style lang="scss">

</style>

<template>
    <div id="cafe-filter">

    </div>
</template>

<script>
    export default {

    }
</script>

然后我们在这个组件中依次为不同的筛选条件编写前端显示编码:

第六步:文本搜索框

首先是文本过滤,我们通过一个文本搜索框来实现:

<div id="cafe-filter">
   <div class="grid-container">
       <div class="grid-x grid-padding-x">
           <div class="large-6 medium-6 small-12 cell">
               <label>搜索</label>
               <input type="text" v-model="textSearch" placeholder="搜索"/>
           </div>
       </div>
   </div>
</div>

然后在返回数据模型中定义对应的模型数据:

data() {
  return {
      textSearch: ''
  }
}

第七步:标签输入框

然后是标签过滤,我们在这里复用之前定义的 TagsInput 组件:

<template>
    <div id="cafe-filter">
        <div class="grid-container">
            <div class="grid-x grid-padding-x">
                <div class="large-6 medium-6 small-12 cell">
                    <label>搜索</label>
                    <input type="text" v-model="textSearch" placeholder="搜索"/>
                </div>
                <div class="large-6 medium-6 small-12 cell">
                    <tags-input v-bind:unique="'cafe-search'"></tags-input>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
    // 引入标签输入组件
    import TagsInput from '../global/forms/TagsInput.vue';

    // 引入事件总线
    import {EventBus} from '../../event-bus.js';

    export default {
        components: {
            TagsInput
        },

        data() {
            return {
                textSearch: '',
                tags: []
            }
        }
    }
</script>

然后我们在 mounted 中监听 tags-edited 事件,如果标签输入框的值变动,则同布过滤组件的 tags 字段:

mounted() {
    EventBus.$on('tags-edited', function( tagsEdited ){
        if( tagsEdited.unique == 'cafe-search' ){
            this.tags = tagsEdited.tags;
        }
    }.bind(this));
}

最后,我们调整下 resources/assets/js/components/global/forms/TagsInput.vue 组件代码,在 <style> 样式代码中将 div.tags-inputdisplay 属性值调整为 table,然后在 TagsInput 组件中引入 lodash

import _ from 'lodash';

以便优化 methods 中的 searchTags 方法:

// 引入防抖动函数,在 300ms 后执行匿名函数内代码
searchTags: _.debounce( function(e) {
    if( this.currentTag.length > 2 && !this.pauseSearch ){
        this.searchSelectedIndex = -1;
        axios.get( ROAST_CONFIG.API_URL + '/tags' , {
            params: {
                search: this.currentTag
            }
        }).then( function( response ){
            this.tagSearchResults = response.data;
        }.bind(this));
    }
}, 300),

第八步:添加是否是烘焙店筛选条件

CafeFilter 组件的 template 中紧随标签组件之后添加是否是烘焙店单选框:

<div class="is-roaster-container">
    <input type="checkbox" v-model="isRoaster"/> <label>是否是烘焙店?</label>
</div>

然后在返回的数据模型中新增 isRoaster

data() {
  return {
      textSearch: '',
      tags: [],
      isRoaster: false
  }
},

默认值是 false,意味着默认筛选出不是烘焙店的咖啡店。

第九步:冲泡方法复选组件

首先还是在 data 返回数据模型中新增 brewMethods 字段:

data() {
  return {
      textSearch: '',
      tags: [],
      isRoaster: false,
      brewMethods: []
  }
},

我们通过计算属性从 Vuex 中获取所有支持的冲泡方法以便从中选择:

// 从 Vuex 中加载冲泡方法
computed: {
    cafeBrewMethods() {
        return this.$store.getters.getBrewMethods;
    },
}

然后在 template 模板的是否是烘焙店条件之后添加如下模板代码:

<div class="brew-methods-container">
    <div class="filter-brew-method" v-on:click="toggleBrewMethodFilter( method.method )"
      v-bind:class="{'active' : brewMethods.indexOf( method.method ) > -1 }"
      v-for="method in cafeBrewMethods">
        {{ method.method }}
    </div>
</div>

接下来在 methods 方法中定义上面用到的 toggleBrewMethodFilter 方法,如果某个冲泡方法被选择,则将其推送到筛选组件的 brewMethods 属性,否则从 brewMethods 属性中将当前冲泡方法移除:

methods: {
   toggleBrewMethodFilter(method) {
       if (this.brewMethods.indexOf(method) > -1) {
           this.brewMethods.splice(this.brewMethods.indexOf(method), 1);
       } else {
           this.brewMethods.push(method);
       }
   }
}

最后,我们为 CafeFilter.vue 组件的冲泡方法组件编写样式代码如下:

<style lang="scss">
  @import '~@/abstracts/_variables.scss';

  div.filter-brew-method{
    display: inline-block;
    height: 35px;
    text-align: center;
    border: 1px solid #ededed;
    border-radius: 5px;
    padding-left: 10px;
    padding-right: 10px;
    padding-top: 5px;
    padding-bottom: 5px;
    margin-right: 10px;
    margin-top: 10px;
    cursor: pointer;
    color: $dark-color;
    font-family: 'Josefin Sans', sans-serif;

    &.active{
      border-bottom: 4px solid $primary-color;
    }
  }
</style>

第十步:发送过滤条件变更事件

最后,我们在 CafeFilter.vue 组件的 methods 方法中新增 updateFilterDisplay 方法,在该方法中会触发全局过滤条件更新事件 filters-updated,并将过滤条件数据随事件发送,让监听函数调用过滤处理函数进行相应的过滤处理:

updateFilterDisplay() {
      EventBus.$emit('filters-updated', {
          text: this.textSearch,
          tags: this.tags,
          roaster: this.isRoaster,
          brew_methods: this.brewMethods
      });
}

当然,还要新增 watch 监听以上四个筛选条件的变动,任何一个条件变动则立即调用上面定义的 updateFilterDisplay 方法发送过滤条件更新事件:

watch: {
    textSearch() {
        this.updateFilterDisplay();
    },
    tags() {
        this.updateFilterDisplay();
    },
    isRoaster() {
        this.updateFilterDisplay();
    },
    brewMethods() {
        this.updateFilterDisplay();
    }
}

第十一步:显示/隐藏咖啡店过滤组件

为了优化用户体验,我们为 CafeFilter 组件实现显示/隐藏功能,首先在返回数据模型中新增 show 字段标识是否显示,默认值为 true,表示默认显示该组件:

data() {
  return {
      textSearch: '',
      tags: [],
      isRoaster: false,
      brewMethods: [],
      show: true
  }
},

然后在 template 模板中 div.grid-container 元素上新增一个 v-show 条件,标识该组件是否显示:

<div id="cafe-filter">
    <div class="grid-container" v-show="show">
    ...

最后紧随上述 grid-container 元素并列添加如下代码用于渲染「显示/隐藏过滤器」元素:

<div class="grid-container">
    <div class="grid-x grid-padding-x">
        <span class="show-filters" v-on:click="show = !show">{{ show ? '隐藏过滤器 ↑' : '显示过滤器 ↓' }}</span>
    </div>
</div>

<style> 添加对应样式代码如下:

span.show-filters{
    display: block;
    margin: auto;
    color: $dark-color;
    font-family: 'Josefin Sans', sans-serif;
    cursor: pointer;
    font-size: 14px;
}

至此,我们的咖啡店过滤组件 CafeFilter 就已经全部编写好了,接下来我们需要将其添加到首页以便发挥过滤作用。

第十二步:添加咖啡店过滤组件到首页

resources/assets/js/pages/Home.vue 中引入 CafeFilter.vue

import CafeFilter from '../components/cafes/CafeFilter.vue';

然后定义一个 components 属性:

components: {
   CafeFilter
}

template 模板中插入新增咖啡店按钮及咖啡店过滤组件以便在页面顶部显示:

<div class="grid-container">
    <div class="grid-x">
        <div class="large-12 medium-12 small-12 columns">
            <router-link :to="{ name: 'newcafe' }" class="add-cafe-button">+ 新增咖啡店</router-link>
        </div>
    </div>
</div>

<cafe-filter></cafe-filter>  

为「新增咖啡店」组件编写样式代码如下:

<style lang="scss">
    @import '~@/abstracts/_variables.scss';

    div#home{
        a.add-cafe-button{
            float: right;
            display: block;
            margin-top: 10px;
            margin-bottom: 10px;
            background-color: $dark-color;
            color: white;
            padding-top: 5px;
            padding-bottom: 5px;
            padding-left: 10px;
            padding-right: 10px;
        }
    }
</style>

第十三步:在首页组件实现筛选结果显示

仅仅有筛选组件还不够,因为到目前为止我们在应用首页并没有任何显示任何有效数据,也没有监听过滤条件变动的处理函数,接下来我们就来新增一个组件来监听过滤条件变动并应到到显示结果,从而体现出筛选功能(该组件在页面加载完成后会默认显示所有咖啡店)。

在创建组件之前,先调整 app/Http/Controllers/API/CafesController.phpgetCafes() 方法使其返回标签数据:

public function getCafes()
{
    $cafes = Cafe::with('brewMethods')
        ->with(['tags' => function ($query) {
            $query->select('name');
        }])
        ->get();

    return response()->json($cafes);
}

然后在 resources/assets/js/components/cafes 目录下创建一个 CafeCard.vue 组件用来显示咖啡店:

<style lang="scss">

</style>

<template>

</template>

<script>
    export default {

    };
</script>

Home.vue 页面模板代码中紧随咖啡店过滤组件添加如下代码:

<div class="grid-container">
  <div class="grid-x grid-padding-x">
    <loader v-show="cafesLoadStatus == 1" :width="100" :height="100"></loader>
    <cafe-card v-for="cafe in cafes" :key="cafe.id" :cafe="cafe"></cafe-card>
  </div>
</div>   

然后在脚本中引入 CafeCard 组件和 Loader 组件:

import CafeCard from '../components/cafes/CafeCard.vue';
import Loader from '../components/global/Loader.vue';

components 中新增上述组件:

components: {
   CafeCard,
   Loader,
   CafeFilter
}

接下来我们来编写 CafeCard.vue 组件代码,首先编写模板代码如下:

<template>
    <div class="large-3 medium-4 small-6 cell cafe-card-container" v-show="show">
        <router-link :to="{ name: 'cafe', params: { id: cafe.id} }">
            <div class="cafe-card">
                <span class="title">{{ cafe.name }}</span>
                <span class="address">
                    <span class="street">{{ cafe.address }}</span>
                    <span class="city">{{ cafe.city }}</span> <span class="state">{{ cafe.state }}</span>
                    <span class="zip">{{ cafe.zip }}</span>
                </span>
            </div>
        </router-link>
    </div>
</template>

对应的样式代码如下:

<style lang="scss">
    @import '~@/abstracts/_variables.scss';

    div.cafe-card {
        border-radius: 5px;
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
        padding: 15px 5px 5px 5px;
        margin-top: 20px;
        cursor: pointer;
        -webkit-transform: scaleX(1) scaleY(1);
        transform: scaleX(1) scaleY(1);
        transition: .2s;
        span.title {
            display: block;
            text-align: center;
            color: black;
            font-size: 18px;
            font-weight: bold;
            font-family: 'Lato', sans-serif;
        }
        span.address {
            display: block;
            text-align: center;
            margin-top: 5px;
            color: $grey;
            font-family: 'Lato', sans-serif;
            span.street {
                font-size: 14px;
                display: block;
            }
            span.city {
                font-size: 14px;
            }
            span.state {
                font-size: 14px;
            }
            span.zip {
                font-size: 14px;
                display: block;
            }
        }
        span.liked-meta {
            color: $grey;
            font-size: 10px;
            margin-left: 5px;
            margin-right: 3px;
            img {
                width: 10px;
            }
        }
        &:hover {
            -webkit-transform: scaleX(1.041) scaleY(1.041);
            transform: scaleX(1.041) scaleY(1.041);
            transition: .2s;
        }
    }
</style>

该组件中的 cafe 是从父组件中传入的,所以需要为其定义 props 属性:

props: [
   'cafe'
],

data 中定义变量标识是否显示该组件,默认显示(如果过滤结果为空,则不显示):

data() {
   return {
       show: true
   }
},

然后我们在这个组件中引入过滤条件处理函数:

import {CafeIsRoasterFilter} from '../../mixins/filters/CafeIsRoasterFilter.js';
import {CafeBrewMethodsFilter} from '../../mixins/filters/CafeBrewMethodsFilter.js';
import {CafeTagsFilter} from '../../mixins/filters/CafeTagsFilter.js';
import {CafeTextFilter} from '../../mixins/filters/CafeTextFilter.js';

并为其定义 mixins 属性:

mixins: [
    CafeIsRoasterFilter,
    CafeBrewMethodsFilter,
    CafeTagsFilter,
    CafeTextFilter
]

引入事件总线:

 import { EventBus } from '../../event-bus.js';

以及在 mounted 中监听过滤条件变更事件 filters-updated

mounted() {
   EventBus.$on('filters-updated', function (filters) {
       this.processFilters(filters);
   }.bind(this));
}

methods 中定义过滤条件变更后的处理方法 processFilters(),如果筛选条件为空则显示所有咖啡店,否则只将与筛选条件匹配的结果显示出来,如果匹配结果为空,则不显示该组件:

processFilters(filters) {
  // 如果没有设置任何过滤条件,则显示
  if (filters.text == ''
      && filters.tags.length == 0
      && filters.roaster == false
      && filters.brew_methods.length == 0) {
      this.show = true;
  } else {
      // 初始化过滤标识符,默认为 false
      var textPassed = false;
      var tagsPassed = false;
      var brewMethodsPassed = false;
      var roasterPassed = false;

      // 烘焙店筛选
      if (filters.roaster && this.processCafeIsRoasterFilter(this.cafe)) {
          roasterPassed = true;
      } else if (!filters.roaster) {
          roasterPassed = true;
      }

      // 文本筛选
      if (filters.text != '' && this.processCafeTextFilter(this.cafe, filters.text)) {
          textPassed = true;
      } else if (filters.text == '') {
          textPassed = true;
      }

      // 冲泡方法筛选
      if (filters.brew_methods.length != 0 && this.processCafeBrewMethodsFilter(this.cafe, filters.brew_methods)) {
          brewMethodsPassed = true;
      } else if (filters.brew_methods.length == 0) {
          brewMethodsPassed = true;
      }

      // 标签筛选
      if (filters.tags.length != 0 && this.processCafeTagsFilter(this.cafe, filters.tags)) {
          tagsPassed = true;
      } else if (filters.tags.length == 0) {
          tagsPassed = true;
      }

      // 如果所有匹配检查通过,则显示,否则不显示
      if (roasterPassed && textPassed && brewMethodsPassed && tagsPassed) {
          this.show = true;
      } else {
          this.show = false;
      }
  }
}

至此,我们就已经完成了在前端通过 Vue Mixins 对咖啡店数据进行过滤的功能。最后运行 npm run dev 重新编译前端资源,访问首页 http://roast.test/#/home

即可通过过滤器对咖啡店进行过滤了:

学院君 has written 980 articles

Laravel学院院长,终身学习者

积分:112252 等级:P12 职业:手艺人 城市:杭州

0 条回复

登录后才能进行评论,立即登录?