基于 Laravel + Vue 构建 API 驱动的前后端分离应用系列(三十一) —— 功能模块重构 & CSS 整体优化:首页篇

通过前面三十篇教程的讲解,我们已经完成了 Roast 应用的所有前端功能,相信你也已经初步掌握了基于 Laravel + Vue 实现前后端分离单页面应用的开发,接下来的几篇教程我们将围绕对现有 Roast 应用进行优化展开,对底层数据结构和前端功能模块进行重构,从而让应用的整体架构更加清晰,同时对 CSS 进行优化,从而让应用看上去更加美观,以首页为例,优化后的效果是这样的:

roast-app-home

跟之前相对简陋的首页相比,可以说是很酷了。

在正式编码前,我们先来规划下对应用哪几块功能进行重构:

  • 将咖啡店列表页合并到首页
  • 移除信息窗体功能,点击咖啡店标记直接跳转到对应的咖啡店详情页
  • 移除标签和标签过滤器(暂时)
  • 移除文件上传功能,将其替换为上传咖啡店 Logo
  • 将是否是烘焙店替换为咖啡店类型字段
  • 将之前的咖啡店总店概念整合到所属公司,分店打平,将对应的公有字段也移到公司表中
  • 有了上面的基础,在新增咖啡店页面,现在一次只能添加一个咖啡店
  • 编辑咖啡店功能实现
  • 应用 CSS 整体优化

接下来,我们遵循之前的开发流程「数据表 -> 模型类-> 路由 -> 控制器-> 前端调用API -> Vuex -> Vue Router -> Vue组件 -> CSS」,从应用首页着手,对应用进行重构,重构教程,我们将以代码为主,讲述为辅,因为流程和原理前面都说过了,直接看代码就能看懂。

第一步:数据表调整

首先需要创建从原来的表中拆分出两张表,一张是公司表 companies,用于存放咖啡店所属公司的公共属性:

php artisan make:migration create_companies_table

在新生成的数据库迁移文件类中,编辑 up 方法如下:

public function up()
{
    Schema::create('companies', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->integer('roaster');
        $table->text('website');
        $table->text('logo');
        $table->text('description');
        $table->integer('added_by')->unsigned()->nullable();
        $table->softDeletes();
        $table->timestamps();
    });
}

另一张表是城市表 cities,用于单独存放城市信息,以便后续实现级联功能:

php artisan make:migration create_cities_table

编辑对应的数据表迁移类的 up 方法如下:

public function up()
{
    Schema::create('cities', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->string('state');
        $table->string('country');
        $table->string('slug');
        $table->decimal('latitude', 11, 8)->nullable();
        $table->decimal('longitude', 11, 8)->nullable();
        $table->decimal('radius', 4, 2)->nullable();
        $table->timestamps();
    });
}

接下来的一些数据库迁移文件由上面两张新表衍生而来,首先看 companies,从 cafes 表中抽走了咖啡店的公共属性,所以需要对 cafes 表进行调整:

php artisan make:migration alter_cafes_drop_company_columns --table=cafes

编辑对应的数据表迁移类 up 方法如下:

public function up()
{
    Schema::table('cafes', function (Blueprint $table) {
        $table->dropColumn('name');
        $table->dropColumn('roaster');
        $table->dropColumn('website');
        $table->dropColumn('description');
        $table->dropColumn('added_by');
        $table->dropColumn('parent');
        $table->integer('company_id')->unsigned()->default(0);
        $table->softDeletes();
    });
}

cafes 表与 companies 表通过 company_id 进行关联,此外关于用户与公司之间的关系,我们创建一张 company_owners 表进行存储:

php artisan make:migration create_company_owners_table

编辑对应的数据表迁移类 up 方法如下:

public function up()
{
    Schema::create('company_owners', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id')->unsigned();
        $table->integer('company_id')->unsigned();
        $table->timestamps();
    });
}

捋好公司表后,我们还要为 cafes 表和 cities 表建立关联:

php artisan make:migration alter_cafes_add_city_id --table=cafes

编辑对应的数据表迁移类 up 方法如下:

Schema::table('cafes', function (Blueprint $table) {
    $table->integer('city_id')->after('location_name')->unsigned()->nullable();
});

最后我们为 companies 添加一个 subscription 字段,标识该咖啡店是否支持订购,为 brew_methods 表添加 add_brew_methods_icon 字段,标识该冲泡方法的 icon 图标:

php artisan make:migration alter_companies_add_subscription --table=companies
php artisan make:migration add_brew_methods_icon --table=brew_methods

对应的数据表迁移类 up 方法分别是:

public function up()
{
    Schema::table('companies', function (Blueprint $table) {
        $table->tinyInteger('subscription')->defualt(0)->after('roaster');
    });
}

和:

public function up()
{
    Schema::table('brew_methods', function (Blueprint $table) {
        $table->string('icon')->after('method');
    });
}

至此,我们已经完成了数据表迁移类的创建,运行 php artisan migrate 应用这些迁移,完成数据库中数据表创建及修改。

第二步:模型类调整

首先是创建两个模型类 CompanyCity

php artisan make:model Models/Company
php artisan make:model Models/City

然后编辑 Company 类代码如下,主要是定义两个关联关系,公司与用户之间是多对多的关系,与咖啡店之间是一对多的关系:

class Company extends Model
{
    // 所属用户
    public function ownedBy()
    {
        return $this->belongsToMany(User::class, 'company_owners', 'company_id', 'user_id');
    }

    // 所有关联咖啡店
    public function cafes()
    {
        return $this->hasMany(Cafe::class, 'company_id', 'id');
    }
}

相对的,在 Cafe 模型类中定义其与 Company 的关联关系如下:

// 归属公司
public function company()
{
    return $this->belongsTo(Company::class, 'company_id', 'id');
}

最后在 User 模型类中定义其与 Company 的关联关系:

// 归属此用户的公司
public function companiesOwned()
{
    return $this->belongsToMany(Company::class, 'company_owners', 'user_id', 'company_id');
}

City 模型类留空即可。

第三步:后端路由及控制器实现

对于路由来说,只需要为获取城市信息新增两个公有路由即可:

/*
|-------------------------------------------------------------------------------
| 获取所有城市
|-------------------------------------------------------------------------------
| URL:            /api/v1/cities
| Controller:     API\CitiesController@getCities
| Method:         GET
| Description:    Get all cities
*/
Route::get('/cities', 'API\CitiesController@getCities');

/*
|-------------------------------------------------------------------------------
| 获取指定城市
|-------------------------------------------------------------------------------
| URL:            /api/v1/cities/{slug}
| Controller:     API\CitiesController@getCity
| Method:         GET
| Description:    Gets an individual city
*/
Route::get('/cities/{slug}', 'API\CitiesController@getCity');

然后创建一个新的控制器:

php artisan make:controller API/CitiesController

在这个新创建的控制器中编写对应的路由方法:

class CitiesController extends Controller
{
    public function getCities()
    {
        $cities = City::all();
        return response()->json($cities);
    }

    public function getCity($slug)
    {
        $city = City::where('slug', '=', $slug)
            ->with(['cafes' => function ($query) {
                $query->with('company');
            }])
            ->first();
        if ($city != null) {
            return response()->json($city);
        } else {
            return response()->json(null, 404);
        }
    }
}

然后由于我们调整了咖啡店的数据结构,所以需要修改 CafesControllergetCafesgetCafe 方法:

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

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

