咖啡店标签前端输入及显示功能实现


上一篇教程中,我们已经实现咖啡店标签的后端 API 接口用于实现标签的查询、新增和删除,在这篇教程中,我们来实现标签的前端输入、提交和显示。

第一步:新增标签输入组件

我们将通过创建一个 Vue 组件来实现标签的输入,使用组件的好处是方便标签输入组件的复用,在 resources/assets/js/components/global 目录下新增一个 forms 子目录,然后在 forms 目录下创建 TagsInput.vue 组件并初始化代码框架如下:

<style lang="scss">

</style>

<template>
    <div class="tags-input">

    </div>
</template>

<script>
    export default {

    }
</script>

第二步:实现标签输入组件

然后我们来编写这个组件的实现代码,在编写代码之前,先理清需要实现的功能:

  • 输入标签时对已存在标签进行自动提示/补全
  • 保存标签到数据库
  • 避免添加重复标签
  • 确保前端输入标签格式与后端处理格式一致
  • 优化标签输入框方便用户新增和删除标签,并且用户可以在提交之前删除标签

考虑好以上功能实现方式后,和之前挤牙膏式的教程风格不同,我们这次一次给出组件所有实现代码:

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

    div.tags-input-container {
        position: relative;

        div.tags-input {
            display: block;
            -webkit-box-sizing: border-box;
            box-sizing: border-box;
            width: 100%;
            height: auto;
            min-height: 100px;
            padding-top: 4px;
            border: 1px solid #cacaca;
            border-radius: 0;
            background-color: #FFFFFF;
            -webkit-box-shadow: inset 0 1px 2px rgba(17, 17, 17, 0.1);
            box-shadow: inset 0 1px 2px rgba(17, 17, 17, 0.1);
            font-family: inherit;
            font-size: 1rem;
            font-weight: normal;
            line-height: 1.5;
            color: #111111;

            div.selected-tag {
                border: 1px solid $dark-color;
                background: $highlight-color;
                font-size: 18px;
                color: $dark-color;
                padding: 3px;
                margin: 5px;
                float: left;
                border-radius: 3px;

                span.remove-tag {
                    margin: 0 0 0 5px;
                    padding: 0;
                    border: none;
                    background: none;
                    cursor: pointer;
                    vertical-align: middle;
                    color: $dark-color;
                }
            }

            input[type="text"].new-tag-input {
                border: 0px;
                margin: 0px;
                float: left;
                width: auto;
                min-width: 100px;
                -webkit-box-shadow: none;
                box-shadow: none;
                margin: 5px;

                &.duplicate-warning {
                    color: red;
                }

                &:focus {
                    box-shadow: none;
                }
            }
        }

        div.tag-autocomplete {
            position: absolute;
            background-color: white;
            width: 100%;
            padding: 5px 0;
            z-index: 99999;
            border: 1px solid rgba(0, 0, 0, 0.2);
            -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
            -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
            box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);

            div.tag-search-result {
                padding: 5px 10px;
                cursor: pointer;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                color: $dark-color;
                font-size: 14px;
                background-color: white;

                &:hover {
                    background-color: $highlight-color;
                }
                &.selected-search-index {
                    background-color: $highlight-color;
                }
            }
        }
    }
</style>

<template>
    <div class="tags-input-container">
        <label>Tags</label>
        <div class="tags-input" v-on:click="focusTagInput()">
            <div class="selected-tag" v-for="(selectedTag, key) in tagsArray">
                {{ selectedTag }}
                <span class="remove-tag" v-on:click="removeTag( key )">×</span>
            </div>
            <input type="text" v-bind:id="unique" class="new-tag-input" v-model="currentTag" v-on:keyup="searchTags"
                   v-on:keyup.enter="addNewTag" v-on:keydown.up="changeIndex( 'up' )" v-on:keydown.delete="handleDelete"
                   v-on:keydown.down="changeIndex( 'down' )" v-bind:class="{ 'duplicate-warning' : duplicateFlag }"
                   placeholder="Add a tag"/>
        </div>
        <div class="tag-autocomplete" v-show="showAutocomplete">
            <div class="tag-search-result" v-for="(tag, key) in tagSearchResults"
                 v-bind:class="{ 'selected-search-index' : searchSelectedIndex === key }"
                 v-on:click="selectTag( tag.tag )">{{ tag.tag }}
            </div>
        </div>
    </div>
