基于 Laravel + Vue 构建 API 驱动的前后端分离应用系列(三十三) —— 功能模块重构 & CSS 整体优化:实现编辑/删除咖啡店功能

在这篇教程中,我们将实现咖啡店的编辑和删除功能,在实现过新增咖啡店功能后,咖啡店的编辑功能实现起来非常简单,无论是前台表单还是后台逻辑,思路都是一样的,无非是最后一个在数据库中新增,一个更新而已,此外,编辑咖啡店时需要先获取待编辑数据渲染到表单中。下面我们就来一步步实现编辑和删除功能。

第一步:更新模型类

由于我们要实现删除功能,并且实现的是软删除,之前已经在数据表迁移类中通过 $table->softDeletes();cafes 表添加了 deleted_at 字段,所以接下来还要在 app/Models/Cafe.php 模型类中通过如下方式使其支持软删除:

class Cafe extends Model
{
    use SoftDeletes;

    ...

第二步:新增编辑/删除路由:

routes/api.php 的私有路由分组中,新增以下三个路由:

/*
|-------------------------------------------------------------------------------
| 获取待编辑咖啡店数据
|-------------------------------------------------------------------------------
| URL:            /api/v1/cafes/{slug}/edit
| Controller:     API\CafesController@getCafeEditData
| Method:         GET
| Description:    获取待编辑咖啡店数据
*/
Route::get('/cafes/{id}/edit', 'API\CafesController@getCafeEditData');

/*
|-------------------------------------------------------------------------------
| 执行更新咖啡店请求
|-------------------------------------------------------------------------------
| URL:            /api/v1/cafes/{slug}
| Controller:     API\CafesController@putEditCafe
| Method:         PUT
| Description:    执行更新咖啡店请求
*/
Route::put('/cafes/{id}', 'API\CafesController@putEditCafe');

/*
|-------------------------------------------------------------------------------
| 删除指定咖啡店
|-------------------------------------------------------------------------------
| URL:            /api/v1/cafes/{slug}
| Controller:     API\CafesController@deleteCafe
| Method:         DELETE
| Description:    删除指定咖啡店
*/
Route::delete('/cafes/{id}', 'API\CafesController@deleteCafe');

第三步:初始化控制器方法

接下来我们需要在控制器 app/Http/Controllers/API/CafesController.php 中编写路由中指定的三个方法:

// 获取咖啡店编辑表单数据
public function getCafeEditData($id)
{
    $cafe = Cafe::where('id', '=', $id)
        ->with('brewMethods')
        ->withCount('userLike')
        ->with(['company' => function ($query) {
            $query->withCount('cafes');
        }])
        ->first();
    return response()->json($cafe);
}

// 更新咖啡店数据
public function putEditCafe($id, Request $request)
{

}

// 删除咖啡店
public function deleteCafe($id)
{
    $cafe = Cafe::where('id', '=', $id)->first();
    $cafe->delete();
    return response()->json(['message' => '删除成功'], 204);
}

getCafeEditDatadeleteCafe 比较简单,直接实现了,putEditCafe 方法留到前端页面实现之后再实现。

第四步:更新前端路由文件

接下来,我们在前端路由文件 resources/assets/js/routes.js 中新增编辑咖啡店页面路由:

{
    path: 'cafes/:id/edit',
    name: 'editcafe',
    component: Vue.component('EditCafe', require('./pages/EditCafe.vue')),
    beforeEnter: requireAuth
},

因为编辑咖啡店需要用户登录后才能访问,所以我们为这个路由添加了导航守卫。

resources/assets/js/api/cafe.js 中新增三个后端 API 调用:

/**
 * GET    /api/v1/cafes/{id}/edit
 */
getCafeEdit: function (id) {
    return axios.get(ROAST_CONFIG.API_URL + '/cafes/' + id + '/edit');
},

/**
 * PUT    /api/v1/cafes/{slug}
 */
putEditCafe: function (id, companyName, companyID, companyType, subscription, website, locationName, address, city, state, zip, brewMethods, matcha, tea) {

    let formData = new FormData();

    formData.append('company_name', companyName);
    formData.append('company_id', companyID);
    formData.append('company_type', companyType);
    formData.append('subscription', subscription);
    formData.append('website', website);
    formData.append('location_name', locationName);
    formData.append('address', address);
    formData.append('city', city);
    formData.append('state', state);
    formData.append('zip', zip);
    formData.append('brew_methods', JSON.stringify(brewMethods));
    formData.append('matcha', matcha);
    formData.append('tea', tea);
    formData.append('_method', 'PUT');

    return axios.post(ROAST_CONFIG.API_URL + '/cafes/' + id,
        formData
    );
},

deleteCafe: function (id) {
    return axios.delete(ROAST_CONFIG.API_URL + '/cafes/' + id);
}

此外,还要在 resources/assets/js/modules/cafes.js 中新增调用相应 API 的 actions

loadCafeEdit({commit}, data) {
   commit('setCafeEditLoadStatus', 1);

   CafeAPI.getCafeEdit(data.id)
       .then(function (response) {
           commit('setCafeEdit', response.data);
           commit('setCafeEditLoadStatus', 2);
       })
       .catch(function () {
           commit('setCafeEdit', {});
           commit('setCafeEditLoadStatus', 3);
       });
},

editCafe({commit, state, dispatch}, data) {
   commit('setCafeEditStatus', 1);

   CafeAPI.putEditCafe(data.id, data.company_name, data.company_id, data.company_type, data.subscription, data.website, data.location_name, data.address, data.city, data.state, data.zip, data.brew_methods, data.matcha, data.tea)
       .then(function (response) {
           if (typeof response.data.cafe_updates_pending !== 'undefined') {
               commit('setCafeEditText', response.data.cafe_updates_pending + ' 正在编辑中!');
           } else {
               commit('setCafeEditText', response.data.name + ' 已经编辑成功!');
           }

           commit('setCafeEditStatus', 2);

           dispatch('loadCafes');
       })
       .catch(function (error) {
           commit('setCafeEditStatus', 3);
       });
 },

deleteCafe({commit, state, dispatch}, data) {
   commit('setCafeDeleteStatus', 1);

   CafeAPI.deleteCafe(data.id)
       .then(function (response) {

           if (typeof response.data.cafe_delete_pending !== 'undefined') {
               commit('setCafeDeletedText', response.data.cafe_delete_pending + ' 正在删除中!');
           } else {
               commit('setCafeDeletedText', '咖啡店删除成功!');
           }

           commit('setCafeDeleteStatus', 2);

           dispatch('loadCafes');
       })
       .catch(function () {
           commit('setCafeDeleteStatus', 3);
       });
},

以及对应的 state

cafeEdit: {},
cafeEditLoadStatus: 0,
cafeEditStatus: 0,
cafeEditText: '',

cafeDeletedStatus: 0,
cafeDeleteText: '', 

还有这些新增的 state 对应的 mutationsgetters

// mutations
setCafeEdit(state, cafe) {
   state.cafeEdit = cafe;
},

setCafeEditStatus(state, status) {
   state.cafeEditStatus = status;
},

setCafeEditText(state, text) {
   state.cafeEditText = text;
},

setCafeEditLoadStatus(state, status) {
   state.cafeEditLoadStatus = status;
},

setCafeDeleteStatus(state, status) {
   state.cafeDeletedStatus = status;
},

setCafeDeletedText(state, text) {
   state.cafeDeleteText = text;
}

// getters
getCafeEdit(state) {
   return state.cafeEdit;
},

getCafeEditStatus(state) {
   return state.cafeEditStatus;
},

getCafeEditText(state) {
   return state.cafeEditText;
},

getCafeEditLoadStatus(state) {
   return state.cafeEditLoadStatus;
},

getCafeDeletedStatus(state) {
   return state.cafeDeletedStatus;
},

getCafeDeletedText(state) {
   return state.cafeDeleteText;
}

第五步:实现编辑咖啡店页面组件

resources/assets/js/pages 目录下创建 EditCafe.vue,编写组件代码如下:

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