public function getCafe($id)
{
    $cafe = Cafe::where('id', '=', $id)
        ->with('brewMethods')
        ->withCount('userLike')
        ->with('tags')
        ->with(['company' => function ($query) {
            $query->withCount('cafes');
        }])
        ->withCount('likes')
        ->first();

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

注:新增咖啡店方法代码放到下一篇教程去修改。

至此,我们的后端代码重构已经完成了,重头戏在前端,接下来我们将进行前端代码的重构。

第四步:新增 API 调用文件

创建一个新的 resources/assets/js/api/cities.js 文件,并编写后端 API 调用代码如下:

/*
  Imports the Roast API URL from the config.
*/
import { ROAST_CONFIG } from '../config.js';

export default {
  /*
    GET   /api/v1/cities
  */
  getCities: function(){
    return axios.get( ROAST_CONFIG.API_URL + '/cities' );
  },

  /*
    GET   /api/v1/cities/{slug}
  */
  getCity: function( slug ){
    return axios.get( ROAST_CONFIG.API_URL + '/cities/' + slug );
  }
}

第五步:新增/调整 Vuex 模块

新增 resources/assets/js/modules/cities.js

/*
|-------------------------------------------------------------------------------
| VUEX modules/cities.js
|-------------------------------------------------------------------------------
| The Vuex data store for the cities state
*/
import CitiesAPI from '../api/cities.js';

export const cities = {
  /*
    Defines the state being monitored for the module.
  */
  state: {
    cities: [],
    citiesLoadStatus: 0,

    city: {},
    cityLoadStatus: 0
  },

  /*
    Defines the actions available on the module.
  */
  actions: {
    /*
      Loads all cities.
    */
    loadCities( { commit } ){
      commit('setCitiesLoadStatus', 1);

      /*
        Calls the API to load the cities
      */
      CitiesAPI.getCities()
               .then( function( response ){
                 commit( 'setCities', response.data );
                 commit( 'setCitiesLoadStatus', 2 );
               })
               .catch( function(){
                 commit( 'setCities', [] );
                 commit( 'setCitiesLoadStatus', 3 );
               });
    },

    /*
      Loads an individual city.
    */
    loadCity( { commit }, data ){
      commit( 'setCityLoadStatus', 1 );

      /*
        Calls the API to load an individual city by slug.
      */
      CitiesAPI.getCity( data.slug )
               .then( function( response ){
                 commit( 'setCity', response.data );
                 commit( 'setCityLoadStatus', 2 );
               })
               .catch( function(){
                 commit( 'setCity', {} );
                 commit( 'setCityLoadStatus', 3 );
               });
    }
  },

  /*
    Defines the mutations based on the data store.
  */
  mutations: {
    /*
      Sets the cities in the state.
    */
    setCities( state, cities ){
      state.cities = cities;
    },

    /*
      Sets the cities load status.
    */
    setCitiesLoadStatus( state, status ){
      state.citiesLoadStatus = status;
    },

    /*
      Sets the city
    */
    setCity( state, city ){
      state.city = city;
    },

    /*
      Sets the city load status.
    */
    setCityLoadStatus( state, status ){
      state.cityLoadStatus = status;
    }
  },

  /*
    Defines the getters on the module.
  */
  getters: {
    /*
      Gets the cities
    */
    getCities( state ){
      return state.cities;
    },

    /*
      Gets the cities load status.
    */
    getCitiesLoadStatus( state ){
      return state.citiesLoadStatus;
    },

    /*
      Get the city
    */
    getCity( state ){
      return state.city;
    },

    /*
      Get the city load status.
    */
    getCityLoadStatus( state ){
      return state.cityLoadStatus;
    }
  }
}

新增 resources/assets/js/modules/display.js

/*
|-------------------------------------------------------------------------------
| VUEX modules/display.js
|-------------------------------------------------------------------------------
| The Vuex data store for the display state
*/
export const display = {
    /*
      Defines the state being monitored for the module
    */
    state: {
        showFilters: true,
        showPopOut: false,
        zoomLevel: '',
        lat: 0.0,
        lng: 0.0
    },

    /*
      Defines the actions that can be performed on the state.
    */
    actions: {
        /*
          Toggles the showing and hiding of filters.
        */
        toggleShowFilters({commit}, data) {
            commit('setShowFilters', data.showFilters);
        },

        /*
          Toggles the showing and hiding of the popout.
        */
        toggleShowPopOut({commit}, data) {
            commit('setShowPopOut', data.showPopOut);
        },

        /*
          Applies the zoom level.
        */
        applyZoomLevel({commit}, data) {
            commit('setZoomLevel', data);
        },

        /*
          Applies the latitude.
        */
        applyLat({commit}, data) {
            commit('setLat', data);
        },

        /*
          Applies the longitude.
        */
        applyLng({commit}, data) {
            commit('setLng', data);
        }
    },

    /*
      Defines the mutations used by the state.
    */
    mutations: {
        /*
          Sets the state to show or hide the filters.
        */
        setShowFilters(state, show) {
            state.showFilters = show;
        },

        /*
          Sets the state to show or hide the pop out.
        */
        setShowPopOut(state, show) {
            state.showPopOut = show;
        },

        /*
          Sets the zoom level
        */
        setZoomLevel(state, level) {
            state.zoomLevel = level;
        },

        /*
          Sets the lat
        */
        setLat(state, lat) {
            state.lat = lat;
        },

        /*
          Sets the lng
        */
        setLng(state, lng) {
            state.lng = lng;
        }
    },

    /*
      Defines the getters on the Vuex module.
    */
    getters: {
        /*
          Returns whether or not the filters are shown or hidden.
        */
        getShowFilters(state) {
            return state.showFilters;
        },

        /*
          Returns whether or not the pop out is shown or hidden.
        */
        getShowPopOut(state) {
            return state.showPopOut;
        },

        /*
          Gets the zoom level
        */
        getZoomLevel(state) {
            return state.zoomLevel;
        },

        /*
          Gets the latitude
        */
        getLat(state) {
            return state.lat;
        },

        /*
          Gets the longitude
        */
        getLng(state) {
            return state.lng;
        }
    }
};

新增 resources/assets/js/modules/filters.js

/*
|-------------------------------------------------------------------------------
| VUEX modules/filters.js
|-------------------------------------------------------------------------------
| The Vuex data store for the filters state
*/
export const filters = {
    /*
      Defines the state used by the module
    */
    state: {
        cityFilter: '',
        textSearch: '',
        activeLocationFilter: 'all',
        onlyLiked: false,
        brewMethodsFilter: [],
        hasMatcha: false,
        hasTea: false,
        hasSubscription: false,
        orderBy: 'name',
        orderDirection: 'asc'
    },

    /*
      Defines the actions that can be performed on the state.
    */
    actions: {
        /*
          Updates the city filter.
        */
        updateCityFilter({commit}, data) {
            commit('setCityFilter', data);
        },

        /*
          Updates the text search filter
        */
        updateSetTextSearch({commit}, data) {
            commit('setTextSearch', data);
        },

        /*
          Updates the active location filter.
        */
        updateActiveLocationFilter({commit}, data) {
            commit('setActiveLocationFilter', data);
        },

        /*
          Updates the only liked filter.
        */
        updateOnlyLiked({commit}, data) {
            commit('setOnlyLiked', data);
        },

        /*
          Updates the brew methods filter.
        */
        updateBrewMethodsFilter({commit}, data) {
            commit('setBrewMethodsFilter', data);
        },

        /*
          Updates the has matcha filter.
        */
        updateHasMatcha({commit}, data) {
            commit('setHasMatcha', data);
        },

        /*
          Updates the has tea filter.
        */
        updateHasTea({commit}, data) {
            commit('setHasTea', data);
        },

        /*
          Updates the has subscription filter.
        */
        updateHasSubscription({commit}, data) {
            commit('setHasSubscription', data);
        },

        /*
          Updates the order by setting and sorts the cafes.
        */
        updateOrderBy({commit, state, dispatch}, data) {
            commit('setOrderBy', data);
            dispatch('orderCafes', {order: state.orderBy, direction: state.orderDirection});
        },

        /*
          Updates the order direction and sorts the cafes.
        */
        updateOrderDirection({commit, state, dispatch}, data) {
            commit('setOrderDirection', data);
            dispatch('orderCafes', {order: state.orderBy, direction: state.orderDirection});
        },

        /*
          Resets the filters
        */
        resetFilters({commit}, data) {
            commit('resetFilters');
        }
    },

    /*
      Defines the mutations used by the state.
    */
    mutations: {
        /*
          Sets the city filter.
        */
        setCityFilter(state, city) {
            state.cityFilter = city;
        },

        /*
          Sets the text search filter.
        */
        setTextSearch(state, search) {
            state.textSearch = search;
        },

        /*
          Sets the active location filter.
        */
        setActiveLocationFilter(state, activeLocationFilter) {
            state.activeLocationFilter = activeLocationFilter;
        },

        /*
          Sets the only liked filter.
        */
        setOnlyLiked(state, onlyLiked) {
            state.onlyLiked = onlyLiked;
        },

        /*
          Sets the brew methods filter.
        */
        setBrewMethodsFilter(state, brewMethods) {
            state.brewMethodsFilter = brewMethods;
        },

        /*
          Sets the has matcha filter.
        */
        setHasMatcha(state, matcha) {
            state.hasMatcha = matcha;
        },

        /*
          Sets the has tea filter.
        */
        setHasTea(state, tea) {
            state.hasTea = tea;
        },

        /*
          Sets the has subscription filter.
        */
        setHasSubscription(state, subscription) {
            state.hasSubscription = subscription;
        },

        /*
          Sets the order by filter.
        */
        setOrderBy(state, orderBy) {
            state.orderBy = orderBy;
        },

        /*
          Sets the order direction filter.
        */
        setOrderDirection(state, orderDirection) {
            state.orderDirection = orderDirection;
        },

        /*
          Resets the active filters.
        */
        resetFilters(state) {
            state.cityFilter = '';
            state.textSearch = '';
            state.activeLocationFilter = 'all';
            state.onlyLiked = false;
            state.brewMethodsFilter = [];
            state.hasMatcha = false;
            state.hasTea = false;
            state.hasSubscription = false;
            state.orderBy = 'name';
            state.orderDirection = 'desc';
        }
    },

    /*
      Defines the getters on the Vuex module.
    */
    getters: {
        /*
          Gets the city fitler.
        */
        getCityFilter(state) {
            return state.cityFilter;
        },

        /*
          Gets the text search filter.
        */
        getTextSearch(state) {
            return state.textSearch;
        },

        /*
          Gets the active location filter.
        */
        getActiveLocationFilter(state) {
            return state.activeLocationFilter;
        },

        /*
          Gets the only liked filter.
        */
        getOnlyLiked(state) {
            return state.onlyLiked;
        },

        /*
          Gets the brew methods filter.
        */
        getBrewMethodsFilter(state) {
            return state.brewMethodsFilter;
        },

        /*
          Gets the has matcha filter.
        */
        getHasMatcha(state) {
            return state.hasMatcha;
        },

        /*
          Gets the has tea filter.
        */
        getHasTea(state) {
            return state.hasTea;
        },

        /*
          Gets the has subscription filter.
        */
        getHasSubscription(state) {
            return state.hasSubscription;
        },

        /*
          Gets the order by filter.
        */
        getOrderBy(state) {
            return state.orderBy;
        },

        /*
          Gets the order direction filter.
        */
        getOrderDirection(state) {
            return state.orderDirection;
        }
    }
};

调整 resources/assets/js/modules/cafes.js

/*
 |-------------------------------------------------------------------------------
 | VUEX modules/cafes.js
 |-------------------------------------------------------------------------------
 | The Vuex data store for the cafes
 */

import CafeAPI from '../api/cafe.js';

export const cafes = {
    /**
     * Defines the state being monitored for the module.
     */
    state: {
        cafes: [],
        cafesLoadStatus: 0,

        cafe: {},
        cafeLoadStatus: 0,

        cafeLiked: false,
        cafeLikeActionStatus: 0,
        cafeUnlikeActionStatus: 0,

        cafeAdded: {},
        cafeAddStatus: 0,
        cafeAddText: '',

        cafeDeletedStatus: 0,
        cafeDeleteText: '',

        cafesView: 'map'
    },
    /**
     * Defines the actions used to retrieve the data.
     */
    actions: {
        loadCafes({commit, rootState, dispatch}) {
            commit('setCafesLoadStatus', 1);

            CafeAPI.getCafes()
                .then(function (response) {
                    commit('setCafes', response.data);
                    dispatch('orderCafes', {
                        order: rootState.filters.orderBy,
                        direction: rootState.filters.orderDirection
                    });
                    commit('setCafesLoadStatus', 2);
                })
                .catch(function () {
                    commit('setCafes', []);
                    commit('setCafesLoadStatus', 3);
                });
        },

        loadCafe({commit}, data) {
            commit('setCafeLikedStatus', false);
            commit('setCafeLoadStatus', 1);

            CafeAPI.getCafe(data.id)
                .then(function (response) {
                    commit('setCafe', response.data);
                    if (response.data.user_like.length > 0) {
                        commit('setCafeLikedStatus', true);
                    }
                    commit('setCafeLoadStatus', 2);
                })
                .catch(function () {
                    commit('setCafe', {});
                    commit('setCafeLoadStatus', 3);
                });
        },

        addCafe({commit, state, dispatch}, data) {
            commit('setCafeAddStatus', 1);

            CafeAPI.postAddNewCafe(data.name, data.locations, data.website, data.description, data.roaster, data.picture)
                .then(function (response) {
                    commit('setCafeAddedStatus', 2);
                    dispatch('loadCafes');
                })
                .catch(function () {
                    commit('setCafeAddedStatus', 3);
                });
        },

        likeCafe({commit, state}, data) {
            commit('setCafeLikeActionStatus', 1);

            CafeAPI.postLikeCafe(data.id)
                .then(function (response) {
                    commit('setCafeLikedStatus', true);
                    commit('setCafeLikeActionStatus', 2);
                })
                .catch(function () {
                    commit('setCafeLikeActionStatus', 3);
                });
        },

        unlikeCafe({commit, state}, data) {
            commit('setCafeUnlikeActionStatus', 1);

            CafeAPI.deleteLikeCafe(data.id)
                .then(function (response) {
                    commit('setCafeLikedStatus', false);
                    commit('setCafeUnlikeActionStatus', 2);
                })
                .catch(function () {
                    commit('setCafeUnlikeActionStatus', 3);
                });
        },

        changeCafesView({commit, state, dispatch}, view) {
            commit('setCafesView', view);
        },

        orderCafes({commit, state, dispatch}, data) {
            let localCafes = state.cafes;

            switch (data.order) {
                case 'name':
                    localCafes.sort(function (a, b) {
                        if (data.direction === 'desc') {
                            return ((a.company.name === b.company.name) ? 0 : ((a.company.name < b.company.name) ? 1 : -1));
                        } else {
                            return ((a.company.name === b.company.name) ? 0 : ((a.company.name > b.company.name) ? 1 : -1));
                        }
                    });
                    break;
                case 'most-liked':
                    localCafes.sort(function (a, b) {
                        if (data.direction === 'desc') {
                            return ((a.likes_count === b.likes_count) ? 0 : ((a.likes_count < b.likes_count) ? 1 : -1));
                        } else {
                            return ((a.likes_count === b.likes_count) ? 0 : ((a.likes_count > b.likes_count) ? 1 : -1));
                        }
                    });
                    break;
            }

            commit('setCafes', localCafes);
        }
    },
    /**
     * Defines the mutations used
     */
    mutations: {
        setCafesLoadStatus(state, status) {
            state.cafesLoadStatus = status;
        },

        setCafes(state, cafes) {
            state.cafes = cafes;
        },

        setCafeLoadStatus(state, status) {
            state.cafeLoadStatus = status;
        },

        setCafe(state, cafe) {
            state.cafe = cafe;
        },

        setCafeAddStatus(state, status) {
            state.cafeAddStatus = status;
        },

        setCafeAdded( state, cafe ){
            state.cafeAdded = cafe;
        },

        setCafeAddedText( state, text ){
            state.cafeAddText = text;
        },

        setCafeLikedStatus(state, status) {
            state.cafeLiked = status;
        },

        setCafeLikeActionStatus(state, status) {
            state.cafeLikeActionStatus = status;
        },

        setCafeUnlikeActionStatus(state, status) {
            state.cafeUnlikeActionStatus = status;
        },

        setCafesView(state, view) {
            state.cafesView = view
        }
    },
    /**
     * Defines the getters used by the module
     */
    getters: {
        getCafesLoadStatus(state) {
            return state.cafesLoadStatus;
        },

        getCafes(state) {
            return state.cafes;
        },

        getCafeLoadStatus(state) {
            return state.cafeLoadStatus;
        },

        getCafe(state) {
            return state.cafe;
        },

        getCafeAddStatus(state) {
            return state.cafeAddStatus;
        },

        getAddedCafe( state ){
            return state.cafeAdded;
        },

        getCafeAddText( state ){
            return state.cafeAddText;
        },

        getCafeLikedStatus(state) {
            return state.cafeLiked;
        },

        getCafeLikeActionStatus(state) {
            return state.cafeLikeActionStatus;
        },

        getCafeUnlikeActionStatus(state) {
            return state.cafeUnlikeActionStatus;
        },

        getCafesView(state) {
            return state.cafesView;
        }
    }
};

然后在 resources/assets/js/store.js 中引入新增的几个文件:

/**
 * Imports all of the modules used in the application to build the data store.
 */
import {cafes} from './modules/cafes.js';
import {users} from './modules/users.js';
import {brewMethods} from './modules/brewMethods.js';
import {filters} from './modules/filters.js';
import {display} from './modules/display.js';
import {cities} from './modules/cities.js';

/**
 * Export our data store.
 */
export default new Vuex.Store({
    modules: {
        cafes,
        users,
        brewMethods,
        filters,
        display,
        cities
    }
});

第六步:调整 Vue Router

打开 resources/assets/js/routes.js 文件,修改前端路由配置如下:

export default new VueRouter({
    routes: [
        {
            path: '/',
            redirect: {name: 'cafes'},
            name: 'layout',
            component: Vue.component('Home', require('./layouts/Layout.vue')),
            children: [
                {
                    path: 'cafes',
                    name: 'cafes',
                    component: Vue.component('Home', require('./pages/Home.vue')),
                    children: [
                        {
                            path: 'new',
                            name: 'newcafe',
                            component: Vue.component('NewCafe', require('./pages/NewCafe.vue')),
                            beforeEnter: requireAuth
                        },
                        {
                            path: ':id',
                            name: 'cafe',
                            component: Vue.component('Cafe', require('./pages/Cafe.vue'))
                        },
                        {
                            path: 'cities/:slug',
                            name: 'city',
                            component: Vue.component( 'City', require( './pages/City.vue' ))
                        }
                    ]
                },
                {
                    path: 'profile',
                    name: 'profile',
                    component: Vue.component('Profile', require('./pages/Profile.vue')),
                    beforeEnter: requireAuth
                },
                {
                    path: '_=_',
                    redirect: '/'
                }
            ]
        }
    ]
});

在这里我们将 Layout.vue 移动到 resources/assets/js/layouts 目录下,同时将 Cafes.vue 合并到 Home.vue 页面。接下来我们就要来具体重构 Layout.vueHome.vue 页面组件,从而完成首页重构。

第七步:重构 Layout 组件

重构 resources/assets/js/layouts/Layout.vue 代码如下:

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

    div#app-layout {
        div.show-filters {
            height: 90px;
            width: 23px;
            position: absolute;
            left: 0px;
            background-color: white;
            border-top-right-radius: 3px;
            border-bottom-right-radius: 3px;
            line-height: 90px;
            top: 50%;
            cursor: pointer;
            margin-top: -45px;
            z-index: 9;
            text-align: center;
        }
    }
