功能模块重构 & CSS 整体优化:新增咖啡店篇


这篇教程我们将来演示新增咖啡店功能的重构,按照上篇教程中的规划,我们会将之前存储咖啡店的 cafes 表一分为三,即 cafes 表、companies 表和 cities 表,所以对应的前端页面和后端处理逻辑都要修改,大的数据表迁移在上篇教程中已经完成,这里我们还有一个微调,在 cafes 表中新增 matchateaadded_by 三个字段,用以适配前端筛选需要。

第一步:数据表迁移

运行如下命令创建一个迁移文件:

php artisan make:migration alter_cafes_add_matcha_tea --table=cafes

编写新生成的迁移类文件代码如下:

class AlterCafesAddMatchaTea extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('cafes', function (Blueprint $table) {
            $table->integer('added_by')->after('zip')->unsigned()->nullable();
            $table->tinyInteger('tea')->after('added_by');
            $table->tinyInteger('matcha')->after('tea');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('cafes', function (Blueprint $table) {
            $table->dropColumn('added_by');
            $table->dropColumn('matcha');
            $table->dropColumn('tea');
        });
    }
}

然后运行 php artisan migrate 让修改生效。

第二步:路由及控制器方法

新增咖啡店的路由之前已经存在了,我们在 routes/api.php 中新增一个公司搜索路由用于在新增咖啡店页面自动提示公司名称:

/*
|-------------------------------------------------------------------------------
| Handles a Company Search
|-------------------------------------------------------------------------------
| URL:            /api/v1/companies/search
| Controller:     API\CompaniesController@getCompanySearch
| Method:         GET
| Description:    Handles a search for a company.
*/
Route::get('/companies/search', 'API\CompaniesController@getCompanySearch');

然后在控制器 CompaniesController 中实现 getCompanySearch 方法:

public function getCompanySearch(Request $request)
{
    $term = $request->input('search');

    $companies = Company::where('name', 'LIKE', '%' . $term . '%')
        ->withCount('cafes')
        ->get();

    return response()->json(['companies' => $companies]);
}

至于对新增咖啡店控制器方法的调整我们放到前端页面重构之后去完成。

第三步:重构新增咖啡店页面组件

接下来,我们直接跳到前端,重构新增咖啡店页面组件 resources/assets/js/pages/NewCafe.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.add-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: 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" v-if="newCompany">
                    <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" v-if="newCompany">
                    <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-if="newCompany">
                    <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-if="newCompany" 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-if="newCompany" 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" class="form-input" v-model="address"
                               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="add-location-button" v-on:click="submitNewCafe()">添加</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: ''
                    }
                }
            }
        },
        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),
            submitNewCafe() {
                if (this.validateNewCafe()) {
                    this.$store.dispatch('addCafe', {
                        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
                    });
                }
            },
            validateNewCafe() {
                let validNewCafeForm = true;

                // 确保 name 字段不为空
                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 = '';
                }

                // 确保网址是有效的 URL
                if (this.website.trim !== '' && !this.website.match(/^((https?):\/\/)?([w|W]{3}\.)?[a-zA-Z0-9\-\.]{3,}\.[a-zA-Z]{2,}(\.[a-zA-Z]{2,})?$/)) {
                    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;
            },
            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 = [];
                this.website = company.website;
            },
            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: ''
                    }
                };
            }
        },
        computed: {
            brewMethods() {
                return this.$store.getters.getBrewMethods;
            }
            ,
            addCafeStatus() {
                return this.$store.getters.getCafeAddStatus;
            }
            ,
            addCafeText() {
                return this.$store.getters.getCafeAddText;
            }
        },
        watch: {
            addCafeStatus() {
                if (this.addCafeStatus === 2) {
                    // 显示添加成功通知
                    EventBus.$emit('show-success', {
                        notification: this.addCafeText
                    });
                    this.clearForm();
                    // 返回列表页
                    this.$router.push({name: 'cafes'});
                }
            }
        }
    }