    div#new-cafe-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;
        }
        .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;
        }
        input[type="text"].form-input {
            border: 1px solid #BABABA;
            border-radius: 3px;
            &.invalid {
                border: 1px solid #D0021B;
            }
        }
        div.validation {
            color: #D0021B;
            font-family: "Lato", sans-serif;
            font-size: 14px;
            margin-top: -15px;
            margin-bottom: 15px;
        }
        div.location-type {
            text-align: center;
            font-family: "Lato", sans-serif;
            font-size: 16px;
            width: 25%;
            display: inline-block;
            height: 55px;
            line-height: 55px;
            cursor: pointer;
            margin-bottom: 5px;
            margin-right: 10px;
            background-color: #EEE;
            color: $black;
            &.active {
                color: white;
                background-color: $secondary-color;
            }
            &.roaster {
                border-top-left-radius: 3px;
                border-bottom-left-radius: 3px;
                border-right: 0px;
            }
            &.cafe {
                border-top-right-radius: 3px;
                border-bottom-right-radius: 3px;
            }
        }
        div.company-selection-container {
            position: relative;
            div.company-autocomplete-container {
                border-radius: 3px;
                border: 1px solid #BABABA;
                background-color: white;
                margin-top: -17px;
                width: 80%;
                position: absolute;
                z-index: 9999;
                div.company-autocomplete {
                    cursor: pointer;
                    padding-left: 12px;
                    padding-right: 12px;
                    padding-top: 8px;
                    padding-bottom: 8px;
                    span.company-name {
                        display: block;
                        color: #0D223F;
                        font-size: 16px;
                        font-family: "Lato", sans-serif;
                        font-weight: bold;
                    }
                    span.company-locations {
                        display: block;
                        font-size: 14px;
                        color: #676767;
                        font-family: "Lato", sans-serif;
                    }
                    &:hover {
                        background-color: #F2F2F2;
                    }
                }
                div.new-company {
                    cursor: pointer;
                    padding-left: 12px;
                    padding-right: 12px;
                    padding-top: 8px;
                    padding-bottom: 8px;
                    font-family: "Lato", sans-serif;
                    color: #054E7A;
                    font-style: italic;
                    &:hover {
                        background-color: #F2F2F2;
                    }
                }
            }
        }
        a.edit-location-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: 10px;
        }
        a.delete-location {
            color: #D0021B;
            font-size: 14px;
            text-decoration: underline;
            display: inline-block;
            margin-bottom: 50px;
        }
    }

    /* Small only */
    @media screen and (max-width: 39.9375em) {
        div#new-cafe-page {
            div.location-type {
                width: 50%;
            }
        }
    }