</style>

<template>
    <div id="app-layout">
        <div class="show-filters" v-show="( !showFilters && cafesView === 'map' )" v-on:click="toggleShowFilters()">
            <img src="/storage/img/grey-right.svg"/>
        </div>

        <success-notification></success-notification>
        <error-notification></error-notification>

        <navigation></navigation>

        <router-view></router-view>

        <login-modal></login-modal>

        <filters></filters>

        <pop-out></pop-out>
    </div>
</template>

<script>

    import Navigation from '../components/global/Navigation.vue';
    import LoginModal from '../components/global/LoginModal.vue';
    import SuccessNotification from '../components/global/SuccessNotification.vue';
    import ErrorNotification from '../components/global/ErrorNotification.vue';
    import Filters from '../components/global/Filters.vue';
    import PopOut from '../components/global/PopOut.vue';

    export default {
        components: {
            Navigation,
            LoginModal,
            SuccessNotification,
            ErrorNotification,
            Filters,
            PopOut
        },
        created() {
            this.$store.dispatch('loadCafes');
            this.$store.dispatch('loadUser');
            this.$store.dispatch('loadBrewMethods');
            this.$store.dispatch('loadCities');
        },
        computed: {
            showFilters() {
                return this.$store.getters.getShowFilters;
            },

            addedCafe() {
                return this.$store.getters.getAddedCafe;
            },

            addCafeStatus() {
                return this.$store.getters.getCafeAddStatus;
            },

            cafesView() {
                return this.$store.getters.getCafesView;
            }
        },

        watch: {
            'addCafeStatus': function () {
                if (this.addCafeStatus === 2) {
                    EventBus.$emit('show-success', {
                        notification: this.addedCafe.name + ' 已经添加成功!'
                    });
                }
            }
        },

        methods: {
            toggleShowFilters() {
                this.$store.dispatch('toggleShowFilters', {showFilters: !this.showFilters});
            }
        }
    }
</script>

这里面引入几个新的组件,我们需要依次创建这些组件。

