基于 Laravel + Vue 构建 API 驱动的前后端分离应用系列(二十) —— 通过 Laravel + Vue 实现喜欢/取消喜欢咖啡店功能

对很多应用而言,尤其是社交媒体类应用,都有喜欢或收藏功能,在这篇教程中,我们就来演示如何基于 Laravel 和 Vue 从前端到后端完整实现喜欢/取消喜欢咖啡店功能。

上一篇教程中我们构建了咖啡店之间的父子关联关系,并且在更早的教程中构建了咖啡店与冲泡方法之间的多对多关联,而在这篇教程中,我们将构建基于用户和咖啡店的另一个多对多关联关系:一个咖啡店可以被多个用户喜欢,一个用户也可以喜欢多个咖啡店。

第一步:创建用户喜欢表

咖啡店和用户表都已经存在了,要构建两者之间的多对多关联,还需要创建一张中间表来映射两者之间的关联:

php artisan make:migration added_users_cafes_likes --create=users_cafes_likes

我们将这个中间表命名为 users_cafes_likes,该表包含两个字段 user_idcafe_id,分别对应用户 ID 和 咖啡店 ID,编辑新生成迁移类的 up 方法如下:

public function up()
{
    Schema::create('users_cafes_likes', function (Blueprint $table) {
        $table->integer('user_id')->unsigned();
        $table->integer('cafe_id')->unsigned();
        $table->primary(['user_id', 'cafe_id']);
        $table->timestamps();
    });
}

然后运行迁移命令生成数据表:

php artisan migrate

生成数据表如下:

第二步:构建模型间关联关系

app/Models/Cafe.php 中定义一个关联关系方法 likes()

// 与 User 间的多对对关联
public function likes()
{
    return $this->belongsToMany(User::class, 'users_cafes_likes', 'cafe_id', 'user_id');
}

app/Models/User.php 中定义一个与之相对的关联关系方法 likes()

// 与 Cafe 间的多对多关联
public function likes()
{
    return $this->belongsToMany(Cafe::class, 'users_cafes_likes', 'user_id', 'cafe_id');
}

第三步:添加喜欢/取消喜欢路由

接下来在 routes/api.php 中定义对咖啡店的喜欢/取消喜欢路由:

// 喜欢咖啡店
Route::post('/cafes/{id}/like', 'API\CafesController@postLikeCafe');
// 取消喜欢咖啡店
Route::delete('/cafes/{id}/like', 'API\CafesController@deleteLikeCafe');

第四步:后端实现喜欢/不喜欢功能

紧接着就来在控制器 app/Http/Controllers/API/CafesController.php 中实现相应的路由处理逻辑。首先定义一个 postLikeCafe 方法来实现喜欢功能,我们使用 attach() 方法来实现映射关系的绑定:

public function postLikeCafe($cafeID)
{
    $cafe = Cafe::where('id', '=', $cafeID)->first();
    $cafe->likes()->attach(Auth::user()->id, [
        'created_at' => Carbon::now(),
        'updated_at' => Carbon::now()
    ]);
    return response()->json(['cafe_liked' => true], 201);
}

然后定义一个 deleteLikeCafe 方法来实现取消喜欢功能,相对的,我们使用 detach() 方法来解除之前绑定的关联关系:

public function deleteLikeCafe($cafeID)
{
    $cafe = Cafe::where('id', '=', $cafeID)->first();

    $cafe->likes()->detach(Auth::user()->id);

    return response(null, 204);
}

至此,后端 API 功能已经完成,接下来实现前端功能。

第五步:添加路由到 cafe.js

首先打开 resources/assets/js/api/cafe.js 定义前端喜欢/取消喜欢路由:

/**
 * POST  /api/v1/cafes/{cafeID}/like
 */
postLikeCafe: function (cafeID) {
    return axios.post(ROAST_CONFIG.API_URL + '/cafes/' + cafeID + '/like');
},

/**
 * DELETE /api/v1/cafes/{cafeID}/like
 */
deleteLikeCafe: function (cafeID) {
    return axios.delete(ROAST_CONFIG.API_URL + '/cafes/' + cafeID + '/like');
}