</style>

<template>
    <transition name="scale-in-center">
        <div id="new-cafe-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 class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered company-selection-container">
                        <label class="form-label">公司名称</label>
                        <input type="text" class="form-input" v-model="companyName" v-on:keyup="searchCompanies()"
                               v-bind:class="{'invalid' : !validations.companyName.is_valid }"/>
                        <div class="validation" v-show="!validations.companyName.is_valid">{{
                            validations.companyName.text }}
                        </div>
                        <input type="hidden" v-model="companyID"/>
                        <div class="company-autocomplete-container" v-show="companyName.length > 0 && showAutocomplete">
                            <div class="company-autocomplete" v-for="companyResult in companyResults"
                                 v-on:click="selectCompany( companyResult )">
                                <span class="company-name">{{ companyResult.name }}</span>
                                <span class="company-locations">{{ companyResult.cafes_count }} location<span
                                        v-if="companyResult.cafes_count > 1">s</span></span>
                            </div>
                            <div class="new-company" v-on:click="addNewCompany()">
                                Add new company called "{{ companyName }}"
                            </div>
                        </div>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">网站</label>
                        <input type="text" class="form-input" v-model="website"
                               v-bind="{ 'invalid' : !validations.website.is_valid }"/>
                        <div class="validation" v-show="!validations.website.is_valid">{{ validations.website.text }}
                        </div>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">类型</label>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <div class="location-type roaster" v-bind:class="{ 'active': companyType === 'roaster' }"
                             v-on:click="setCompanyType('roaster')">
                            烘焙店
                        </div>
                        <div class="location-type cafe" v-bind:class="{ 'active': companyType === 'cafe' }"
                             v-on:click="setCompanyType('cafe')">
                            咖啡店
                        </div>
                    </div>
                </div>

                <div class="grid-x grid-padding-x" v-show="companyType === 'roaster'">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">是否提供订购服务?</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x" v-show="companyType === 'roaster'">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <div class="subscription-option option"
                             v-on:click="subscription === 0 ? subscription = 1 : subscription = 0"
                             v-bind:class="{'active': subscription === 1}">
                            <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 grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">支持的冲泡方法</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <div class="brew-method option" v-on:click="toggleSelectedBrewMethod(method.id)"
                             v-for="method in brewMethods"
                             v-bind:class="{'active': brewMethodsSelected.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 class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">支持的饮料选项</label>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <div class="drink-option option" v-on:click="matcha === 0 ? matcha = 1 : matcha = 0"
                             v-bind:class="{'active': 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-on:click="tea === 0 ? tea = 1 : tea = 0"
                             v-bind:class="{'active': 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 grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">位置名称</label>
                        <input type="text" class="form-input" v-model="locationName"/>
                    </div>
                </div>

                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">街道地址</label>
                        <input type="text" v-model="address" placeholder="街道地址"
                               class="form-input" v-bind:class="{'invalid' : !validations.address.is_valid }"/>
                        <div class="validation" v-show="!validations.address.is_valid">{{ validations.address.text }}
                        </div>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <label class="form-label">城市</label>
                        <input type="text" class="form-input" v-model="city"
                               v-bind:class="{'invalid' : !validations.city.is_valid }"/>
                        <div class="validation" v-show="!validations.city.is_valid">{{ validations.city.text }}</div>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <div class="grid-x grid-padding-x">
                            <div class="large-6 medium-6 small-12 cell">
                                <label class="form-label">省份</label>
                                <select v-model="state" v-bind:class="{'invalid' : !validations.state.is_valid }">
                                    <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 class="validation" v-show="!validations.state.is_valid">{{ validations.state.text
                                    }}
                                </div>
                            </div>
                            <div class="large-6 medium-6 small-12 cell">
                                <label class="form-label">邮编</label>
                                <input type="text" class="form-input" v-model="zip"
                                       v-bind:class="{'invalid' : !validations.zip.is_valid }"/>
                                <div class="validation" v-show="!validations.zip.is_valid">{{ validations.zip.text }}
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <a class="edit-location-button" v-on:click="submitEditCafe()">提交更改</a>
                    </div>
                </div>
                <div class="grid-x grid-padding-x">
                    <div class="large-8 medium-9 small-12 cell centered">
                        <a class="delete-location" v-on:click="deleteCafe()">删除这个咖啡店</a>
                    </div>
                </div>
            </div>
        </div>
    </transition>