resources/assets/js/components/global/SuccessNotification.vue 用于显示成功通知:

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

    div.success-notification-container {
        position: fixed;
        z-index: 999999;
        left: 0;
        right: 0;
        top: 0;
        div.success-notification {
            background: #FFFFFF;
            box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.12), 0 4px 4px 0 rgba(0, 0, 0, 0.24);
            border-left: 5px solid #00C853;
            height: 50px;
            line-height: 50px;
            margin: auto;
            width: 400px;
            margin-top: 150px;
            color: #242E38;
            font-family: "Lato", sans-serif;
            font-size: 16px;
            img {
                margin-right: 20px;
                margin-left: 20px;
            }
        }
    }
</style>

<template>
    <transition name="slide-in-top">
        <div class="success-notification-container" v-show="show">
            <div class="success-notification">
                <img src="/storage/img/success.svg"/> {{ successMessage }}
            </div>
        </div>
    </transition>
</template>

<script>

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

    export default {

        data() {
            return {
                successMessage: '',
                show: false
            }
        },

        mounted() {
            EventBus.$on('show-success', function (data) {
                this.successMessage = data.notification;
                this.show = true;

                setTimeout(function () {
                    this.show = false;
                }.bind(this), 3000);
            }.bind(this));
        }
    }
</script>

resources/assets/js/components/global/ErrorNotification.vue 用于显示失败通知:

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

    div.error-notification-container {
        position: fixed;
        z-index: 999999;
        left: 0;
        right: 0;
        top: 0;
        div.error-notification {
            background: #FFFFFF;
            box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.12), 0 4px 4px 0 rgba(0, 0, 0, 0.24);
            border-left: 5px solid #FF0000;
            height: 50px;
            line-height: 50px;
            margin: auto;
            width: 400px;
            margin-top: 150px;
            color: #242E38;
            font-family: "Lato", sans-serif;
            font-size: 16px;
            img {
                margin-right: 20px;
                margin-left: 20px;
                height: 20px;
            }
        }
    }
</style>

<template>
    <transition name="slide-in-top">
        <div class="error-notification-container" v-show="show">
            <div class="error-notification">
                <img src="/storage/img/error.svg"/> {{ errorMessage }}
            </div>
        </div>
    </transition>
</template>

<script>

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

    export default {

        data() {
            return {
                errorMessage: '',
                show: false
            }
        },

        mounted() {
            EventBus.$on('show-error', function (data) {
                this.errorMessage = data.notification;
                this.show = true;

                setTimeout(function () {
                    this.show = false;
                }.bind(this), 3000);
            }.bind(this));
        }
    }
</script>

