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

在这篇教程中我们将在前面重构的基础上对咖啡店详情页和个人信息编辑页进行重构。这两个页面的重构不涉及到后端逻辑的调整,主要改动都在前端。

一、咖啡店详情页重构

首先打开 resources/assets/js/components/cafes/CafeMap.vue,修改点标记点击回调函数实现:

let store = this.$store;
let router = this.$router;

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

点击应用首页地图上的点标记图标就会跳转到详情页。

接下来打开 resources/assets/js/pages/Cafe.vue,重构代码如下:

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

    div#cafe-page {
        position: absolute;
        right: 30px;
        top: 125px;
        background: #FFFFFF;
        box-shadow: 0 2px 4px 0 rgba(3, 27, 78, 0.10);
        width: 100%;
        max-width: 480px;
        padding: 20px;
        padding-top: 10px;
        img.close-icon {
            float: right;
            cursor: pointer;
            margin-top: 10px;
        }
        h2.cafe-title {
            color: #342C0C;
            font-size: 36px;
            line-height: 44px;
            font-family: "Lato", sans-serif;
            font-weight: bolder;
        }
        span.location-number {
            display: inline-block;
            color: #8E8E8E;
            font-size: 18px;
            span.location-image-container {
                width: 35px;
                text-align: center;
                display: inline-block;
            }
        }
        label.cafe-label {
            font-family: "Lato", sans-serif;
            text-transform: uppercase;
            font-weight: bold;
            color: black;
            margin-top: 20px;
            margin-bottom: 10px;
        }
        div.address-container {
            color: #666666;
            font-size: 18px;
            line-height: 23px;
            font-family: "Lato", sans-serif;
            margin-bottom: 5px;
            span.address {
                display: block;
            }
            span.city-state {
                display: block;
            }
            span.zip {
                display: block;
            }
        }
        a.cafe-website {
            font-family: "Lato", sans-serif;
            color: #543729;
            font-size: 18px;
        }
        img.social-icon {
            margin-top: 10px;
            margin-right: 10px;
        }
        a.suggest-cafe-edit {
            font-family: "Lato", sans-serif;
            color: #054E7A;
            font-size: 14px;
            display: inline-block;
            margin-top: 30px;
            text-decoration: underline;
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        div#cafe-page {
            position: fixed;
            right: 0px;
            left: 0px;
            top: 0px;
            bottom: 0px;
            z-index: 99999;
        }
    }

    /* 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="cafe-page"
         v-if="cafeLoadStatus === 2 || (cafeLoadStatus !== 2 && (cafeLikeActionStatus === 1 || cafeLikeActionStatus === 2 || cafeUnlikeActionStatus === 1 || cafeUnlikeActionStatus === 2 ))">
        <a v-on:click="leaveCafe()">
            <img class="close-icon" src="/storage/img/close-icon.svg"/>
        </a>
        <h2 class="cafe-title">{{ cafe.company.name }}</h2>
        <div class="grid-x">
            <div class="large-12 medium-12 small-12 cell">
                <toggle-like></toggle-like>
            </div>
        </div>
        <div class="grid-x" v-if="cafe.company.cafes_count > 1">
            <div class="large-12 medium-12 small-12 cell">
        <span class="location-number">
          <span class="location-image-container">
            <img src="/storage/img/location.svg"/>
          </span> {{ cafe.company.cafes_count }} other locations
        </span>
            </div>
        </div>
        <div class="grid-x">
            <div class="large-12 medium-12 small-12 cell">
                <label class="cafe-label">类型</label>
                <div class="location-type roaster" v-if="cafe.company.roaster === 1">
                    <img src="/storage/img/roaster-logo.svg"/> Roaster
                </div>
                <div class="location-type cafe" v-if="cafe.company.roaster === 0">
                    <img src="/storage/img/cafe-logo.svg"/> Cafe
                </div>
            </div>
        </div>
        <div class="grid-x" v-if="cafe.company.subscription === 1">
            <div class="large-12 medium-12 small-12 cell centered">
                <label class="cafe-label">提供咖啡订购</label>
                <div class="subscription-option option">
                    <div class="option-container">
                        <img src="/storage/img/coffee-pack.svg" class="option-icon"/> <span
                            class="option-name">咖啡订购</span>
                    </div>
                </div>
            </div>
        </div>
        <div class="grid-x">
            <div class="large-12 medium-12 small-12 cell">
                <label class="cafe-label">冲泡方法</label>
                <div class="brew-method option" v-for="method in cafe.brew_methods">
                    <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 class="grid-x" v-if="cafe.matcha === 1 || cafe.tea === 1">
            <div class="large-12 medium-12 small-12 cell">
                <label class="cafe-label">Drink Options</label>
                <div class="drink-option option" v-if="cafe.matcha === 1">
                    <div class="option-container">
                        <img v-bind:src="'/storage/img/matcha-latte.svg'" class="option-icon"/>
                        <span class="option-name">抹茶</span>
                    </div>
                </div>
                <div class="drink-option option" v-if="cafe.tea === 1">
                    <div class="option-container">
                        <img v-bind:src="'/storage/img/tea-bag.svg'" class="option-icon"/>
                        <span class="option-name">茶包</span>
                    </div>
                </div>
            </div>
        </div>
        <div class="grid-x">
            <div class="large-12 medium-12 small-12 cell">
                <label class="cafe-label">位置信息</label>
                <div class="address-container">
                    <span class="address">{{ cafe.address }}</span>
                    <span class="city-state">{{ cafe.city }}, {{ cafe.state }}</span>
                    <span class="zip">{{ cafe.zip }}</span>
                </div>

                <a class="cafe-website" target="_blank" v-bind:href="cafe.company.website">{{ cafe.company.website
                    }}</a>
                <br>
                <router-link :to="{ name: 'editcafe', params: { slug: cafe.slug } }"
                             v-show="userLoadStatus === 2 && user != ''" class="suggest-cafe-edit">
                    编辑
                </router-link>
                <a class="suggest-cafe-edit" v-if="userLoadStatus === 2 && user == ''" v-on:click="loginToEdit()">
                    登录后编辑
                </a>
            </div>
        </div>
    </div>
</template>

<script>

    import Loader from '../components/global/Loader.vue';
    import IndividualCafeMap from '../components/cafes/IndividualCafeMap.vue';
    import ToggleLike from '../components/cafes/ToggleLike.vue';

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

    export default {
        // 定义页面使用的组件
        components: {
            Loader,
            IndividualCafeMap,
            ToggleLike
        },

        // 页面创建时通过路由中的参数ID加载咖啡店数据
        created() {
            this.$store.dispatch('toggleShowFilters', {showFilters: false});
            this.$store.dispatch('changeCafesView', 'map');
            this.$store.dispatch('loadCafe', {
                id: this.$route.params.id
            });
        },

        // 定义计算属性
        watch: {
            '$route.params.id': function () {
                this.$store.dispatch('clearLikeAndUnlikeStatus');
                this.$store.dispatch('loadCafe', {
                    id: this.$route.params.id
                });
            },
            cafeLoadStatus() {
                if (this.cafeLoadStatus === 2) {
                    EventBus.$emit('location-selected', {
                        lat: parseFloat(this.cafe.latitude),
                        lng: parseFloat(this.cafe.longitude)
                    });
                }

                if (this.cafeLoadStatus === 3) {
                    EventBus.$emit('show-error', {notification: 'Cafe Not Found!'});
                    this.$router.push({name: 'cafes'});
                }
            }
        },

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

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

            cafeLoadStatus() {
                return this.$store.getters.getCafeLoadStatus;
            },

            cafeLikeActionStatus() {
                return this.$store.getters.getCafeLikeActionStatus;
            },

            cafeUnlikeActionStatus() {
                return this.$store.getters.getCafeUnlikeActionStatus;
            },

            cafe() {
                return this.$store.getters.getCafe;
            },

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

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

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

            leaveCafe() {
                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;
                        }
                    }
                    this.$router.push({name: 'city', params: {slug: slug}});
                } else {
                    this.$router.push({name: 'cafes'});
                }
            }
        }
    }
</script>

对其引用的组件,需要对 resources/assets/js/components/cafes/ToggleLike.vue 进行调整:

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

    span.toggle-like {
        span.like-toggle {
            display: inline-block;
            cursor: pointer;
            color: #8E8E8E;
            font-size: 18px;
            margin-bottom: 5px;
            span.image-container {
                width: 35px;
                text-align: center;
                display: inline-block;
            }
        }
        span.like-count {
            font-family: "Lato", sans-serif;
            font-size: 12px;
            margin-left: 10px;
            color: #8E8E8E;
        }
    }
</style>

<template>
    <span class="toggle-like" v-show="userLoadStatus === 2 && user != ''">
    <span class="like like-toggle" v-on:click="likeCafe(cafe.id)"
          v-if="!liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
      <span class="image-container">
        <img src="/storage/img/unliked.svg"/>
      </span> 喜欢?
    </span>
    <span class="un-like like-toggle" v-on:click="unlikeCafe(cafe.id)"
          v-if="liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
      <span class="image-container">
        <img src="/storage/img/liked.svg"/>
      </span> 已喜欢
    </span>
    <loader v-show="cafeLikeActionStatus === 1 || cafeUnlikeActionStatus === 1 || cafeLoadStatus !== 2"
            :width="23"
            :height="23"
            :display="'inline-block'">
    </loader>
    <span class="like-count">
      {{ cafe.likes_count }} likes
    </span>
  </span>
</template>

<script>
    import Loader from '../../components/global/Loader.vue';

    export default {
        components: {
            Loader
        },
        computed: {
            userLoadStatus() {
                return this.$store.getters.getUserLoadStatus();
            },
            user() {
                return this.$store.getters.getUser;
            },
            cafeLoadStatus() {
                return this.$store.getters.getCafeLoadStatus;
            },

            cafe() {
                return this.$store.getters.getCafe;
            },

            liked() {
                return this.$store.getters.getCafeLikedStatus;
            },

            cafeLikeActionStatus() {
                return this.$store.getters.getCafeLikeActionStatus;
            },

            cafeUnlikeActionStatus() {
                return this.$store.getters.getCafeUnlikeActionStatus;
            }
        },
        methods: {
            likeCafe(cafeID) {
                this.$store.dispatch('likeCafe', {
                    id: this.cafe.id
                });
            },
            unlikeCafe(cafeID) {
                this.$store.dispatch('unlikeCafe', {
                    id: this.cafe.id
                });
            }
        }
    }
</script>

此外,还需要在 resources/assets/js/modules/cafes.js 中新增一个 Action:

clearLikeAndUnlikeStatus({commit}, data) {
   commit('setCafeLikeActionStatus', 0);
   commit('setCafeUnlikeActionStatus', 0);
},

还要对 loadCafe Action 中设置已经喜欢咖啡店状态的条件字段进行调整:

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_count > 0) {
               commit('setCafeLikedStatus', true);
           }
           commit('setCafeLoadStatus', 2);
       })
       .catch(function () {
           commit('setCafe', {});
           commit('setCafeLoadStatus', 3);
       });
},

这样,就完成了对咖啡店详情页前端页面的重构,运行 npm run dev 重新编译前端资源,在首页地图上点击咖啡店点标记,即可进入咖啡店详情页:

上面是未登录情况下的显示,登录后会显示出喜欢/取消喜欢组件和编辑咖啡店链接,点击编辑链接就可以对这个咖啡店进行编辑:

二、个人信息页重构

最后一个需要重构的页面就是个人信息编辑页了,这个页面本身很简单,只需要编辑 resources/assets/js/pages/Profile.vue 就可以了,重构后的代码如下:

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

    div#profile-page {
        position: fixed;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        background-color: white;
        z-index: 99999;
        overflow: auto;
        img#back {
            float: right;
            margin-top: 20px;
            margin-right: 20px;
        }
        div.centered {
            margin: auto;
        }
        h2.page-title {
            color: #342C0C;
            font-size: 36px;
            font-weight: 900;
            font-family: "Lato", sans-serif;
            margin-top: 60px;
        }
        label.form-label {
            font-family: "Lato", sans-serif;
            text-transform: uppercase;
            font-weight: bold;
            color: black;
            margin-top: 10px;
            margin-bottom: 10px;
        }
        a.update-profile-button {
            display: block;
            text-align: center;
            height: 50px;
            color: white;
            border-radius: 3px;
            font-size: 18px;
            font-family: "Lato", sans-serif;
            background-color: #A7BE4D;
            line-height: 50px;
            margin-bottom: 50px;
        }
    }
</style>

<template>
    <transition name="scale-in-center">
        <div id="profile-page">
            <router-link :to="{ name: 'cafes' }">
                <img src="/storage/img/close-modal.svg" id="back"/>
            </router-link>

            <div class="grid-container">
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <h2 class="page-title">个人信息</h2>
                    </div>
                </div>
            </div>

            <div class="grid-container">
                <div class="grid-x grid-padding-x">
                    <loader v-show="userLoadStatus === 1" :width="100" :height="100"></loader>
                </div>
            </div>

            <div class="grid-container" v-show="userLoadStatus === 2">
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <label class="form-label">最喜欢的咖啡</label>
                        <textarea v-model="favorite_coffee"></textarea>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <label class="form-label">口味记录</label>
                        <textarea v-model="flavor_notes"></textarea>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <label class="form-label">是否公开</label>
                        <select id="public-visibility" v-model="profile_visibility">
                            <option value="0">仅自己可见</option>
                            <option value="1">所有人可见</option>
                        </select>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <label class="form-label">所在城市</label>
                        <input type="text" v-model="city"/>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <label class="form-label">所在省份</label>
                        <select v-model="state">
                            <option value=""></option>
                            <option value="北京">北京</option>
                            <option value="上海">上海</option>
                            <option value="天津">天津</option>
                            <option value="重庆">重庆</option>
                            <option value="江苏">江苏</option>
                            <option value="浙江">浙江</option>
                            <option value="安徽">安徽</option>
                            <option value="广东">广东</option>
                            <option value="山东">山东</option>
                            <option value="四川">四川</option>
                            <option value="湖北">湖北</option>
                            <option value="湖南">湖南</option>
                            <option value="山西">山西</option>
                            <option value="陕西">陕西</option>
                            <option value="辽宁">辽宁</option>
                            <option value="吉林">吉林</option>
                            <option value="黑龙江">黑龙江</option>
                            <option value="内蒙古">内蒙古</option>
                            <option value="河南">河南</option>
                            <option value="河北">河北</option>
                            <option value="广西">广西</option>
                            <option value="贵州">贵州</option>
                            <option value="云南">云南</option>
                            <option value="西藏">西藏</option>
                            <option value="青海">青海</option>
                            <option value="新疆">新疆</option>
                            <option value="甘肃">甘肃</option>
                            <option value="宁夏">宁夏</option>
                            <option value="江西">江西</option>
                            <option value="海南">海南</option>
                            <option value="福建">福建</option>
                            <option value="台湾">台湾</option>
                        </select>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-10 small-12 cell centered">
                        <a class="update-profile-button" v-on:click="updateProfile()">更新</a>
                    </div>
                </div>
            </div>
        </div>
    </transition>
</template>

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

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

    export default {
        components: {
            Loader
        },
        data() {
            return {
                favorite_coffee: '',
                flavor_notes: '',
                profile_visibility: 0,
                city: '',
                state: ''
            }
        },
        watch: {
            'userLoadStatus': function () {
                if (this.userLoadStatus === 2) {
                    this.setFields();
                }
            },

            'userUpdateStatus': function () {
                if (this.userUpdateStatus === 2) {
                    EventBus.$emit('show-success', {
                        notification: '个人信息更新成功!'
                    });
                }
            }
        },
        created() {
            if (this.userLoadStatus === 2) {
                this.setFields();
            }
        },
        computed: {
            user() {
                return this.$store.getters.getUser;
            },

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

            userUpdateStatus() {
                return this.$store.getters.getUserUpdateStatus;
            }
        },
        methods: {
            setFields() {
                this.profile_visibility = this.user.profile_visibility;
                this.favorite_coffee = this.user.favorite_coffee;
                this.flavor_notes = this.user.flavor_notes;
                this.city = this.user.city;
                this.state = this.user.state;
            },
            updateProfile() {
                if (this.validateProfile()) {
                    this.$store.dispatch('editUser', {
                        profile_visibility: this.profile_visibility,
                        favorite_coffee: this.favorite_coffee,
                        flavor_notes: this.flavor_notes,
                        city: this.city,
                        state: this.state
                    });
                }
            },
            validateProfile() {
                return true;
            }
        }
    }
</script>

运行 npm run dev 重新编译前端资源,访问个人信息页 http://roast.test/#/profile,显示结果如下:

至此,我们已经对之前教程中实现的所有页面完成了重构,现在应用界面效果比之前更酷,整体功能和数据结构也更加清晰,但还有一些不足,那就是没有权限系统和后台管理系统,我们将在接下来用几篇教程的篇幅来为 Roast 应用补上这些短板。

注:项目源码位于 nonfu/roastapp

学院君 has written 1083 articles

Laravel学院院长,终身学习者

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

9 条回复

  1. Mr_White_DT Mr_White_DT says:
    @ 学院君

    我改成先拿$user = Auth::guard('api)->user(),然后发现未登录的用户也要调用啊,然后就判断用户是否登录,如果没登录,就不关联,直接给user_like_count = 0,反正没登录嘛,不存在喜不喜欢。你这个方法比我的好。

  2. Mr_White_DT Mr_White_DT says:

    学院君,喜欢会报错啊,在Cafe模型的userLike方法中auth()->id()拿到的是空。。。

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