</template>

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

    import _ from 'lodash';

    import {ROAST_CONFIG} from '../config.js';

    export default {
        data() {
            return {
                companyResults: [],
                showAutocomplete: true,
                companyName: '',
                companyID: '',
                newCompany: false,
                companyType: 'roaster',
                subscription: 0,
                website: '',
                locationName: '',
                address: '',
                city: '',
                state: '',
                zip: '',
                brewMethodsSelected: [],
                matcha: 0,
                tea: 0,
                validations: {
                    companyName: {
                        is_valid: true,
                        text: ''
                    },
                    website: {
                        is_valid: true,
                        text: ''
                    },
                    address: {
                        is_valid: true,
                        text: ''
                    },
                    city: {
                        is_valid: true,
                        text: ''
                    },
                    state: {
                        is_valid: true,
                        text: ''
                    },
                    zip: {
                        is_valid: true,
                        text: ''
                    }
                }
            }
        },
        created() {
            this.$store.dispatch('loadCafeEdit', {
                id: this.$route.params.id
            });
        },

        computed: {
            brewMethods() {
                return this.$store.getters.getBrewMethods;
            },
            editCafeStatus() {
                return this.$store.getters.getCafeEditStatus;
            },
            editCafeLoadStatus() {
                return this.$store.getters.getCafeEditLoadStatus;
            },
            editCafe() {
                return this.$store.getters.getCafeEdit;
            },
            editCafeText() {
                return this.$store.getters.getCafeEditText;
            },
            deleteCafeStatus() {
                return this.$store.getters.getCafeDeletedStatus;
            },
            deleteCafeText() {
                return this.$store.getters.getCafeDeletedText;
            }
        },

        watch: {
            'editCafeStatus': function () {
                if (this.editCafeStatus === 2) {
                    EventBus.$emit('show-success', {
                        notification: this.editCafeText
                    });
                    this.$router.push({name: 'cafe', params: {id: this.$route.params.id}});
                }
            },
            'editCafeLoadStatus': function () {
                if (this.editCafeLoadStatus === 2) {
                    this.populateForm();
                }
            },
            'deleteCafeStatus': function () {
                if (this.deleteCafeStatus === 2) {
                    this.$router.push({name: 'cafes'});
                    EventBus.$emit('show-success', {
                        notification: this.deleteCafeText
                    });
                }
            }
        },

        methods: {

            setCompanyType(type) {
                this.companyType = type;
            },

            toggleSelectedBrewMethod(id) {
                if (this.brewMethodsSelected.indexOf(id) >= 0) {
                    this.brewMethodsSelected.splice(this.brewMethodsSelected.indexOf(id), 1);
                } else {
                    this.brewMethodsSelected.push(id);
                }
            },

            searchCompanies: _.debounce(function (e) {
                if (this.companyName.length > 1) {
                    this.showAutocomplete = true;
                    axios.get(ROAST_CONFIG.API_URL + '/companies/search', {
                        params: {
                            search: this.companyName
                        }
                    }).then(function (response) {
                        this.companyResults = response.data.companies;
                    }.bind(this));
                }
            }, 300),
            // 渲染表单
            populateForm() {
                this.companyName = this.editCafe.company.name;
                this.companyID = this.editCafe.company.id;
                this.newCompany = false;
                this.companyType = this.editCafe.company.roaster == 1 ? 'roaster' : 'cafe';
                this.subscription = this.editCafe.company.subscription;
                this.website = this.editCafe.company.website;
                this.locationName = this.editCafe.location_name;
                this.address = this.editCafe.address;
                this.city = this.editCafe.city;
                this.state = this.editCafe.state;
                this.zip = this.editCafe.zip;
                this.matcha = this.editCafe.matcha;
                this.tea = this.editCafe.tea;
                for (let i = 0; i < this.editCafe.brew_methods.length; i++) {
                    this.brewMethodsSelected.push(this.editCafe.brew_methods[i].id);
                }
                this.showAutocomplete = false;
            },
            // 提交更改
            submitEditCafe() {
                if (this.validateEditCafe()) {
                    this.$store.dispatch('editCafe', {
                        id: this.editCafe.id,
                        company_name: this.companyName,
                        company_id: this.companyID,
                        company_type: this.companyType,
                        subscription: this.subscription,
                        website: this.website,
                        location_name: this.locationName,
                        address: this.address,
                        city: this.city,
                        state: this.state,
                        zip: this.zip,
                        brew_methods: this.brewMethodsSelected,
                        matcha: this.matcha,
                        tea: this.tea
                    });
                }
            },

            addNewCompany() {
                this.showAutocomplete = false;
                this.newCompany = true;
                this.companyResults = [];
            },

            selectCompany(company) {
                this.showAutocomplete = false;
                this.companyName = company.name;
                this.companyID = company.id;
                this.newCompany = false;
                this.companyResults = [];
            },

            validateEditCafe() {
                let validNewCafeForm = true;

                if (this.companyName.trim() === '') {
                    validNewCafeForm = false;
                    this.validations.companyName.is_valid = false;
                    this.validations.companyName.text = '请输入公司名称';
                } else {
                    this.validations.companyName.is_valid = true;
                    this.validations.companyName.text = '';
                }

                if (this.website.trim !== '' && !this.website.match(/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/)) {
                    validNewCafeForm = false;
                    this.validations.website.is_valid = false;
                    this.validations.website.text = '请输入有效的网址信息';
                } else {
                    this.validations.website.is_valid = true;
                    this.validations.website.text = '';
                }

                if (this.address.trim() === '') {
                    validNewCafeForm = false;
                    this.validations.address.is_valid = false;
                    this.validations.address.text = '请输入咖啡店地址';
                } else {
                    this.validations.address.is_valid = true;
                    this.validations.address.text = '';
                }

                if (this.city.trim() === '') {
                    validNewCafeForm = false;
                    this.validations.city.is_valid = false;
                    this.validations.city.text = '请输入咖啡店所在城市';
                } else {
                    this.validations.city.is_valid = true;
                    this.validations.city.text = '';
                }

                if (this.state.trim() === '') {
                    validNewCafeForm = false;
                    this.validations.state.is_valid = false;
                    this.validations.state.text = '请输入咖啡店所在省份/直辖市';
                } else {
                    this.validations.state.is_valid = true;
                    this.validations.state.text = '';
                }

                if (this.zip.trim() === '' || !this.zip.match(/(^\d{6}$)/)) {
                    validNewCafeForm = false;
                    this.validations.zip.is_valid = false;
                    this.validations.zip.text = '请输入咖啡店所在地区邮政编码';
                } else {
                    this.validations.zip.is_valid = true;
                    this.validations.zip.text = '';
                }

                return validNewCafeForm;
            },

            deleteCafe() {
                if (confirm('确定要删除这个咖啡店吗?')) {
                    this.$store.dispatch('deleteCafe', {
                        id: this.editCafe.id
                    });
                }
            },

            clearForm() {
                this.companyResults = [];
                this.companyName = '';
                this.companyID = '';
                this.newCompany = false;
                this.companyType = 'roaster';
                this.subscription = 0;
                this.website = '';
                this.locationName = '';
                this.address = '';
                this.city = '';
                this.state = '';
                this.zip = '';
                this.brewMethodsSelected = [];
                this.matcha = 0;
                this.tea = 0;
                this.validations = {
                    companyName: {
                        is_valid: true,
                        text: ''
                    },
                    website: {
                        is_valid: true,
                        text: ''
                    },
                    address: {
                        is_valid: true,
                        text: ''
                    },
                    city: {
                        is_valid: true,
                        text: ''
                    },
                    state: {
                        is_valid: true,
                        text: ''
                    },
                    zip: {
                        is_valid: true,
                        text: ''
                    }
                };
            }
        }
    }