resources/assets/js/components/global/Filters.vue 用于实现过滤器:

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

    div.filters-container {
        background-color: white;
        position: fixed;
        left: 0;
        bottom: 0;
        top: 75px;
        max-width: 550px;
        width: 100%;
        padding-top: 50px;
        box-shadow: 0 2px 4px 0 rgba(3, 27, 78, 0.10);
        z-index: 99;
        span.clear-filters {
            font-size: 16px;
            color: $text-secondary-color;
            font-family: "Lato", sans-serif;
            cursor: pointer;
            display: block;
            float: left;
            margin-bottom: 20px;
            display: none;
            img {
                margin-right: 10px;
                float: left;
                margin-top: 6px;
            }
        }
        span.filters-header {
            display: block;
            font-family: "Lato", sans-serif;
            font-weight: bold;
            margin-bottom: 10px;
        }
        input[type="text"].search {
            box-shadow: none;
            border-radius: 3px;
            color: #BABABA;
            font-size: 16px;
            font-family: "Lato", sans-serif;
            background-image: url('/storage/img/search-icon.svg');
            background-repeat: no-repeat;
            background-position: 6px;
            padding-left: 35px;
            padding-top: 5px;
            padding-bottom: 5px;
        }
        label.filter-label {
            font-family: "Lato", sans-serif;
            text-transform: uppercase;
            font-weight: bold;
            color: black;
            margin-top: 20px;
            margin-bottom: 10px;
        }
        div.location-filter {
            text-align: center;
            font-family: "Lato", sans-serif;
            font-size: 16px;
            color: $secondary-color;
            border-bottom: 1px solid $secondary-color;
            border-top: 1px solid $secondary-color;
            border-left: 1px solid $secondary-color;
            border-right: 1px solid $secondary-color;
            width: 33%;
            display: inline-block;
            height: 55px;
            line-height: 55px;
            cursor: pointer;
            margin-bottom: 5px;
            &.active {
                color: white;
                background-color: $secondary-color;
            }
            &.all-locations {
                border-top-left-radius: 3px;
                border-bottom-left-radius: 3px;
            }
            &.roasters {
                border-left: none;
                border-right: none;
            }
            &.cafes {
                border-top-right-radius: 3px;
                border-bottom-right-radius: 3px;
            }
        }
        span.liked-location-label {
            color: #666666;
            font-size: 16px;
            font-family: "Lato", sans-serif;
            margin-left: 10px;
        }
        div.close-filters {
            height: 90px;
            width: 23px;
            position: absolute;
            right: -20px;
            background-color: white;
            border-top-right-radius: 3px;
            border-bottom-right-radius: 3px;
            line-height: 90px;
            top: 50%;
            cursor: pointer;
            margin-top: -82px;
            text-align: center;
        }
        span.no-results {
            display: block;
            text-align: center;
            margin-top: 50px;
            color: #666666;
            text-transform: uppercase;
            font-weight: 600;
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        div.filters-container {
            padding-top: 25px;
            overflow-y: auto;
            span.clear-filters {
                display: block;
            }
            div.close-filters {
                display: none;
            }
        }
    }

    /* Medium only */
    @media screen and (min-width: 40em) and (max-width: 63.9375em) {
    }

    /* Large only */
    @media screen and (min-width: 64em) and (max-width: 74.9375em) {
    }
</style>

<template>
    <transition name="slide-in-left">
        <div class="filters-container" id="filters-container" v-show="showFilters && cafesView === 'map'">
            <div class="close-filters" v-on:click="toggleShowFilters()">
                <img src="/storage/img/grey-left.svg"/>
            </div>

            <div class="grid-x grid-padding-x">
                <div class="large-12 medium-12 small-12 cell">
                    <span class="filters-header">城市</span>
                    <select v-model="cityFilter">
                        <option value=""></option>
                        <option v-for="city in cities" v-bind:value="city.id">{{ city.name }}</option>
                    </select>
                </div>
            </div>

            <div class="grid-x grid-padding-x">
                <div class="large-12 medium-12 small-12 cell">
                    <span class="filters-header">查找你寻找的咖啡店类型</span>
                </div>
            </div>

            <div class="grid-x grid-padding-x" id="text-container">
                <div class="large-12 medium-12 small-12 cell">
                    <span class="clear-filters" v-show="showFilters" v-on:click="clearFilters()">
                        <img src="/storage/img/clear-filters-icon.svg"/> 清除过滤器
                    </span>
                    <input type="text" class="search" v-model="textSearch" placeholder="通过名称查找位置"/>
                </div>
            </div>

            <div id="location-type-container">
                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <label class="filter-label">位置类型</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <div class="location-filter all-locations"
                             v-bind:class="{ 'active': activeLocationFilter === 'all' }"
                             v-on:click="setActiveLocationFilter('all')">
                            所有位置
                        </div>
                        <div class="location-filter roasters"
                             v-bind:class="{ 'active': activeLocationFilter === 'roasters' }"
                             v-on:click="setActiveLocationFilter('roasters')">
                            烘焙店
                        </div>
                        <div class="location-filter cafes" v-bind:class="{ 'active': activeLocationFilter === 'cafes' }"
                             v-on:click="setActiveLocationFilter('cafes')">
                            咖啡店
                        </div>
                    </div>
                </div>
            </div>

            <div class="grid-x grid-padding-x" id="only-liked-container" v-show="user != '' && userLoadStatus === 2">
                <div class="large-12 medium-12 small-12 cell">
                    <input type="checkbox" v-model="onlyLiked"/> <span class="liked-location-label">只显示我喜欢过的</span>
                </div>
            </div>

            <div class="grid-x grid-padding-x"
                 v-show="activeLocationFilter === 'roasters' || activeLocationFilter === 'all'">
                <div class="large-12 medium-12 small-12 cell">
                    <label class="filter-label">是否提供订购服务</label>
                </div>
            </div>

            <div class="grid-x grid-padding-x"
                 v-show="activeLocationFilter === 'roasters' || activeLocationFilter === 'all'">
                <div class="large-12 medium-12 small-12 cell">
                    <div class="subscription option" v-on:click="toggleSubscriptionFilter()"
                         v-bind:class="{'active': hasSubscription }">
                        <div class="option-container">
                            <img src="/storage/img/coffee-pack.svg" class="option-icon"/>
                            <span class="option-name">咖啡订购</span>
                        </div>
                    </div>
                </div>
            </div>

            <div id="brew-methods-container">
                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <label class="filter-label">冲泡方法</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <div class="brew-method option" v-on:click="toggleBrewMethodFilter( method.id )"
                             v-for="method in brewMethods" v-if="method.cafes_count > 0"
                             v-bind:class="{'active': brewMethodsFilter.indexOf( method.id ) >= 0 }">
                            <div class="option-container">
                                <img v-bind:src="method.icon" class="option-icon"/> <span class="option-name">{{ method.method }}</span>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <div id="drink-options-container">
                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <label class="filter-label">饮料选项</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-12 medium-12 small-12 cell">
                        <div class="drink-option option" v-on:click="toggleMatchaFilter()"
                             v-bind:class="{'active':hasMatcha}">
                            <div class="option-container">
                                <img src="/storage/img/matcha-latte.svg" class="option-icon"/>
                                <span class="option-name">抹茶</span>
                            </div>
                        </div>
                        <div class="drink-option option" v-on:click="toggleTeaFilter()"
                             v-bind:class="{'active':hasTea}">
                            <div class="option-container">
                                <img src="/storage/img/tea-bag.svg" class="option-icon"/>
                                <span class="option-name">茶包</span>
                            </div>
                        </div>
                    </div>
                </div>
            </div>

        </div>
    </transition>
</template>

<script>

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

    export default {
        mounted() {
            // 显示过滤器
            EventBus.$on('show-filters', function () {
                this.show = true;
            }.bind(this));
            // 清除过滤器
            EventBus.$on('clear-filters', function () {
                this.clearFilters();
            }.bind(this));
        },
        watch: {
            'cityFilter': function () {
                if (this.cityFilter != '') {
                    let slug = '';

                    for (let i = 0; i < this.cities.length; i++) {
                        if (this.cities[i].id === this.cityFilter) {
                            slug = this.cities[i].slug;
                        }
                    }
                    if (slug == '') {
                        this.$router.push({name: 'cafes'});
                    } else {
                        this.$router.push({name: 'city', params: {slug: slug}});
                    }
                } else {
                    this.$router.push({name: 'cafes'});
                }
            },
            'citiesLoadStatus': function () {
                if (this.citiesLoadStatus === 2 && this.$route.name === 'city') {
                    let id = '';
                    for (let i = 0; i < this.cities.length; i++) {
                        if (this.cities[i].slug === this.$route.params.slug) {
                            this.cityFilter = this.cities[i].id;
                        }
                    }
                }
            }
        },

        computed: {

            cities() {
                return this.$store.getters.getCities;
            },

            citiesLoadStatus() {
                return this.$store.getters.getCitiesLoadStatus;
            },

            cityFilter: {
                set(cityFilter) {
                    this.$store.commit('setCityFilter', cityFilter);
                },
                get() {
                    return this.$store.getters.getCityFilter;
                }
            },

            showFilters() {
                return this.$store.getters.getShowFilters;
            },

            brewMethods() {
                return this.$store.getters.getBrewMethods;
            },

            user() {
                return this.$store.getters.getUser;
            },

            userLoadStatus() {
                return this.$store.getters.getUserLoadStatus();
            },

            cafesView() {
                return this.$store.getters.getCafesView;
            },

            textSearch: {
                set(textSearch) {
                    this.$store.commit('setTextSearch', textSearch)
                },
                get() {
                    return this.$store.getters.getTextSearch;
                }
            },

            activeLocationFilter() {
                return this.$store.getters.getActiveLocationFilter;
            },

            onlyLiked: {
                set(onlyLiked) {
                    this.$store.commit('setOnlyLiked', onlyLiked);
                },
                get() {
                    return this.$store.getters.getOnlyLiked;
                }
            },

            brewMethodsFilter() {
                return this.$store.getters.getBrewMethodsFilter;
            },

            hasMatcha() {
                return this.$store.getters.getHasMatcha;
            },

            hasTea() {
                return this.$store.getters.getHasTea;
            },

            hasSubscription() {
                return this.$store.getters.getHasSubscription;
            }
        },

        methods: {

            setActiveLocationFilter(filter) {
                this.$store.dispatch('updateActiveLocationFilter', filter);
            },

            toggleBrewMethodFilter(id) {
                let localBrewMethodsFilter = this.brewMethodsFilter;
                /*
                  If the filter is in the selected filter, we remove it, otherwise
                  we add it.
                */
                if (localBrewMethodsFilter.indexOf(id) >= 0) {
                    localBrewMethodsFilter.splice(localBrewMethodsFilter.indexOf(id), 1);
                } else {
                    localBrewMethodsFilter.push(id);
                }
                this.$store.dispatch('updateBrewMethodsFilter', localBrewMethodsFilter);
            },

            toggleShowFilters() {
                this.$store.dispatch('toggleShowFilters', {showFilters: !this.showFilters});
            },

            toggleMatchaFilter() {
                this.$store.dispatch('updateHasMatcha', !this.hasMatcha);
            },

            toggleTeaFilter() {
                this.$store.dispatch('updateHasTea', !this.hasTea);
            },

            toggleSubscriptionFilter() {
                this.$store.dispatch('updateHasSubscription', !this.hasSubscription);
            },

            clearFilters() {
                this.$store.dispatch('resetFilters');
            }
        }
    }
</script>

resources/assets/js/components/global/PopOut.vue 用于实现滑出菜单:

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

    div.pop-out {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        top: 0;
        background-color: rgba(55, 44, 12, .29);
        z-index: 9999;
        div.pop-out-side-bar {
            position: fixed;
            right: 0;
            bottom: 0;
            top: 0;
            width: 250px;
            background-color: white;
            box-shadow: -2px 0 4px 0 rgba(3, 27, 78, 0.10);
            padding: 30px;
            div.side-bar-link {
                border-bottom: 1px solid #BABABA;
                font-size: 16px;
                font-weight: bold;
                font-family: "Lato", sans-serif;
                text-transform: uppercase;
                padding-top: 25px;
                padding-bottom: 25px;
                a {
                    color: black;
                }
            }
            img.close-menu-icon {
                float: right;
                cursor: pointer;
            }
            div.ssu-container {
                position: absolute;
                bottom: 30px;
                span.ssu-built-on {
                    color: black;
                    font-size: 14px;
                    font-family: "Lato", sans-serif;
                    display: block;
                    margin-bottom: 10px;
                }
                img {
                    margin: auto;
                    max-width: 190px;
                }
            }
        }
    }
</style>

<template>
    <div class="pop-out" v-show="showPopOut" v-on:click="hideNav()">
        <transition name="slide-in-right">
            <div class="pop-out-side-bar" v-show="showRightNav" v-on:click.stop>
                <img src="/storage/img/close-menu.svg" class="close-menu-icon" v-on:click="hideNav()"/>
                <div class="side-bar-link">
                    <router-link :to="{ name: 'cafes' }" v-on:click.native="hideNav()">
                        咖啡店
                    </router-link>
                </div>
                <div class="side-bar-link" v-if="user != '' && userLoadStatus === 2">
                    <router-link :to="{ name: 'newcafe' }" v-on:click.native="hideNav()">
                        新增咖啡店
                    </router-link>
                </div>
                <div class="side-bar-link" v-if="user != '' && userLoadStatus === 2">
                    <router-link :to="{ name: 'profile' }" v-on:click.native="hideNav()">
                        个人信息
                    </router-link>
                </div>
                <div class="side-bar-link" v-if="user != '' && userLoadStatus === 2 && user.permission >= 1">
                    <router-link :to="{ name: 'admin'}" v-on:click.native="hideNav()">
                        后台
                    </router-link>
                </div>
                <div class="side-bar-link">
                    <a v-if="user != '' && userLoadStatus === 2" v-show="userLoadStatus === 2"
                       v-on:click="logout()">退出</a>
                    <a v-if="user == ''" v-on:click="login()">登录</a>
                </div>
                <div class="side-bar-link">
                    <a href="https://github.com/nonfu/roastapp/issues/new/choose" target="_blank">
                        提交bug
                    </a>
                </div>
                <div class="side-bar-link">
                    <a href="https://laravelacademy.org/api-driven-development-laravel-vue" target="_blank">
                        项目文档
                    </a>
                </div>
                <div class="side-bar-link">
                    <a href="https://github.com/nonfu/roastapp" target="_blank">
                        在Github上查看
                    </a>
                </div>
            </div>
        </transition>
    </div>
</template>

<script>

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

    export default {

        computed: {
            showPopOut() {
                return this.$store.getters.getShowPopOut;
            },

            showRightNav() {
                return this.showPopOut;
            },

            user() {
                return this.$store.getters.getUser;
            },

            userLoadStatus() {
                return this.$store.getters.getUserLoadStatus();
            }
        },

        methods: {
            hideNav() {
                this.$store.dispatch('toggleShowPopOut', {showPopOut: false});
            },

            login() {
                this.$store.dispatch('toggleShowPopOut', {showPopOut: false});
                EventBus.$emit('prompt-login');
            },

            logout() {
                this.$store.dispatch('logoutUser');
                window.location = '/logout';
            }
        }
    }
</script>

最后,我们还要修改原来的导航组件 resources/assets/js/components/global/Navigation.vue

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

    nav.top-navigation {
        background-color: #FFFFFF;
        height: 75px;
        box-shadow: 0 2px 4px 0 rgba(3, 27, 78, 0.1);
        z-index: 9999;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        a.filters {
            cursor: pointer;
            color: $secondary-color;
            width: 140px;
            height: 45px;
            border: 2px solid $secondary-color;
            border-radius: 3px;
            text-transform: uppercase;
            display: block;
            float: left;
            text-align: center;
            line-height: 41px;
            margin-top: 15px;
            margin-left: 20px;
            font-family: "Lato", sans-serif;
            font-weight: bold;
            font-size: 16px;
            img {
                display: inline-block;
                vertical-align: middle;
                margin-right: 10px;
                height: 13px;
                &.list {
                    transform: rotate(-90deg);
                }
            }
            img.chevron-active {
                display: none;
            }
            &.active {
                background-color: $secondary-color;
                color: white;
                img.chevron {
                    display: none;
                }
                img.chevron-active {
                    display: inline-block;
                    &.list {
                        transform: rotate(-90deg);
                    }
                }
            }
            span.filter-count-active {
                display: inline-block;
                margin-left: 5px;
            }
        }
        span.clear-filters {
            font-size: 16px;
            color: $text-secondary-color;
            font-family: "Lato", sans-serif;
            cursor: pointer;
            margin-left: 15px;
            display: block;
            float: left;
            margin-top: 25px;
            img {
                margin-right: 10px;
                float: left;
                margin-top: 6px;
            }
        }
        img.logo {
            margin: auto;
            margin-top: 22.5px;
            margin-bottom: 22.5px;
            display: block;
        }
        img.hamburger {
            float: right;
            margin-right: 18px;
            margin-top: 30px;
            cursor: pointer;
        }
        img.avatar {
            float: right;
            margin-right: 20px;
            width: 40px;
            height: 40px;
            border-radius: 20px;
            margin-top: 18px;
        }
        &:after {
            content: "";
            display: table;
            clear: both;
        }
        span.login {
            font-family: "Lato", sans-serif;
            font-size: 16px;
            text-transform: uppercase;
            color: black;
            font-weight: bold;
            float: right;
            margin-top: 27px;
            margin-right: 15px;
            cursor: pointer;
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        nav.top-navigation {
            a.filters {
                line-height: 31px;
                margin-top: 20px;
                width: 75px;
                height: 35px;
                img {
                    display: none;
                }
                &.active {
                    img.chevron-active {
                        display: none;
                    }
                }
            }
            span.clear-filters {
                display: none;
            }
            span.login {
                display: none;
            }
            img.hamburger {
                margin-top: 26px;
            }
        }
    }

    /* Medium only */
    @media screen and (min-width: 40em) and (max-width: 63.9375em) {
    }

    /* Large only */
    @media screen and (min-width: 64em) and (max-width: 74.9375em) {
    }
</style>

<template>
    <nav class="top-navigation">
        <div class="grid-x">
            <div class="large-4 medium-4 small-4 cell">
                <a class="filters" v-bind:class="{'active': showFilters}" v-on:click="toggleShowFilters()">
                    <img class="chevron" v-bind:class="{'list' : cafesView === 'list'}"
                         src="/storage/img/chevron-right.svg"/>
                    <img class="chevron-active" v-bind:class="{'list' : cafesView === 'list'}"
                         src="/storage/img/chevron-right-active.svg"/> 过滤器
                    <span class="filter-count-active" v-show="activeFilterCount > 0">({{ activeFilterCount }})</span>
                </a>

                <span class="clear-filters" v-show="showFilters" v-on:click="clearFilters()">
                    <img src="/storage/img/clear-filters-icon.svg"/> 清除过滤器
                </span>

            </div>
            <div class="large-4 medium-4 small-4 cell">
                <router-link :to="{ name: 'cafes'}">
                    <img src="/storage/img/logo.svg" class="logo"/>
                </router-link>
            </div>
            <div class="large-4 medium-4 small-4 cell">
                <img class="hamburger" src="/storage/img/hamburger.svg" v-on:click="setShowPopOut()"/>
                <img class="avatar" v-if="user != '' && userLoadStatus === 2" :src="user.avatar"
                     v-show="userLoadStatus === 2"/>
                <span class="login" v-if="user == ''" v-on:click="login()">登录</span>
            </div>
        </div>
    </nav>
</template>

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

    export default {
        // 定义组件的计算属性
        computed: {
            // 从 Vuex 中获取用户加载状态
            userLoadStatus() {
                return this.$store.getters.getUserLoadStatus();
            },

            // 从 Vuex 中获取用户信息
            user() {
                return this.$store.getters.getUser;
            },

            showFilters() {
                return this.$store.getters.getShowFilters;
            },

            cafesView() {
                return this.$store.getters.getCafesView;
            },

            cityFilter() {
                return this.$store.getters.getCityFilter;
            },

            textSearch() {
                return this.$store.getters.getTextSearch;
            },

            activeLocationFilter() {
                return this.$store.getters.getActiveLocationFilter;
            },

            onlyLiked() {
                return this.$store.getters.getOnlyLiked;
            },

            brewMethods() {
                return this.$store.getters.getBrewMethodsFilter;
            },

            hasMatcha() {
                return this.$store.getters.getHasMatcha;
            },

            hasTea() {
                return this.$store.getters.getHasTea;
            },

            hasSubscription() {
                return this.$store.getters.getHasSubscription;
            },

            activeFilterCount() {
                let activeCount = 0;
                if (this.textSearch !== '') {
                    activeCount++;
                }
                if (this.activeLocationFilter !== 'all') {
                    activeCount++;
                }
                if (this.onlyLiked) {
                    activeCount++;
                }
                if (this.brewMethods.length !== 0) {
                    activeCount++;
                }
                if (this.hasMatcha) {
                    activeCount++;
                }
                if (this.hasTea) {
                    activeCount++;
                }
                if (this.hasSubscription) {
                    activeCount++;
                }
                if (this.cityFilter !== '') {
                    activeCount++;
                }
                return activeCount;
            }
        },

        methods: {
            login() {
                EventBus.$emit('prompt-login');
            },

            logout() {
                this.$store.dispatch('logoutUser');
                window.location = '/logout';
            },

            toggleShowFilters() {
                this.$store.dispatch('toggleShowFilters', {showFilters: !this.showFilters});
            },

            setShowPopOut() {
                this.$store.dispatch('toggleShowPopOut', {showPopOut: true});
            },

            clearFilters() {
                EventBus.$emit('clear-filters');
            }
        }
    }
</script>

以及登录模态框组件 resources/assets/js/components/global/LoginModal.vue

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

    div#login-modal {
        position: fixed;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        background-color: rgba(0, 0, 0, .6);
        z-index: 99999;
        div.login-box {
            width: 100%;
            max-width: 530px;
            min-width: 320px;
            padding: 20px;
            background-color: #fff;
            border: 1px solid #ddd;
            -webkit-box-shadow: 0 1px 3px rgba(50, 50, 50, 0.08);
            box-shadow: 0 1px 3px rgba(50, 50, 50, 0.08);
            -webkit-border-radius: 4px;
            border-radius: 4px;
            font-size: 16px;
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            a.social-link {
                display: block;
                margin: auto;
                width: 230px;
                margin-top: 10px;
                margin-bottom: 10px;
            }
            div.login-label {
                color: black;
                font-family: "Lato", sans-serif;
                font-weight: bold;
                text-transform: uppercase;
                text-align: center;
                margin-top: 40px;
                margin-bottom: 20px;
            }
            p.learn-more-description {
                color: #666666;
                text-align: center;
            }
            a.learn-more-button {
                border: 2px solid $secondary-color;
                border-radius: 3px;
                text-transform: uppercase;
                font-family: "Lato", sans-serif;
                color: $secondary-color;
                width: 360px;
                font-size: 16px;
                text-align: center;
                padding: 10px;
                margin-top: 20px;
                display: block;
                margin: auto;
                &:hover {
                    color: white;
                    background-color: $secondary-color;
                }
            }
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        div#login-modal {
            div.login-box {
                width: 95%;
                a.learn-more-button {
                    width: 300px;
                }
            }
        }
    }

    /* Medium only */
    @media screen and (min-width: 40em) and (max-width: 63.9375em) {
    }

    /* Large only */
    @media screen and (min-width: 64em) and (max-width: 74.9375em) {
    }
</style>

<template>
    <div id="login-modal" v-show="show" v-on:click="show = false">
        <div class="login-box" v-on:click.stop="">
            <div class="login-label">使用第三方服务登录</div>

            <a href="/auth/github" v-on:click.stop="">
                <img src="/storage/img/github-login.jpg"/>
            </a>

            <div class="login-label">关于本项目</div>

            <p class="learn-more-description">Roast 项目由 <a href="https://laravelacademy.org" target="_blank">Laravel 学院</a>提供,Laravel 学院致力于提供优质 Laravel 中文学习资源。</p>

            <a class="learn-more-button" href="https://laravelacademy.org/api-driven-development-laravel-vue" target="_blank">关于本项目的构建教程,可以在这里看到</a>
        </div>
    </div>
</template>

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

    export default {
        data() {
            return {
                show: false
            }
        },

        mounted() {
            EventBus.$on('prompt-login', function () {
                this.show = true;
            }.bind(this));
        }
    }
</script>

注:以上组件中用到的图片都可以到 https://github.com/nonfu/roastapp/tree/master/storage/app/public/img 去下载,后面也是一样,不再赘述。

第八步:重构 Home 组件

修改 resources/assets/js/pages/Home.vue 组件代码如下:

<style>

</style>

<template>
    <div id="cafes" class="page">
        <cafe-map v-show="cafesView === 'map'"></cafe-map>
        <cafe-list v-show="cafesView === 'list'"></cafe-list>

        <add-cafe-button></add-cafe-button>
        <toggle-cafes-view></toggle-cafes-view>
        <map-legend></map-legend>

        <router-view></router-view>
    </div>
</template>

<script>
    import CafeMap from '../components/cafes/CafeMap.vue';
    import CafeList from '../components/cafes/CafeList.vue';
    import AddCafeButton from '../components/cafes/AddCafeButton.vue';
    import ToggleCafesView from '../components/cafes/ToggleCafesView.vue';
    import MapLegend from '../components/cafes/MapLegend.vue';

    export default {
        components: {
            CafeMap,
            CafeList,
            AddCafeButton,
            ToggleCafesView,
            MapLegend
        },

        computed: {
            cafesView() {
                return this.$store.getters.getCafesView;
            }
        }
    }
</script>

同样我们新增了一些组件。

resources/assets/js/components/cafes/CafeList.vue 用于渲染咖啡店列表:

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

    div#cafe-list-container {
        position: absolute;
        top: 75px;
        left: 0px;
        right: 0px;
        bottom: 0px;
        background-color: white;
        overflow-y: scroll;
        div.cafe-grid-container {
            max-width: 900px;
            margin: auto;
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        div.cafe-grid-container {
            height: inherit;
        }
    }
</style>

<template>
    <div id="cafe-list-container">
        <div class="grid-x grid-padding-x cafe-grid-container">
            <list-filters></list-filters>
        </div>

        <div class="grid-x grid-padding-x cafe-grid-container" id="cafe-grid">
            <cafe-card v-for="cafe in cafes" :key="cafe.id" :cafe="cafe"></cafe-card>
            <div class="large-12 medium-12 small-12 cell">
                <span class="no-results" v-if="shownCount === 0">No Results</span>
            </div>
        </div>
    </div>
</template>

<script>

    import CafeCard from '../../components/cafes/CafeCard.vue';
    import ListFilters from '../../components/cafes/ListFilters.vue';

    export default {
        data() {
            return {
                shownCount: 1
            }
        },

        components: {
            CafeCard,
            ListFilters
        },

        computed: {
            cafes() {
                return this.$store.getters.getCafes;
            },

            cafesView() {
                return this.$store.getters.getCafesView;
            }
        }
    }
</script>

在这个组件中引入了一个新的组件 resources/assets/js/components/cafes/ListFilters.vue,其实现思路和 Filters.vue 完全一致,详细代码可以从这里拷贝:https://github.com/nonfu/roastapp/blob/master/resources/assets/js/components/cafes/ListFilters.vue

另外,resources/assets/js/components/cafes/CafeCard.vue 代码也有调整:https://github.com/nonfu/roastapp/blob/master/resources/assets/js/components/cafes/CafeCard.vue

回到 Home.vue 组件,resources/assets/js/components/cafes/AddCafeButton.vue 用于实现新增咖啡店按钮:

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

    div#add-cafe-button {
        background-color: $secondary-color;
        width: 56px;
        height: 56px;
        border-radius: 50px;
        -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2);
        text-align: center;
        z-index: 9;
        cursor: pointer;
        position: absolute;
        right: 60px;
        bottom: 30px;
        color: white;
        line-height: 50px;
        font-size: 40px;
    }
