功能模块重构 & CSS 整体优化:首页篇
通过前面三十篇教程的讲解,我们已经完成了 Roast 应用的所有前端功能,相信你也已经初步掌握了基于 Laravel + Vue 实现前后端分离单页面应用的开发,接下来的几篇教程我们将围绕对现有 Roast 应用进行优化展开,对底层数据结构和前端功能模块进行重构,从而让应用的整体架构更加清晰,同时对 CSS 进行优化,从而让应用看上去更加美观,以首页为例,优化后的效果是这样的:
跟之前相对简陋的首页相比,可以说是很酷了。
在正式编码前,我们先来规划下对应用哪几块功能进行重构:
- 将咖啡店列表页合并到首页
- 移除信息窗体功能,点击咖啡店标记直接跳转到对应的咖啡店详情页
- 移除标签和标签过滤器(暂时)
- 移除文件上传功能,将其替换为上传咖啡店 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
应用这些迁移,完成数据库中数据表创建及修改。
第二步:模型类调整
首先是创建两个模型类 Company
和 City
:
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);
}
}
}
然后由于我们调整了咖啡店的数据结构,所以需要修改 CafesController
的 getCafes
和 getCafe
方法:
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.vue
和 Home.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://laravel.geekai.co/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://laravel.geekai.co" target="_blank">Laravel 学院</a>提供,Laravel 学院致力于提供优质 Laravel 中文学习资源。</p>
<a class="learn-more-button" href="https://laravel.geekai.co/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()">
+
</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
,并显示我们开头提供的重构后的页面效果,上面是地图布局,下面给出列表布局的渲染效果:
完成首页重构后,我们将会在下一篇教程完成新增咖啡店功能的重构。
注:完整项目代码位于 nonfu/roastapp。
16 Comments
还真是
还有个问题,在调了
我本周抽空把教程的里代码都调整下 辛苦你了 CEO
新的问题,在添加咖啡店的时候,冲泡方法传了个字符串,然后控制器接收的时候,你用了
json_decode()
当成json
处理了,我在控制器字符串转数组就OK了,修改的时候,冲泡方法是json
没问题新增的地方应该是漏掉了 JSON.stringify 了
之前跟敲代码的时候不求甚解,一直不是很明白
为啥
commit
要带{}
,今天研究了一下,原来这个是ES6的语法, 以上等于:即要求传入第一个参数为
object
,有commit
属性。在foo
方法体中,调用commit
等同于调用第一个参数的commit
属性, 希望能对有疑问的同学有所帮 :)