</script>

具体的实现逻辑和新增咖啡店一致,不再赘述细节,需要注意的一点是我们将删除咖啡店的逻辑也放到这个页面里面,在「提交更改」按钮下面有一个「点击删除咖啡店」链接,点击该链接会触发 Vuex 中的删除 Action 执行删除咖啡店请求。

下面我们到后端编写更新咖啡店业务逻辑代码。

第六步:实现后端更新咖啡店逻辑

首先,要创建一个新的表单请求验证类:

php artisan make:request EditCafeRequest

编写新生成的 app/Http/Requests/EditCafeRequest.php 代码如下:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class EditCafeRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'company_name' => 'required_without:company_id',
            'address' => 'required',
            'city' => 'required',
            'state' => 'required',
            'zip' => 'required',
            'website' => 'sometimes|url'
        ];
    }

    /**
     * Get the error messages for the defined validation rules.
     *
     * @return array
     */
    public function messages()
    {
        return [
            'company_name.required_without' => '咖啡店所属公司名称不能为空',
            'address' => ['required' => '街道地址不能为空'],
            'city' => ['required' => '城市字段不能为空'],
            'state' => ['required' => '省份字段不能为空'],
            'zip' => [
                'required' => '邮编字段不能为空'
            ],
            'website.url' => '请输入有效的网址信息'
        ];
    }
}