</style>

<template>
    <div id="add-cafe-button" v-on:click="checkAuth()">
        &plus;
    </div>
</template>

<script>

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

    export default {

        computed: {
            user() {
                return this.$store.getters.getUser;
            },

            userLoadStatus() {
                return this.$store.getters.getUserLoadStatus();
            }
        },

        methods: {
            // 如果用户已经登录,跳转到新增咖啡店页面,否则弹出登录框
            checkAuth() {
                if (this.user == '' && this.userLoadStatus === 2) {
                    EventBus.$emit('prompt-login');
                } else {
                    this.$router.push({name: 'newcafe'});
                }
            }
        }
    }
</script>

resources/assets/js/components/cafes/ToggleCafesView.vue 用于在地图和列表布局之间进行切换:

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

    div#toggle-cafes-view {
        position: absolute;
        z-index: 9;
        right: 15px;
        top: 90px;
        -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
        border-radius: 5px;
        span.toggle-button {
            cursor: pointer;
            display: inline-block;
            padding: 5px 20px;
            background-color: white;
            font-family: "Lato", sans-serif;
            text-align: center;
            &.map-view {
                border-top-left-radius: 5px;
                border-bottom-left-radius: 5px;
                &.active {
                    color: white;
                    background-color: $secondary-color;
                }
            }
            &.list-view {
                border-top-right-radius: 5px;
                border-bottom-right-radius: 5px;
                &.active {
                    color: white;
                    background-color: $secondary-color;
                }
            }
        }
    }