第六步:更新 Vuex Cafe 模块

然后打开 resources/assets/js/modules/cafes.js,在这个 Vuex 模块的 state 中新增下面三个字段:

state: {
    ...

    cafeLikeActionStatus: 0,
    cafeUnlikeActionStatus: 0,

    cafeLiked: false
},

分别用于监听喜欢、取消喜欢动作的加载状态以及用户是否已经喜欢过这个咖啡店。接下来在 actions 中新增两个动作用于请求上面定义的前端路由:

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);
       });
}

mutations 中定义三个方法设置 state 中新增的三个状态:

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

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

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

getters 中定义三个方法获取 state 中新增的三个状态值:

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

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

getCafeUnlikeActionStatus( state ){
    return state.cafeUnlikeActionStatus;
}

这样,cafes 模块已经具备了支持喜欢咖啡店功能具备的所有动作和状态。

第七步:更新 Cafe.vue 页面

喜欢/取消喜欢功能是在咖啡店详情页实现的,但是到目前为止咖啡店详情页 Cafe.vue 还是空的,接下来我们需要来实现咖啡店详情页的渲染。我们需要在页面创建的时候通过 Vue Router 基于路由中的参数 ID 来获取单个咖啡店数据,在组件中通过 this.$route.params.id 即可获取参数 ID:

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

然后我们创建一个新的组件 resources/assets/js/components/cafes/IndividualCafeMap.vue 用于在咖啡店详情页将咖啡店标记到地图上:

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

    div#individual-cafe-map {
        width: 700px;
        height: 500px;
        margin: auto;
        margin-bottom: 200px;
    }
</style>

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

    </div>
</template>

<script>
    import {ROAST_CONFIG} from '../../config.js';

    export default {
        computed: {
            cafeLoadStatus() {
                return this.$store.getters.getCafeLoadStatus;
            },

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

        watch: {
            cafeLoadStatus() {
                if (this.cafeLoadStatus === 2) {
                    this.displayIndividualCafeMap();
                }
            }
        },

        methods: {

            displayIndividualCafeMap() {

                this.map = new AMap.Map('individual-cafe-map', {
                    center: [parseFloat(this.cafe.latitude), parseFloat(this.cafe.longitude)],
                    zoom: 13
                });

                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 marker = new AMap.Marker({
                    position: new AMap.LngLat(parseFloat(this.cafe.latitude), parseFloat(this.cafe.longitude)),
                    icon: icon
                });

                this.map.add(marker);
            }
        }
    }
</script>

然后在 resources/assets/js/pages/Cafe.vue 页面中引入这个组件,并且编写页面代码如下:

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

    div.cafe-page {
        h2 {
            text-align: center;
            color: $primary-color;
            font-family: 'Josefin Sans', sans-serif;
        }

        h3 {
            text-align: center;
            color: $secondary-color;
            font-family: 'Josefin Sans', sans-serif;
        }

        span.address {
            text-align: center;
            display: block;
            font-family: 'Lato', sans-serif;
            color: #A0A0A0;
            font-size: 20px;
            line-height: 30px;
            margin-top: 50px;
        }

        a.website {
            text-align: center;
            color: $dull-color;
            font-size: 30px;
            font-weight: bold;
            margin-top: 50px;
            display: block;
            font-family: 'Josefin Sans', sans-serif;
        }

        div.brew-methods-container {
            max-width: 700px;
            margin: auto;

            div.cell {
                text-align: center;
            }
        }
    }
</style>

<template>
    <div id="cafe" class="page">

        <div class="grid-container">
            <div class="grid-x grid-padding-x">

                <div class="large-12 medium-12 small-12 cell">
                    <loader v-show="cafeLoadStatus === 1" :width="100" :height="100">
                    </loader>

                    <div class="cafe-page" v-show="cafeLoadStatus === 2">
                        <h2>{{ cafe.name }}</h2>
                        <h3 v-if="cafe.location_name !== ''">{{ cafe.location_name }}</h3>

                        <div class="grid-x">
                            <div class="large-12 medium-12 small-12 cell">
                                <toggle-like></toggle-like>
                            </div>
                        </div>

                        <span class="address">
                            {{ cafe.address }}<br>
                            {{ cafe.city }}, {{ cafe.state }}<br>
                            {{ cafe.zip }}
                        </span>

                        <a class="website" v-bind:href="cafe.website" target="_blank">{{ cafe.website }}</a>

                        <div class="brew-methods-container">
                            <div class="grid-x grid-padding-x">
                                <div class="large-3 medium-4 small-12 cell" v-for="brewMethod in cafe.brew_methods">
                                    {{ brewMethod.method }}
                                </div>
                            </div>
                        </div>

                        <br>

                        <individual-cafe-map></individual-cafe-map>
                    </div>
                </div>

            </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';

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

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

        // 定义计算属性
        computed: {
            cafeLoadStatus() {
                return this.$store.getters.getCafeLoadStatus;
            },

            cafe() {
                return this.$store.getters.getCafe;
            }
        }
    }