</template>

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

    export default {
        props: ['unique'],
        data() {
            return {
                currentTag: '',
                tagsArray: [],
                tagSearchResults: [],
                duplicateFlag: false,
                searchSelectedIndex: -1,
                pauseSearch: false
            }
        },
        mounted() {
            EventBus.$on('clear-tags', function (unique) {
                this.currentTag = '';
                this.tagsArray = [];
                this.tagSearchResults = [];
                this.duplicateFlag = false;
                this.searchSelectedIndex = -1;
                this.pauseSearch = false;
            }.bind(this));
        },
        computed: {
            showAutocomplete() {
                return this.tagSearchResults.length !== 0;
            }
        },
        methods: {
            // 从下拉列表选择自动提示标签
            selectTag(tag) {
                // 检查标签数组中是否已存在该标签
                if (!this.checkDuplicates(tag)) {
                    tag = this.cleanTagName(tag);
                    this.tagsArray.push(tag);
                    // 在事件总线中广播标签值变动
                    EventBus.$emit('tags-edited', {unique: this.unique, tags: this.tagsArray});
                    // 重置标签输入框中的标签
                    this.resetInputs();
                } else {
                    this.duplicateFlag = true;
                }
            },

            // 新增标签
            addNewTag() {
                // 判断输入标签是否已存在
                if (!this.checkDuplicates(this.currentTag)) {
                    var newTagName = this.cleanTagName(this.currentTag);
                    this.tagsArray.push(newTagName);
                    // 在事件总线中广播标签值变动
                    EventBus.$emit('tags-edited', {unique: this.unique, tags: this.tagsArray});
                    this.resetInputs();
                } else {
                    this.duplicateFlag = true;
                }
            },

            // 删除标签
            removeTag(tagIndex) {
                // 从标签数组中删除当前标签
                this.tagsArray.splice(tagIndex, 1);
                // 在事件总线中广播标签值变动
                EventBus.$emit('tags-edited', {unique: this.unique, tags: this.tagsArray});
            },

            // 从下拉列表中选择自动提示的标签
            changeIndex(direction) {
                this.pauseSearch = true;

                if (direction === 'up' && (this.searchSelectedIndex - 1 > -1)) {
                    this.searchSelectedIndex = this.searchSelectedIndex - 1;
                    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
                }

                if (direction === 'down' && (this.searchSelectedIndex + 1 <= this.tagSearchResults.length - 1)) {
                    this.searchSelectedIndex = this.searchSelectedIndex + 1;
                    this.currentTag = this.tagSearchResults[this.searchSelectedIndex].tag;
                }
            },

            // 根据搜索词查询后端自动提示 API 接口并将结果展示到下拉列表
            searchTags() {
                if (this.currentTag.length > 2 && !this.pauseSearch) {
                    this.searchSelectedIndex = -1;
                    axios.get(ROAST_CONFIG.API_URL + '/tags', {
                        params: {
                            search: this.currentTag
                        }
                    }).then(function (response) {
                        this.tagSearchResults = response.data;
                    }.bind(this));
                }
            },

            // 检查标签是否重复
            checkDuplicates(tagName) {
                tagName = this.cleanTagName(tagName);
                return this.tagsArray.indexOf(tagName) > -1;
            },

            // 清理标签,移除不必要的空格和字符
            cleanTagName(tagName) {
                var cleanTag = tagName.trim();
                return cleanTag;
            },

            // 重置标签输入框
            resetInputs() {
                this.currentTag = '';
                this.tagSearchResults = [];
                this.duplicateFlag = false;
                this.searchSelectedIndex = -1;
                this.pauseSearch = false;
            },

            // 将焦点移到标签输入框
            focusTagInput() {
                document.getElementById(this.unique).focus();
            },

            // 处理标签删除
            handleDelete() {
                this.duplicateFlag = false;
                this.pauseSearch = false;
                this.searchSelectedIndex = -1;

                // 如果当前标签没有任何数据则移除最后一个标签
                if (this.currentTag.length === 0) {
                    this.tagsArray.splice(this.tagsArray.length - 1, 1);
                    EventBus.$emit('tags-edited', {unique: this.unique, tags: this.tagsArray});
                }
            }
        }
    }