</style>

<template>
    <div id="toggle-cafes-view" v-show="$route.name === 'cafes' || $route.name === 'city'">
        <span class="map-view toggle-button" v-bind:class="{ 'active': cafesView === 'map' }"
              v-on:click="displayView('map')">地图</span>
        <span class="list-view toggle-button" v-bind:class="{ 'active': cafesView == 'list' }"
              v-on:click="displayView('list')">列表</span>
    </div>
</template>

<script>
    export default {

        computed: {
            cafesView() {
                return this.$store.getters.getCafesView;
            }
        },

        methods: {
            displayView(type) {
                this.$store.dispatch('changeCafesView', type);
            }
        }
    }
</script>

resources/assets/js/components/cafes/MapLegend.vue 用于渲染图例:

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

    div#map-legend {
        position: absolute;
        z-index: 9;
        left: 15px;
        bottom: 90px;
        -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
        box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 0 1px rgba(0, 0, 0, 0.08);
        border-radius: 5px;
        background-color: white;
        padding: 10px;
        max-width: 200px;
        font-family: "Lato", sans-serif;
        span.legend-title {
            display: block;
            text-align: center;
            font-family: "Lato", sans-serif;
            margin-bottom: 10px;
            font-size: 18px;
            font-weight: bold;
        }
        div.legend-row {
            margin-bottom: 5px;
            img {
                margin-right: 10px;
            }
        }
    }
</style>

<template>
    <div id="map-legend" v-show="( !showFilters && cafesView === 'map' )">
        <div class="grid-x">
            <div class="large-12 medium-12 small-12 cell">
                <span class="legend-title">图例</span>
            </div>
            <div class="large-12 medium-12 small-12 cell legend-row">
                <img src="/storage/img/roaster-marker.svg"/> 烘焙店
            </div>
            <div class="large-12 medium-12 small-12 cell legend-row">
                <img src="/storage/img/cafe-marker.svg"/> 咖啡店
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        data() {
            return {}
        },

        computed: {
            showFilters() {
                return this.$store.getters.getShowFilters;
            },

            cafesView() {
                return this.$store.getters.getCafesView;
            }
        }
    }
</script>

最后,用于在地图上以点标记渲染咖啡店的地图组件 resources/assets/js/components/cafes/CafeMap.vue 代码也有很大的调整:

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

    div#cafe-map-container {
        position: absolute;
        top: 75px;
        left: 0px;
        right: 0px;
        bottom: 0px;

        div#cafe-map {
            position: absolute;
            top: 0px;
            left: 0px;
            right: 0px;
            bottom: 0px;
        }

        div.cafe-info-window {

            div.cafe-name {
                display: block;
                text-align: center;
                color: $dark-color;
                font-family: 'Josefin Sans', sans-serif;
            }

            div.cafe-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: 12px;
                }
                span.state {
                    font-size: 12px;
                }
                span.zip {
                    font-size: 12px;
                    display: block;
                }
                a {
                    color: $secondary-color;
                    font-weight: bold;
                }
            }
        }
    }
</style>

<template>
    <div id="cafe-map-container">
        <div id="cafe-map">

        </div>
    </div>
</template>