</script>

我们几乎完全重写了新增咖啡店页面,在这个新页面中,公司相关字段默认只暴露「公司名称」,如果用户输入公司名称会去后台调用搜索 API 并通过下来列表自动提示,如果用户选择下拉列表中已存在的公司,则会将这个新咖啡店与该公司关联,否则会显示出所有公司相关字段,这样提交数据的时候,会插入一个新公司,然后在后台将新咖啡店与新公司关联。其他的逻辑和之前大体相同,只不过现在在新增咖啡店页面一次只能新增一个咖啡店。

由于我们修改了表单页面,对应提交的数据格式也发生了变化,所以需要对 resources/assets/js/modules/cafes.js 中的 addCafe Action 和 resources/assets/js/api/cafe.js 中对应的 API 调用进行修改:

// resources/assets/js/modules/cafes.js
addCafe({commit, state, dispatch}, data) {
   commit('setCafeAddStatus', 1);

   CafeAPI.postAddNewCafe(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_add_pending !== 'undefined') {
               commit('setCafeAddedText', response.data.cafe_add_pending + ' 正在添加中!');
           } else {
               commit('setCafeAddedText', response.data.name + ' 已经添加!');
           }

           commit('setCafeAddStatus', 2);
           commit('setCafeAdded', response.data);

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

// resources/assets/js/api/cafe.js
postAddNewCafe: function (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', brewMethods);
    formData.append('matcha', matcha);
    formData.append('tea', tea);

    return axios.post(ROAST_CONFIG.API_URL + '/cafes',
        formData,
        {
            headers: {
                'Content-Type': 'multipart/form-data'
            }
        }
    );
},

第四步:后端新增咖啡店功能重构

首先,需要修改的是 app/Http/Requests/StoreCafeRequest.php 中的验证规则:

/**
 * 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',
        'tea' => 'boolean',
        'matcha' => 'boolean'
    ];
}

public function messages()
{
    return [
        'company_name.required_without' => '咖啡店所属公司不能为空',
        'address.required' => '咖啡店地址不能为空',
        'city.required' => '咖啡店所在城市不能为空',
        'state.required' => '咖啡店所在省份不能为空',
        'zip.required' => '咖啡店邮编不能为空',
        'zip.regex' => '无效的邮政编码',
        'website.url' => '无效的咖啡店网址',
    ];
}

然后是将控制器中的具体新增咖啡店实现逻辑移到 app/Services/CafeService.php 中:

/**
 * 添加咖啡店到数据库
 *
 * @param array $data 咖啡店数据
 * @param int $addedBy 添加者
 * @return Cafe
 */
public function addCafe($data, $addedBy)
{
    $companyID = isset($data['company_id']) ? $data['company_id'] : '';
    // 如果对应公司不存在,先添加之
    if ($companyID != '') {
        $company = Company::where('id', '=', $companyID)->first();
    } else {
        $company = new Company();
        $company->name = $data['company_name'];
        $company->roaster = $data['company_type'] == 'roaster' ? 1 : 0;
        $company->subscription = isset($data['subscription']) ? $data['subscription'] : 0;
        $company->website = $data['website'];
        $company->logo = '';
        $company->description = '';
        $company->added_by = $addedBy;
        $company->save();
    }

    $address = $data['address'];
    $city = $data['city'];
    $state = $data['state'];
    $zip = $data['zip'];
    $locationName = $data['location_name'];
    $brewMethods = $data['brew_methods'];
    $coordinates = GaodeMaps::geocodeAddress($address, $city, $state);
    $lat = $coordinates['lat'];
    $lng = $coordinates['lng'];
    // 创建新的咖啡店
    $cafe = new Cafe();
    $cafe->company_id = $company->id;
    $cafe->location_name = $locationName != null ? $locationName : '';
    // 根据城市、经纬度从 cities 表获取对应的 city_id
    $cafe->city_id = GaodeMaps::findClosestCity($city, $lat, $lng);
    $cafe->address = $address;
    $cafe->city = $city;
    $cafe->state = $state;
    $cafe->zip = $zip;
    $cafe->latitude = $lat;
    $cafe->longitude = $lng;
    $cafe->added_by = $addedBy;
    $cafe->tea = isset($data['tea']) ? $data['tea'] : 0;
    $cafe->matcha = isset($data['matcha']) ? $data['matcha'] : 0;
    $cafe->save();
    // 保存咖啡店支持的冲泡方法
    $cafe->brewMethods()->sync(json_decode($brewMethods));

    return $cafe;
}

其中,我们通过 GaodeMaps 工具类提供的 findClosestCity 方法获取新增咖啡店对应的 city_id

/**
 * 通过经纬度反查距离最近的城市
 * @param $name
 * @param $latitude
 * @param $longitude
 * @return int|null
 */
public static function findClosestCity($name, $latitude, $longitude)
{
    $cities = City::where('name', 'LIKE', $name . '%')->get();

    // 检查距离信息
    if ($cities && count($cities) == 1) {
        return $cities[0]->id;
    } else {
        // 我们可以对地址进行地理编码获取经纬度
        // 反过来通过对经纬度进行逆地理编码也可以获取地址信息
        $apiKey = config('services.gaode.ws_api_key'); // WebService API Key
        $location = $latitude . ',' . $longitude;
        $url = 'https://restapi.amap.com/v3/geocode/regeo?location=' . $location . '&key=' . $apiKey;
        // 创建 Guzzle HTTP 客户端发起请求
        $client = new Client();

        // 发送请求并获取响应数据
        $regeocodeResponse = $client->get($url)->getBody();
        $regeocodeData = json_decode($regeocodeResponse);
        if (empty($regeocodeData) || $regeocodeData->status == 0) {
            return null;
        }

        if ($cities) {
            foreach ($cities as $city) {
                if ($city->name == $regeocodeData->regeocode->addressComponent->city) {
                    return $city->id;
                }
            }
        }

        $city = new City();
        // 直辖市city字段为空数组
        if (!$regeocodeData->regeocode->addressComponent->city) {
            $city->name = $regeocodeData->regeocode->addressComponent->province;
        } else {
            $city->name = $regeocodeData->regeocode->addressComponent->city;
        }
        $city->slug = $city->name;
        $city->state = $regeocodeData->regeocode->addressComponent->province;
        $city->country = $regeocodeData->regeocode->addressComponent->country;
        $city->save();

        return $city->id;
    }
}

最后我们修改 app/Http/Controllers/API/CafesController.phppostNewCafe 方法如下:

public function postNewCafe(StoreCafeRequest $request)
{
    $cafeService = new CafeService();
    $cafe = $cafeService->addCafe($request->all(), Auth::user()->id);

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

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

非常的简洁明了。

至此,我们的新增咖啡店重构功能已经完成了,运行 npm run dev 重新编译前端资源,访问应用首页 http://roast.test,点击页面右下角的「+」号按钮就可以弹出新增咖啡店表单页面 http://roast.test/#/cafes/new

如果输入公司名称,已经存在的公司会在下拉列表显示出来:

如果选择已存在的公司名称,则会调用组件的 selectCompany 方法建立起新咖啡店与该公司的关联,并且不会显示公司相关的字段了,从而提升了用户体验:

而如果我们选择添加新公司,则会出现公司相关字段让我们填写,这样在提交表单后,后端会先创建公司记录,然后创建咖啡店记录,并将两者关联起来,具体的实现逻辑可以查看 CafeServiceaddCafe 方法:

添加成功后,页面会调整到首页(列表页),并在地图上将新增的咖啡店打上点标记,赶紧去试试这个重构的新增咖啡店功能吧:

下一篇教程,我们将为咖啡店实现编辑和删除功能。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 功能模块重构 & CSS 整体优化:首页篇

>> 下一篇: 功能模块重构 & CSS 整体优化:实现编辑/删除咖啡店功能