然后和新增咖啡店一样,我们先在 app/Services/CafeService.php 中实现编辑咖啡店方法:

/**
 * 更新咖啡店数据
 * @param $id
 * @param $data
 * @param $updatedBy
 * @return mixed
 */
public function editCafe($id, $data, $updatedBy)
{
    // 如果选择已有的公司,则更新公司信息,否则新增
    if (isset($data['company_id'])) {
        $company = Company::where('id', '=', $data['company_id'])->first();

        if (isset($data['company_name'])) {
            $company->name = $data['company_name'];
        }

        if (isset($data['company_type'])) {
            $company->roaster = $data['company_type'] == 'roaster' ? 1 : 0;
        }

        if (isset($data['subscription'])) {
            $company->subscription = $data['subscription'];
        }

        if (isset($data['website'])) {
            $company->website = $data['website'];
        }

        $company->logo = '';
        $company->description = '';

        $company->save();
    } else {
        $company = new Company();

        if (isset($data['company_name'])) {
            $company->name = $data['company_name'];
        }

        if (isset($data['company_type'])) {
            $company->roaster = $data['company_type'] == 'roaster' ? 1 : 0;
        } else {
            $company->roaster = 0;
        }

        if (isset($data['subscription'])) {
            $company->subscription = $data['subscription'];
        }

        if (isset($data['website'])) {
            $company->website = $data['website'];
        }

        $company->logo = '';
        $company->description = '';
        $company->added_by = $updatedBy;

        $company->save();
    }

    $cafe = Cafe::where('id', '=', $id)->first();
    if (isset($data['city_id'])) {
        $cityID = $data['city_id'];
    } else {
        $cityID = $cafe->city_id;
    }

    if (isset($data['address'])) {
        $address = $data['address'];
    } else {
        $address = $cafe->address;
    }

    if (isset($data['city'])) {
        $city = $data['city'];
    } else {
        $city = $cafe->city;
    }

    if (isset($data['state'])) {
        $state = $data['state'];
    } else {
        $state = $cafe->state;
    }

    if (isset($data['zip'])) {
        $zip = $data['zip'];
    } else {
        $zip = $cafe->zip;
    }

    if (isset($data['location_name'])) {
        $locationName = $data['location_name'];
    } else {
        $locationName = $cafe->location_name;
    }

    if (isset($data['brew_methods'])) {
        $brewMethods = $data['brew_methods'];
    }

    $coordinates = GaodeMaps::geocodeAddress($address, $city, $state);
    $lat = $coordinates['lat'];
    $lng = $coordinates['lng'];

    $cafe->company_id = $company->id;
    if (!$cityID) {
        $cityID = GaodeMaps::findClosestCity($city, $lat, $lng);
    }
    $cafe->city_id = $cityID;
    $cafe->location_name = $locationName != null ? $locationName : '';
    $cafe->address = $address;
    $cafe->city = $city;
    $cafe->state = $state;
    $cafe->zip = $zip;
    $cafe->latitude = $lat;
    $cafe->longitude = $lng;

    if (isset($data['matcha'])) {
        $cafe->matcha = $data['matcha'];
    }

    if (isset($data['tea'])) {
        $cafe->tea = $data['tea'];
    }

    $cafe->save();

    // 更新关联的冲泡方法
    if (isset($data['brew_methods'])) {
        $cafe->brewMethods()->sync(json_decode($brewMethods));
    }

    return $cafe;
}