<script>
    import {ROAST_CONFIG} from '../../config.js';
    import {EventBus} from '../../event-bus.js';

    import {CafeTypeFilter} from '../../mixins/filters/CafeTypeFilter.js';
    import {CafeBrewMethodsFilter} from '../../mixins/filters/CafeBrewMethodsFilter.js';
    import {CafeTagsFilter} from '../../mixins/filters/CafeTagsFilter.js';
    import {CafeTextFilter} from '../../mixins/filters/CafeTextFilter.js';
    import {CafeUserLikeFilter} from '../../mixins/filters/CafeUserLikeFilter.js';
    import {CafeHasMatchaFilter} from '../../mixins/filters/CafeHasMatchaFilter.js';
    import {CafeHasTeaFilter} from '../../mixins/filters/CafeHasTeaFilter.js';
    import {CafeSubscriptionFilter} from '../../mixins/filters/CafeSubscriptionFilter.js';
    import {CafeInCityFilter} from '../../mixins/filters/CafeInCityFilter.js';
    import cafe from "../../api/cafe";

    export default {
        mixins: [
            CafeTypeFilter,
            CafeBrewMethodsFilter,
            CafeTagsFilter,
            CafeTextFilter,
            CafeUserLikeFilter,
            CafeHasMatchaFilter,
            CafeHasTeaFilter,
            CafeSubscriptionFilter,
            CafeInCityFilter
        ],
        props: {
            'latitude': {
                type: Number,
                default: function () {
                    return 120.21
                }
            },
            'longitude': {
                type: Number,
                default: function () {
                    return 30.29
                }
            },
            'zoom': {
                type: Number,
                default: function () {
                    return 5
                }
            }
        },
        data() {
            return {
                markers: [],
                infoWindows: []
            }
        },
        mounted() {
            this.markers = [];
            this.map = new AMap.Map('cafe-map', {
                center: [this.latitude, this.longitude],
                zoom: this.zoom
            });
            this.clearMarkers();
            this.buildMarkers();

            // 监听位置选择事件
            EventBus.$on('location-selected', function (cafe) {
                var latLng = new AMap.LngLat(cafe.lat, cafe.lng);
                this.map.setZoom(17);
                this.map.panTo(latLng);
            }.bind(this));

            // 监听城市选择事件
            EventBus.$on('city-selected', function (city) {
                var latLng = new AMap.LngLat(city.lat, city.lng);
                this.map.setZoom(11);
                this.map.panTo(latLng);
            }.bind(this));
        },
        computed: {
            cafes() {
                return this.$store.getters.getCafes;
            },

            city() {
                return this.$store.getters.getCity;
            },

            cityFilter() {
                return this.$store.getters.getCityFilter;
            },

            textSearch() {
                return this.$store.getters.getTextSearch;
            },

            activeLocationFilter() {
                return this.$store.getters.getActiveLocationFilter;
            },

            onlyLiked() {
                return this.$store.getters.getOnlyLiked;
            },

            brewMethodsFilter() {
                return this.$store.getters.getBrewMethodsFilter;
            },

            hasMatcha() {
                return this.$store.getters.getHasMatcha;
            },

            hasTea() {
                return this.$store.getters.getHasTea;
            },

            hasSubscription() {
                return this.$store.getters.getHasSubscription;
            },

            previousLat() {
                return this.$store.getters.getLat;
            },

            previousLng() {
                return this.$store.getters.getLng;
            },

            previousZoom() {
                return this.$store.getters.getZoomLevel;
            }
        },
        methods: {
            // 为所有咖啡店创建点标记
            buildMarkers() {
                // 初始化点标记数组
                this.markers = [];

                // 自定义点标记
                /*var image = ROAST_CONFIG.APP_URL + '/storage/img/coffee-marker.png';
                var icon = new AMap.Icon({
                    image: image,  // Icon的图像
                    imageSize: new AMap.Size(19, 33)
                });*/

                // 遍历所有咖啡店创建点标记
                // var infoWindow = new AMap.InfoWindow();
                for (var i = 0; i < this.cafes.length; i++) {

                    if (this.cafes[i].company.roaster === 1) {
                        var image = ROAST_CONFIG.APP_URL + '/storage/img/roaster-marker.svg';
                    } else {
                        var image = ROAST_CONFIG.APP_URL + '/storage/img/cafe-marker.svg';
                    }
                    var icon = new AMap.Icon({
                        image: image,  // Icon的图像
                        imageSize: new AMap.Size(19, 33)
                    });

                    // 为每个咖啡店创建点标记并设置经纬度
                    var marker = new AMap.Marker({
                        position: new AMap.LngLat(parseFloat(this.cafes[i].latitude), parseFloat(this.cafes[i].longitude)),
                        title: this.cafes[i].location_name,
                        icon: icon
                    });

                    // 自定义信息窗体
                    /*var contentString = '<div class="cafe-info-window">' +
                        '<div class="cafe-name">' + this.cafes[i].name + this.cafes[i].location_name + '</div>' +
                        '<div class="cafe-address">' +
                        '<span class="street">' + this.cafes[i].address + '</span>' +
                        '<span class="city">' + this.cafes[i].city + '</span> ' +
                        '<span class="state">' + this.cafes[i].state + '</span>' +
                        '<span class="zip">' + this.cafes[i].zip + '</span>' +
                        '<a href="/#/cafes/' + this.cafes[i].id + '">Visit</a>' +
                        '</div>' +
                        '</div>';
                    marker.content = contentString;*/
                    marker.cafeId = this.cafes[i].id;

                    // 绑定点击事件到点标记对象,点击跳转到咖啡店详情页
                    marker.on('click', mapClick);

                    // 将点标记放到数组中
                    this.markers.push(marker);
                }

                function mapClick(mapEvent) {
                    // infoWindow.setContent(mapEvent.target.content);
                    // infoWindow.open(this.getMap(), this.getPosition());
                    let center = this.getMap().getCenter();
                    this.$store.dispatch('applyZoomLevel', this.getMap().getZoom());
                    this.$store.dispatch('applyLat', center.getLat());
                    this.$store.dispatch('applyLng', center.getLng());
                    this.$router.push({name: 'cafe', params: {id: mapEvent.target.cafeId}});
                }

                // 将所有点标记显示到地图上
                this.map.add(this.markers);
            },
            // 从地图上清理点标记
            clearMarkers() {
                // 遍历所有点标记并将其设置为 null 从而从地图上将其清除
                for (var i = 0; i < this.markers.length; i++) {
                    this.markers[i].setMap(null);
                }
            },
            processFilters(filters) {
                for (var i = 0; i < this.markers.length; i++) {
                    if (this.textSearch === ''
                        && this.activeLocationFilter === 'all'
                        && this.brewMethodsFilter.length === 0
                        && !this.onlyLiked
                        && !this.hasMatcha
                        && !this.hasTea
                        && !this.hasSubscription
                        && this.cityFilter === '') {
                        this.markers[i].setMap(this.map);
                    } else {
                        // 初始化过滤器标识
                        var textPassed = false;
                        var brewMethodsPassed = false;
                        var typePassed = false;
                        var likedPassed = false;
                        var matchaPassed = false;
                        var teaPassed = false;
                        var subscriptionPassed = false;
                        var cityPassed = false;

                        if (this.processCafeTypeFilter(this.markers[i].cafe, this.activeLocationFilter)) {
                            typePassed = true;
                        }

                        if (this.textSearch !== '' && this.processCafeTextFilter(this.markers[i].cafe, this.textSearch)) {
                            textPassed = true;
                        } else if (this.textSearch === '') {
                            textPassed = true;
                        }

                        if (this.brewMethodsFilter.length !== 0 && this.processCafeBrewMethodsFilter(this.markers[i].cafe, this.brewMethodsFilter)) {
                            brewMethodsPassed = true;
                        } else if (this.brewMethodsFilter.length === 0) {
                            brewMethodsPassed = true;
                        }

                        if (this.onlyLiked && this.processCafeUserLikeFilter(this.markers[i].cafe)) {
                            likedPassed = true;
                        } else if (!this.onlyLiked) {
                            likedPassed = true;
                        }

                        if (this.hasMatcha && this.processCafeHasMatchaFilter(this.markers[i].cafe)) {
                            matchaPassed = true;
                        } else if (!this.hasMatcha) {
                            matchaPassed = true;
                        }

                        if (this.hasTea && this.processCafeHasTeaFilter(this.markers[i].cafe)) {
                            teaPassed = true;
                        } else if (!this.hasTea) {
                            teaPassed = true;
                        }

                        if (this.hasSubscription && this.processCafeSubscriptionFilter(this.markers[i].cafe)) {
                            subscriptionPassed = true;
                        } else if (!this.hasSubscription) {
                            subscriptionPassed = true;
                        }

                        if (this.cityFilter !== '' && this.processCafeInCityFilter(this.markers[i].cafe, this.cityFilter)) {
                            cityPassed = true;
                        } else if (this.cityFilter === '') {
                            cityPassed = true;
                        }

                        if (typePassed && textPassed && brewMethodsPassed && likedPassed && matchaPassed && teaPassed && subscriptionPassed && cityPassed) {
                            this.markers[i].setMap(this.map);
                        } else {
                            this.markers[i].setMap(null);
                        }
                    }
                }
            },
        },
        watch: {
            // 一旦 cafes 有更新立即重构地图点标记
            cafes() {
                this.clearMarkers();
                this.buildMarkers();
                this.processFilters();
            },
            // 如果路由从咖啡店详情页切换到咖啡店列表,检查之前的经纬度是否设置,
            // 如果设置的话将其作为新绘制地图的定位点
            '$route'(to, from) {
                if (to.name === 'cafes' && from.name === 'cafe') {
                    if (this.previousLat !== 0.0 && this.previousLng !== 0.0 && this.previousZoom !== '') {
                        var latLng = new AMap.LngLat(this.previousLat, this.previousLng);
                        this.map.setZoom(this.previousZoom);
                        this.map.panTo(latLng);
                    }
                }
            },

            cityFilter() {
                this.processFilters();
            },

            textSearch() {
                this.processFilters();
            },

            activeLocationFilter() {
                this.processFilters();
            },

            onlyLiked() {
                this.processFilters();
            },

            brewMethodsFilter() {
                this.processFilters();
            },

            hasMatcha() {
                this.processFilters();
            },

            hasTea() {
                this.processFilters();
            },

            hasSubscription() {
                this.processFilters();
            }
        }
    }
</script>

我们在这个组件的 mixins 中新引入了多个过滤器函数,大家可以在 https://github.com/nonfu/roastapp/tree/master/resources/assets/js/mixins/filters 拷贝/下载代码到本地,这里就不一一列举了,需要注意的是,在 buildMarkers 方法中,我们注释掉了之前的信息窗体实现代码,将点击点标记事件处理为跳转到咖啡店详情页。

第九步:优化全局 CSS

最后,我们来完全全局 CSS 文件的编写,打开 resources/assets/sass/app.scss,引入新的组件 SCSS 和动画效果 SCSS 文件:

@charset "UTF-8";

/* ==========================================================================
    Builds our style structure
        https://sass-guidelin.es/#the-7-1-pattern
   ========================================================================== */
/**
 * Table of Contents:
 *
 *  1. Abstracts
 */
@import 'abstracts/variables';

/**
 * 2: Components
 */
@import 'components/validations';
@import 'components/labels';
@import 'components/notification';
@import 'components/pac-container';
@import 'components/options';
@import 'components/cafe-type';

/**
 * 3: Layouts
 */
@import 'layouts/page';

/**
 * 4: Animations
 */
@import 'animations/slide-in-left';
@import 'animations/slide-in-right';
@import 'animations/scale-in-center';
@import 'animations/slide-out-back';
@import 'animations/slide-in-top';

@import 'node_modules/foundation-sites/assets/foundation.scss';

具体的 SCSS 文件源码可以从 https://github.com/nonfu/roastapp/tree/master/resources/assets/sass 下载或拷贝,因为文件太多,这里就不一一列举了。

完成以上编码工作后,运行 npm run dev 重新编译所有前端资源,访问应用首页,页面会跳转到 http://roast.test/#/cafes,并显示我们开头提供的重构后的页面效果,上面是地图布局,下面给出列表布局的渲染效果:

roast-app-list

完成首页重构后,我们将会在下一篇教程完成新增咖啡店功能的重构。

注:完整项目代码位于 nonfu/roastapp

学院君 has written 1176 articles

Laravel学院院长,终身学习者

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

16 条回复

  1. Totn Totn says:

    之前跟敲代码的时候不求甚解,一直不是很明白

    foo ({commit}, val) {
        commit('setAttributes', val);
    }

    为啥commit要带{},今天研究了一下,原来这个是ES6的语法, 以上等于:

    foo({commit: function}) ...

    即要求传入第一个参数为object,有commit属性。在 foo方法体中,调用commit等同于调用第一个参数的commit属性, 希望能对有疑问的同学有所帮 :)

  2. Mr_White_DT Mr_White_DT says:
    @ 学院君

    新的问题,在添加咖啡店的时候,冲泡方法传了个字符串,然后控制器接收的时候,你用了json_decode()当成json处理了,我在控制器字符串转数组就OK了,修改的时候,冲泡方法是json没问题

  3. Mr_White_DT Mr_White_DT says:
    @ 学院君

    报告学院君,又发现问题了,在GaodeMaps.php中获取最近距离城市$location = $latitude. ',' . $longitude;你没改过来,写反了,后边请求返回的数据全是空,然后后边就报错了

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