</script>

完成页面渲染后就可以编写喜欢/取消喜欢功能的实现代码了。

第八步:添加喜欢/取消喜欢咖啡店组件

这个组件是一个简单的元素,用于插入到独立咖店页面以喜欢/取消喜欢某个咖啡店,我们会基于用户喜欢/不喜欢咖啡店状态值来切换显示喜欢/取消喜欢按钮,此外,为了提升用户体验,我们还插入了一个加载器组件用来显示服务器端对喜欢/取消喜欢操作的响应进度。为此,我们在加载器组件中定义了一个 display 属性用来标识组件是块级组件还是内联组件,默认是 block,完整的 resources/assets/js/components/global/Loader.vue 代码如下:

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

    div.loader {
        margin: auto;
        vertical-align: middle;
    }

    svg path,
    svg rect {
        fill: $secondary-color;
    }
</style>

<template>
    <div class="loader loader--style3" v-bind:style="'width: '+width+'px; height: '+height+'px; display: '+display+''"
         title="2">
        <svg version="1.1" id="loader-1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
            v-bind:width="width+'px'" v-bind:height="height+'px'" viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
            <path fill="#000" d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z">
                <animateTransform attributeType="xml"
                                  attributeName="transform"
                                  type="rotate"
                                  from="0 25 25"
                                  to="360 25 25"
                                  dur="0.6s"
                                  repeatCount="indefinite"/>
            </path>
        </svg>
    </div>
</template>

<script>
    export default {
        props: {
            'width': Number,
            'height': Number,
            'display': {
                default: 'block'
            }
        }
    }
</script>

现在在我们的切换喜欢组件中,当我们点击喜欢按钮时,会发送喜欢咖啡店请求,服务器处理请求时,会显示上述加载器,请求完成后,悔显示取消喜欢按钮。

创建切换喜欢组件 resources/assets/js/components/cafes/ToggleLike.vue,初始化代码如下:

<style>

</style>
<template>
  <span class="toggle-like">

  </span>
</template>
<script>
    export default {

    }
</script>

现在我们来实现组件中的喜欢部分,需要添加一个喜欢按钮,并将其插入到模板代码中:

<template>
    <span class="toggle-like">
        <span class="like" v-on:click="likeCafe( cafe.id )"
              v-if="!liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
            喜欢
        </span>
    </span>
</template>

接下来从 Vuex 中获取用于实现喜欢功能的一应状态值,如咖啡店加载状态、咖啡店数据、是否已经标记为喜欢、喜欢请求状态、取消喜欢请求状态等,我们用计算属性来提供:

computed: {
   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 方法中添加 1ikeCafe 来实现喜欢咖啡店功能:

methods: {
   likeCafe(cafeID) {
       this.$store.dispatch('likeCafe', {
           id: this.cafe.id
       });
   },
}

代码非常简单,分发 Vuex 中的 likeCafe 动作,并传递对应咖啡店 ID 即可。

接下来,我们来实现取消喜欢功能,首先添加不喜欢按钮:

<span class="un-like" v-on:click="unlikeCafe( cafe.id )"
          v-if="liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
    取消喜欢
</span>

和喜欢按钮非常类似,只不过逻辑恰好相反,只有当咖啡店被喜欢时才会展示该按钮。

然后我们在 methods 方法中定义 unlikeCafe 方法:

unlikeCafe(cafeID) {
    this.$store.dispatch('unlikeCafe', {
        id: this.cafe.id
    });
}

点击不喜欢按钮时,就会分发 unlikeCafe 动作来发送取消喜欢请求。

最后,我们以如下方式将加载器组件引入切换喜欢组件:

<loader v-show="cafeLikeActionStatus === 1 || cafeUnlikeActionStatus === 1"
            :width="30"
            :height="30"
            :display="'inline-block'">
</loader>

这将以 inline-block 元素形式显示加载器并且只在喜欢/取消喜欢咖啡店动作发生时显示。

以下是 resources/assets/js/components/cafes/ToggleLike.vue 的完整代码,这也是我们第一个基于元素的用户交互功能:

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

    span.toggle-like {
        display: block;
        text-align: center;
        margin-top: 30px;

        span.like-toggle {
            display: inline-block;
            font-weight: bold;
            text-decoration: underline;
            font-size: 20px;
            cursor: pointer;

            &.like {
                color: $dark-success;
            }

            &.un-like {
                color: $dark-failure;
            }
        }
    }
</style>
<template>
    <span class="toggle-like">
        <span class="like" v-on:click="likeCafe( cafe.id )"
              v-if="!liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
            喜欢
        </span>
        <span class="un-like" v-on:click="unlikeCafe( cafe.id )"
              v-if="liked && cafeLoadStatus === 2 && cafeLikeActionStatus !== 1 && cafeUnlikeActionStatus !== 1">
            取消喜欢
        </span>
        <loader v-show="cafeLikeActionStatus === 1 || cafeUnlikeActionStatus === 1"
                :width="30"
                :height="30"
                :display="'inline-block'">
        </loader>
    </span>
</template>
<script>
    export default {
        computed: {
            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>

第九步:确保载入咖啡店时加载是否喜欢状态

这是我们喜欢/取消喜欢咖啡店的最后一步操作,在这一步中,需要确保加载咖啡店详情页时,用户是否喜欢该咖啡店的状态值被加载。首先,需要打开 app/Models/Cafe.php 文件,并添加如下关联关系方法:

public function userLike()
{
    return $this->belongsToMany(User::class, 'users_cafes_likes', 'cafe_id', 'user_id')->where('user_id', auth()->id());
}

该关联方法用于标识登录用户是否已经喜欢/取消喜欢指定咖啡店,以便我们可以正确初始化状态。

接下来,打开 app/Http/Controllers/API/CafesController.php 并调整 getCafe($id) 方法来加载 userLike 关联关系:

public function getCafe($id)
{
    $cafe = Cafe::where('id', '=', $id)
        ->with('brewMethods')
        ->with('userLike')
        ->first();

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

这样,我们返回给咖啡店详情页的数据中就包含了用户是否喜欢咖啡店。

最后,打开 resources/assets/js/modules/cafes.js 模块并找到 loadCafe() 方法,在这个方法中,我们会从后端 API 获取咖啡店记录并设置相应数据,此外,我们还会设置咖啡店是否被用户喜欢,以便在 ToggleLike.vue 中读取并处理:

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);
       });
},

至此,我们已经完成了前后端所有喜欢/取消喜欢咖啡店的功能编码,重新运行 npm run dev 编译前端资源,访问某个咖啡店详情页,如 http://roast.test/#/cafes/1,就可以看到「喜欢」按钮:

点击「喜欢」按钮,后端处理成功后,页面就会切换为显示「取消喜欢」按钮:

数据表 users_cafes_likes 也成功插入了关联记录:

表示用户喜欢咖啡店成功。

学院君 has written 980 articles

Laravel学院院长,终身学习者

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

7 条回复

  1. Mr_White_DT Mr_White_DT says:
    @ 学院君

    我闭合标签之后编译成功但是程序运行报错了,报的是loader组件没有注册,然后我去研究了下你的代码逻辑,你注册loader组件应该是要在ToggleLike.vue中使用(不知道我理解 的对不对。。。),但是在ToggleLike.vue中没有引入loader组件,然后就报loader没注册,我引入之后就没有问题了

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