然后在控制器 CafesController 中调用这个方法,同时将注入依赖调整为 EditCafeRequest

// 更新咖啡店数据
public function putEditCafe($id, EditCafeRequest $request)
{
    $cafe = Cafe::where('id', '=', $id)->with('brewMethods')->first();

    $cafeService = new CafeService();
    $updatedCafe = $cafeService->editCafe($cafe->id, $request->all(), Auth::user()->id);

    $company = Company::where('id', '=', $updatedCafe->company_id)
        ->with('cafes')
        ->first();

    return response()->json($company, 200);
}

这样,就完成了编辑咖啡店和删除咖啡店的所有功能代码编写,运行 npm run dev 重新编译前端资源,直接访问某个咖啡店的编辑链接,如 http://roast.test/#/cafes/5/edit,就可以看到编辑页面了(当然需要登录后才能访问):

将页面拉到最下面,就可以看到删除链接:

点击删除链接,会有一个确认提示框,点击确定即可删除这个咖啡店,并跳转到应用首页:

如果你执行的是更新操作,更新完成后页面会跳转到咖啡店详情页,关于咖啡店详情页,我们将在下一篇教程中进行重构,并将编辑咖啡店的入口链接放到详情页中显示。

注:项目完整代码可在 nonfu/roastapp 中查看。

学院君 has written 1176 articles

Laravel学院院长,终身学习者

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

4 条回复

  1. 学院君 学院君 says:
    @ xyy1994

    可以对单页内部路由进行拆包 实现按需加载 当进入特定路由才加载该路由的 JavaScript 文件 从而减少首屏的加载时间 同时 服务器端还可以通过渲染一部分首屏页面数据 避免造成首页产生过多请求

  2. xyy1994 xyy1994 says:

    请教一下单页面应用会导致js文件过大从而首次加载太慢,有什么好的解决方案么

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