</script>

有了前面的基础,相信你看起来应该没什么难度,有什么问题,可以在下面的评论框中与我讨论。

第三步:在新增咖啡店页面引入标签输入组件

接下来,我们在 resources/assets/js/pages/NewCafe.vue 组件中引入上面创建的标签输入组件:

import TagsInput from '../components/global/forms/TagsInput.vue';
import { EventBus } from '../event-bus.js';  

在导出模块中定义一个 components 属性:

components: {
    TagsInput
}

然后在 template 模板中冲泡方法输入框下面引入标签输入组件模板:

<div class="large-12 medium-12 small-12 cell">
    <tags-input v-bind:unique="key"></tags-input>
</div>

由于我们将标签数据和新增咖啡店数据一起提交到后端,所以需要修改 addLocation 方法,新增 tags 字段:

this.locations.push({
    name: '',
    address: '',
    city: '',
    state: '',
    zip: '',
    methodsAvailable: [],
    tags: ''
});

然后在 mounted 中监听全局 tags-edited 事件,一旦标签输入框有变动,则修改待提交的标签值:

mounted() {
    EventBus.$on('tags-edited', function (tagsAdded) {
        this.locations[tagsAdded.unique].tags = tagsAdded.tags;
    }.bind(this));
},

最后,修改新增表单的 clearForm 方法,清理表单时,同时清理标签输入框的值:

EventBus.$emit('clear-tags');

至此,前端标签输入和提交功能已经完成了,运行 npm run dev 重新编译前端资源,访问新增咖啡店页面 http://roast.test/#/cafes/new,就可以输入标签了:

要体验标签自动提示需要先在 tags 表中插入一些数据:

提交带标签的咖啡店数据并保存成功后,就可以在相应的数据表中看到新增的数据了,也可以在表单提交成功的返回数据中看到标签数据。

第四步:在咖啡店详情页显示标签

最后,我们想要在咖啡店详情页 resources/assets/js/pages/Cafe.vue 中查看标签数据,为此,需要在 toggle-like 模板之后插入如下标签渲染代码:

<div class="tags-container">
  <div class="grid-x grid-padding-x">
    <div class="large-12 medium-12 small-12 cell">
      <span class="tag" v-for="tag in cafe.tags">#{{ tag.tag }}</span>
    </div>
  </div>
</div>

同时在 style 中为标签定义样式:

div.tags-container {
    max-width: 700px;
    margin: auto;
    text-align: center;
    margin-top: 30px;

    span.tag {
        color: $dark-color;
        font-family: 'Josefin Sans', sans-serif;
        margin-right: 20px;
        display: inline-block;
        line-height: 20px;
    }
}

然后修改后端控制器 app/Http/Controllers/API/CafesController.phpgetCafe 方法,让其在返回数据中包含标签数据:

$cafe = Cafe::where('id', '=', $id)
        ->with('brewMethods')
        ->with('userLike')
        ->with('tags')
        ->first();

接下来运行 npm run dev 重新编译前端资源,访问咖啡店详情页:

就可以看到包含标签信息的咖啡店页面了。

注:完整代码已发布到 GitHub 上:nonfu/roastapp


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

<< 上一篇: 咖啡店标签后端 API 接口功能实现

>> 下一篇: 通过 Vue Mixins 在前端首页对咖啡店进行过滤筛选