咖啡店标签前端输入及显示功能实现
在上一篇教程中,我们已经实现咖啡店标签的后端 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.php
的 getCafe
方法,让其在返回数据中包含标签数据:
$cafe = Cafe::where('id', '=', $id)
->with('brewMethods')
->with('userLike')
->with('tags')
->first();
接下来运行 npm run dev
重新编译前端资源,访问咖啡店详情页:
就可以看到包含标签信息的咖啡店页面了。
注:完整代码已发布到 GitHub 上:nonfu/roastapp
4 Comments
在编写vue文件的时候,有什么工具是可以即时预览效果的呢 我用VS编辑器,开发的时候对于css代码毫无头绪 只有在浏览器中才能看到效果
就是在浏览器中预览 不然还想怎样
捉个虫:
v-on:click="selectTag( tag.tag )">{{ tag.tag }}
这里的tag.tag应该改成tag.name,从后端返回的参数为name。新增组件
NewCafe.vue
中的这段代码,key
如何定义?